Skip to Content

← All archived runs

Run: demo-view-as-user-list

run.md

Run: demo-view-as-user-list

  • branch: claude/pipeline-demo-view-as-user-list-s0a4sc
  • pr: #861

02_define/output/spec.md

Spec: A plain, role-labelled user list in the demo View As switcher

  • slug: demo-view-as-user-list
  • personas: Admin
  • touches: apps/web/lib/view-as-switcher-data.ts, apps/web/lib/view-as-switcher-data.test.ts, apps/web/lib/view-as.ts, apps/web/components/view-as/, apps/web/app/(app)/layout.tsx, apps/docs/app/technical/demo-environment/
  • complexity: standard

Problem

The demo tenant's View As switcher pins one account per persona by matching the address convention demo+<role>@…, so a presenter's click always lands on the seeded storyline account rather than on whichever filler login sorts first. demo-roster-provisioning (#855) moved every seat to jamie.nisbet+<lastname>.<role>@sustentus.com, which matches that prefix for no account at all. The pin therefore falls through to its "first user alphabetically" fallback for every role — exactly the failure the pinning was built to prevent, and the switcher is now actively worse than no pin, because it presents an arbitrary account as though it were the deliberate one. A presenter two clicks into an empty account in front of a vendor partner is the demo failing at the moment it matters, which is what the Q2-2026 Objective 1 work (Establish Product-Market Fit with Vendor Partners, under Refine the bridge) rests on the demo doing well.

With a fixed roster there is no longer a "default" account per role to pin at all: all twenty seats are deliberate, each is a named person with a seeded world, and the presenter should pick the person they mean by name.

Proposed change

On a tenant flagged isDemo, the switcher stops offering pinned personas and bare-role emulation and becomes a single direct-user selector: every Clerk-backed emulatable user in the tenant, listed flat, each entry showing the person's name and their role, ordered by role then by name. The presenter banner keeps its presenter treatment and carries the identical list, so a walkthrough still moves person to person from any page.

Tenants without isDemo are untouched — bare-role personas plus per-role user submenus, exactly as today.

The shape. resolveSwitcherData in apps/web/lib/view-as-switcher-data.ts stays the single pure decision point; the change is to what it returns.

  • ViewAsSwitcherData.pinned is replaced by a flat users: ViewAsUserOption[]. pinnedForRole and the demo+<role>@… convention it encodes are deleted outright — nothing reads an email address to decide anything any more.
  • Demo tenant (presenter: true): users is every supplied user whose role is in EMULATABLE_PERSONAS, sorted by role in the canonical USER_ROLES order (sales, csm, sdm, expert, vendor, customer) and by name (localeCompare) within a role. personas is [] and groups is [] — a demo presenter never opens a submenu and never emulates a bare role.
  • Non-demo tenant (presenter: false): unchanged — personas is every EMULATABLE_PERSONAS entry, groups is today's per-role user grouping, and users is [].
  • Admin-role users are excluded, on both kinds of tenant, exactly as today: EMULATABLE_PERSONAS already omits admin, so the demo list is 19 of the roster's 20 seats. The presenter already holds admin — entering the admin seat would show them the view they just left.
  • The empty-directory guard stays. A tenant flagged isDemo whose directory yields no emulatable Clerk-backed user falls back to the non-demo shape, so an unprovisioned demo tenant keeps a working switcher instead of a dead, empty menu. The condition moves from pinned.length === 0 to the equivalent test on the new list.
  • PERSONA_LABELS stays and is the role label rendered against each name.

The two consumers move together. The sidebar switcher (apps/web/components/view-as/view-as-switcher.tsx), the banner quick-switch (view-as-banner.tsx via presenter-persona-items.tsx) and the layout that feeds the banner (apps/web/app/(app)/layout.tsx, which passes viewAsData.pinned today) all read the new users field. PresenterPersonaItems already renders name over role label, which is the required entry shape; it now receives the full list. Nineteen entries do not fit a dropdown, so the presenter list scrolls within the menu the way the existing per-role submenus already do.

Nothing in the emulation path changes: enterViewAs still receives { kind: "user", clerkUserId, role } from each entry, and the admin-only check, own-tenant lookup, cookie binding and audit entries are untouched.

Acceptance criteria

  • On a tenant flagged isDemo with the roster provisioned, an admin's View As menu shows a single flat list of every Clerk-backed emulatable user in the tenant — 19 of the roster's 20 seats, the admin seat excluded — each entry showing the person's name and their role label.
  • Selecting any entry enters that user's emulation directly, with no submenu in the way: the demo shape carries no groups, no pinned entry and no bare-role entry.
  • The demo list is ordered deterministically: by role in the canonical USER_ROLES order (sales, csm, sdm, expert, vendor, customer), then by name within a role.
  • The presenter banner's "Switch persona" menu offers that same list in the same order, and keeps its way back to the admin's own view.
  • A tenant without isDemo renders exactly as it does today — bare-role personas for all six emulatable roles plus per-role user submenus, and no flat user list.
  • A tenant flagged isDemo whose directory yields no emulatable Clerk-backed user renders the non-demo shape rather than an empty menu.
  • resolveSwitcherData returns null for any real role other than admin and for a signed-out request, on either kind of tenant — a non-admin receives no switcher data at all.
  • Entering and leaving an emulation writes the same audit entries as today; no file under apps/web/lib/actions/view-as.ts or apps/web/lib/auth.ts changes.
  • apps/web/lib/view-as-switcher-data.test.ts builds its fixtures on the roster convention and covers every rule above; no test — and no source comment — asserts on or describes demo+<role>@… as current behaviour.
  • The docs demo-environment page no longer carries pinning as current behaviour or the pin fallback as a known gap (Ship's docs-sync, in this PR).

Out of scope

  • The emulation gate itself — the admin-only check, the same-org check, the cookie's binding to the emulating admin, and the audit trail all stay exactly as they are.
  • Emulating anyone outside the current tenant, or any change to what an emulated session may do.
  • The seeder and the roster — those are demo-roster-seeder and the shipped demo-roster-provisioning.
  • Including admin-role users in the demo list, on either kind of tenant. Decided against: the presenter already holds admin.
  • Role section headers, separators or grouping UI on the demo list — one flat list, the role shown against each name.
  • Search, filtering or a type-ahead in the switcher. Nineteen scrollable entries is the target size; the roster does not grow on its own.
  • Any change to the non-demo tenant's switcher, its submenus or its bare-role personas.

Open questions

  • none

Context budget: within band. No scope.md behind this batch — the demo-roster-logins cut was made directly from Jamie's request of 2026-08-19 (recorded in intake/demo-roster-logins/breakdown.md), as demo-roster-provisioning (#855) was before it, so there are no Q-n traces to carry.

03_build/output/notes.md

Build notes: demo-view-as-user-list

  • commits: feat: demo-view-as-user-list — a plain, role-labelled user list in the demo switcher

What changed

  • apps/web/lib/view-as-switcher-data.ts: pinnedForRole and the demo+<role>@… convention it encoded are deleted; ViewAsSwitcherData.pinned becomes a flat users. On a demo tenant the shape is now presenter: true with every emulatable user in users, and personas/groups both empty. The flat list is groupByRole(users).flatMap(…), so role order and name-within-role come from the one existing ordering rule rather than a second sort. Nothing in the module reads an email address any more.
  • Same file, the empty-directory guard: the fallback condition moves from pinned.length === 0 to the equivalent test on the flat list, so an isDemo tenant whose directory yields no emulatable Clerk-backed user still renders the standard shape instead of a dead menu.
  • apps/web/components/view-as/presenter-persona-items.tsx: takes users instead of personas, and wraps its items in max-h-72 overflow-y-auto — the list is now the whole roster (19 seats), not a pick of five, so both menus that hold it need to scroll. One scroll container here rather than one per consumer.
  • apps/web/components/view-as/view-as-switcher.tsx and view-as-banner.tsx: both read the new users field. The switcher's data.groups block is unchanged and simply renders nothing on a demo tenant, since groups is empty there.
  • apps/web/app/(app)/layout.tsx: feeds the banner viewAsData.users in place of .pinned.
  • apps/web/lib/view-as-switcher-data.test.ts: rewritten against the roster convention (jamie.nisbet+<lastname>.<role>@sustentus.com), asserting the criteria rather than the implementation. The fixtures carry an admin seat and two customers so "admin excluded" and "role order, then name" are each distinguishable from the other rules.

Nothing under apps/web/lib/actions/view-as.ts or apps/web/lib/auth.ts was touched: each entry still sends the same { kind: "user", clerkUserId, role } the gate already validates.

Acceptance criteria status

  • Demo tenant lists every Clerk-backed emulatable user, admin seat excluded — EMULATABLE_PERSONAS omits admin, and groupByRole builds only from it, so the admin seat never enters the list.
  • Entries enter emulation directly, no submenu — the demo shape returns groups: [] and personas: [], and the switcher's submenu block is driven off groups.
  • Ordered by role in USER_ROLES order, then by name — asserted directly in view-as-switcher-data.test.ts.
  • The presenter banner offers the same list and keeps "Back to admin" — both surfaces render the same PresenterPersonaItems off the same users array; the exit button is untouched.
  • A non-isDemo tenant is unchanged — the non-demo branch returns the same personas + groups it always did, with users: []; covered by two tests.
  • A demo tenant with no emulatable user falls back to the standard shape — covered by two tests (empty directory, and a directory holding only the admin seat).
  • resolveSwitcherData returns null for a non-admin and for a signed-out request.
  • Emulation audit entries unchanged — no file under lib/actions/ or lib/auth.ts is in the diff.
  • Tests rebuilt on the roster convention; no test or source comment describes demo+<role>@… as current behaviour.
  • The docs demo-environment page still describes pinning as current behaviour and the pin fallback as a known gap (apps/docs/app/technical/demo-environment/page.mdx, ~L203) — that page is Ship's docs-sync job, in this PR.

The first push failed Quality on two of the new assertions, not on the code: sorting is on the display name the switcher renders ("Anneke Vos"), so the first name leads, and I had written the two multi-customer expectations as though it sorted by surname. The implementation was right — it is the pre-existing a.name.localeCompare(b.name), and "by name" is what the spec asks for — so the fix corrected the expectations and left the behaviour alone. Writing the tests from the criteria rather than from the implementation is what surfaced the assumption.

Notes for Verify

  • Criteria 1, 2 and 4 are rendering outcomes, and this repo has no component test tier — the operator smoke on the Vercel preview owns them. Smoke against a tenant that has actually had pnpm demo:roster run: without the roster provisioned, the directory yields no Clerk-backed user and the tenant correctly renders the standard switcher, which looks like the feature failing when it is the fallback working.
  • The presenter list scrolls inside the dropdown via a plain div wrapper. Radix tracks menu items through its collection context rather than DOM adjacency, so arrow-key navigation and typeahead should still walk all 19 entries — worth a keyboard pass on the preview, since that is the one thing the wrapper could plausibly break.
  • The demo tenant has ten customers among its twenty seats, so the "Customer" role label repeats down most of the list. That is the spec's shape (name plus role, one flat list), not a defect.

Context budget: within band.

04_verify/output/verify.md

Verify: demo-view-as-user-list

  • production-readiness: not required — no DB, migration, env var, payment or route-policy change in the diff. The tenant-scoped directory read (userService.listTenantDirectory) lives in apps/web/lib/view-as.ts, which the spec listed under touches: but which needed no change and is not in the diff.
  • code-review: medium (spec complexity: standard). Three findings — one fixed on branch, two recorded below. The CI Review diff against CONVENTIONS.md check is skipped on this PR, so the review was run here rather than triaged from CI.
  • security-review: run — no HIGH or MEDIUM findings. The diff is presentation-layer: the admin-only early return in resolveSwitcherData is intact, enterViewAs (admin check, own-tenant lookup, cookie binding, audit) is untouched, and the emulatable set is byte-for-byte the same population groupByRole produced before — every user now in the flat list was already reachable through the per-role submenus. Email exposure on the demo tenant decreases, since the submenus that rendered addresses are gone.
  • playwright: TODO — manual DoD smoke performed instead.

DoD smoke (on the preview — each line says who verified it)

Agent-run (no preview credentials, so this is the honest limit of what I could demonstrate):

  • The preview responds and gates the app shell — GET / returns 307 to /sign-in?redirect_url=%2F, so an unauthenticated request reaches no switcher data at all (agent).
  • resolveSwitcherData returns null for any real role other than admin and for a signed-out request — asserted in view-as-switcher-data.test.ts, green in the Quality run on 367a612 (agent).
  • Ordering is by role in canonical USER_ROLES order then by display name — asserted directly in the unit suite, including the two-customer case (agent).
  • A non-isDemo tenant keeps today's shape, and a demo tenant with no emulatable Clerk-backed user falls back to it — four assertions across both cases, green (agent).
  • Emulation audit entries unchanged — apps/web/lib/actions/view-as.ts and lib/auth.ts are absent from the diff; traced by file, not inferred (agent).

Operator-demonstrated — outstanding, this stage is not complete without them:

  • The demo list on the preview: 19 seats, admin excluded, each showing name and role, ordered by role then name, no submenus (operator)
  • Selecting an entry enters that person's emulation directly; the banner's "Switch persona" menu shows the same list in the same order, and "Back to admin" returns (operator)
  • Keyboard pass over the presenter list — arrow keys and typeahead reach all 19 entries after the wrapper removal below (operator)
  • auth (Clerk): the affected personas still sign in and reach their dashboards (operator)
  • payments: not touched by this diff — nothing to test
  • notifications: none expected — the diff fires none, and adds no notification path

The demo tenant used for the smoke must have had pnpm demo:roster run against it. Without the roster there are no Clerk-backed users, the fallback correctly renders the standard switcher, and that is indistinguishable from the feature not working.

Findings & cleanup

  • Redundant scroll wrapper, invalid ARIA (presenter-persona-items.tsx) — fixed on branch (367a612). Build added a <div className="max-h-72 overflow-y-auto"> around the items, which nested 19 menuitems under a generic element inside Radix's role="menu". It was also unnecessary: DropdownMenuContent in packages/ui already carries max-h-(--radix-dropdown-menu-content-available-height) and overflow-y-auto, so the menu already scrolled — and capped to the viewport rather than a fixed 18rem, which is the better behaviour. Reverted to a fragment.

  • The demo list is the whole tenant directory, not the roster (view-as-switcher-data.ts) — needs an owner decision; not fixed here. provisionCustomerForOrg (apps/web/app/(app)/service-leads/create/provision-customer.ts, called from the lead-create form) creates a real Clerk user and customer row in the current tenant during ordinary vendor use. On the demo tenant those accounts join the presenter's list, alphabetically interleaved with the ten roster customers and visually identical to them — a presenter can pick one and land in an account with no seeded world.

    This is not a regression and not a defect against the spec: the implementation does exactly what the spec says ("every Clerk-backed emulatable user in the tenant"), and on main today the dead pin already falls through to "first user alphabetically", which is strictly worse. But it does mean the scope's premise — "every seat is deliberate" — holds only for a tenant that has been reset and re-provisioned, not for one that has been demoed against. Fixing it needs a way to tell a roster seat from a provisioned one, and the obvious candidate (matching the address convention) is the exact mechanism this scope deleted. That is a Define-level decision, not a Build one, so I have left the behaviour as specified rather than invent a rule.

  • Deactivated users appear in the list (listTenantDirectory) — recorded, not fixed. The query filters isDeleted: { $ne: true } but selects isActive without filtering on it, so a deactivated Clerk-backed user is offered for emulation. This is pre-existing and unchanged — it was equally true of the per-role submenus before this PR, and remains true of them on non-demo tenants, where criterion 5 requires today's behaviour exactly. Adding an isActive filter would change behaviour the spec did not ask to change, on both tenant kinds.

  • apps/docs demo-environment page still describes pinning as current behaviour and the pin fallback as a known gap (~L203). Spec criterion 10, deliberately Ship's docs-sync job in this same PR — the one acceptance criterion still unticked.

Context budget: within band. One file outside the Inputs table was read to check a review claim — provision-customer.ts — because the finding could not be confirmed or dismissed without it.

05_ship/output/changelog.md


title: Pick a demo persona by name, from one list date: 2026-08-19T14:15:00Z personas: [admin] slug: demo-view-as-user-list pr: https://github.com/sustentus/sustentus/pull/861

Pick a demo persona by name, from one list

On a demo tenant, View As now shows everyone in one plain list — each person's name with their role beside it, ordered by role and then by name. You pick the person you mean, rather than a role that stands in for them, and there is no submenu to open first. The banner you get while presenting carries the same list, so you can move from one person to the next from any page.

Before this, the switcher pinned a single account per role and kept the rest behind submenus. The pin recognised accounts by an older address format that the fixed demo roster replaced, so it had quietly stopped finding the account it was meant to — and fell back to whichever account came first alphabetically. That could put you two clicks into an empty account with a client watching.

Nothing about access changed. View As is still admin-only, still limited to your own organisation, and entering and leaving a session is still recorded exactly as before. Tenants that aren't demo tenants are untouched.

05_ship/output/investor-update.md

A demo presenter picks the person they mean, by name

Who it's for: Admins running a demo What shipped: On a demo tenant, View As is now one list of everyone — name and role, no submenus. Why it matters: The old pin no longer resolved, so a walkthrough could open an empty account in front of a prospect. Serves Refine the bridge → Q2-2026 Objective 1, Establish Product-Market Fit with Vendor Partners.

Permissions are unchanged.

Dig deeper: https://github.com/sustentus/sustentus/pull/861 · https://help.sustentus.com/changelog/2026-08-19-demo-view-as-user-list

05_ship/output/release.md

Ship: demo-view-as-user-list

  • pr: #861 (https://github.com/sustentus/sustentus/pull/861) · merge: authorised — Ready to merge ticked by Jamie; this commit rides the squash
  • CI: green on b60bf6b — Quality Project success (format, lint, typecheck, 107 tests), the three advisory pipeline checks success, Review diff against CONVENTIONS.md skipped by its own path filter. Two red rounds earlier in the run, both mine and both fixed on branch: two test assertions that ordered by surname when the sort is on the display name (60e3135), and the invalid-ARIA scroll wrapper Verify's code review caught (367a612).
  • technical docs: apps/docs/app/technical/demo-environment — the "Presenter persona switching" section now describes the flat list, its ordering, the admin-seat exclusion and the empty-directory fallback; the paragraph carrying the demo+<role>@… pin and its alphabetical fallback as a known gap is deleted, because this change closes it.
  • business docs: apps/docs/app/business/roles — the Admin View As section's demo-tenant paragraph described the switcher as pinning storyline personas; it now describes the by-name list.
  • release notes: both — changelog entry apps/help/app/changelog/2026-08-19-demo-view-as-user-list (personas [admin], following the 2026-08-17-demo-reset-audit-and-cron precedent that demo changes are announced to admins) and the ship note, 56 words against the 60-word cap, both Dig deeper links filled with real URLs.
  • sent: queued — ship-note.yaml fires on the squash-merge and emails the note to #product-update. Not sent at the time this file was written.

Acceptance check (vs spec)

  • Demo tenant shows one flat list of every Clerk-backed emulatable user, admin seat excluded — structural (EMULATABLE_PERSONAS omits admin); unit-asserted
  • Entries enter emulation directly, no submenu — groups: [] and personas: [] on demo; unit-asserted
  • Ordered by role in USER_ROLES order, then by name — unit-asserted, including the two-customer case
  • The presenter banner offers the same list and keeps "Back to admin" — both surfaces render one PresenterPersonaItems off one users array; traced in the diff
  • A non-isDemo tenant is unchanged — unit-asserted, two cases
  • A demo tenant with no emulatable user falls back to the standard shape — unit-asserted, two cases
  • resolveSwitcherData returns null for a non-admin and a signed-out request — unit-asserted
  • Emulation audit entries unchanged — lib/actions/view-as.ts and lib/auth.ts absent from the diff
  • Tests rebuilt on the roster convention; no test or source comment describes demo+<role>@… as current behaviour
  • The docs demo-environment page no longer carries pinning as current behaviour or the pin fallback as a known gap — done in this stage, above

What this ships without

The operator DoD smoke was never performed. Verify recorded four signed-in checks as outstanding — the 19-seat list on a provisioned demo tenant, entering a person and returning via the banner, a keyboard pass over the list, and persona sign-in — and they are still outstanding at merge. Jamie called ship with them open and ticked Ready to merge, which is his call to make; this line records what that authorisation covers so the record is not read as "smoked and passed". Everything ticked above rests on the unit suite and on tracing the diff, which is what an agent without preview credentials can honestly demonstrate. Nothing in this change has been seen rendering in a browser.

Two findings from Verify ride to main unresolved, both deliberately:

  • The demo list is the tenant's directory, not the roster — customers provisioned through the lead-create flow appear among the roster's people and look identical. As specified, and better than the dead pin it replaces, but the scope's "every seat is deliberate" premise holds only on a freshly reset tenant. Now documented on the demo-environment page rather than left implicit; a roster-only list would be a Define change.
  • listTenantDirectory does not filter isActive, so deactivated users are emulatable. Pre-existing, unchanged, and equally true of the non-demo submenus.

Context budget: within band.