Skip to content

Multi-organization membership - #433

Merged
jhodapp merged 17 commits into
mainfrom
feat/multi-org-membership
Aug 7, 2026
Merged

Multi-organization membership#433
jhodapp merged 17 commits into
mainfrom
feat/multi-org-membership

Conversation

@jhodapp

@jhodapp jhodapp commented Aug 6, 2026

Copy link
Copy Markdown
Member

Description

Frontend for multi-organization membership: adds an existing user to an organization with an explicit role and an optional pre-assigned coach, and fixes the cross-organization leaks that became reachable once a user could belong to two organizations.

Pairs with refactor-group/refactor-platform-rs#376. They must land together: the session hooks now send an organization_id the backend only accepts after that PR merges.

Changes

  • add-member-dialog.tsx becomes two modes, gated on isAdminOrSuperAdmin: Create new member (unchanged) and Add existing member (find by email, confirm, pick a role, add). Copy states the shared-account semantics, since an org admin is less likely than a SuperAdmin to reason about them.
  • A Clear button discards a found user, for when the wrong person comes back.
  • Both modes can pre-assign a coach. The coach goes in the same request, so a failed assignment means no membership and no welcome email rather than a half-created member who was already emailed.
  • Session hooks take an optional organizationId, included in the request and in the SWR key. Without it in the key, switching organizations serves the previous one's cached list, which looks exactly like the bug being fixed.
  • organization-switcher.tsx derives avatar initials from the selected organization instead of a hardcoded "RG", at all three render sites. Internal capitals count as word boundaries, so "BigTable" reads "BT" and "Refactor Group" reads "RG".
  • use-todays-sessions.ts passes the selected organization, fixing the Upcoming Session and Goals Overview cards.
  • Removed the dead useUserList() / UserApi.list() that called a nonexistent GET /users.

Screenshots / Videos Showing UI Changes (if applicable)

If you're only 1 organization admin user
Screenshot 2026-08-07 at 18 06 10

If you're an organization admin of 2 or more organizations or a super admin
Screenshot 2026-08-07 at 18 06 36

...you can add existing members to your organization
Screenshot 2026-08-07 at 18 06 48

They'll be able to switch between these organizations then when logged in.

Testing Strategy

npm run lint (0 errors, 9 pre-existing warnings), npx tsc --noEmit clean, npm test at 156 files / 1671 tests.

Two live Playwright specs against a real backend and Chrome, both LIVE_E2E=1 gated:

  • multi-org-live.spec.ts walks the whole driving use case. Not idempotent, needs a fresh database.
  • multi-org-scoping-live.spec.ts is read-only and repeatable: it pins the avatar initials, that session requests carry the selected organization and refetch on switch, and that neither the Upcoming/Previous tabs nor the Upcoming Session card show another organization's sessions.

Every new assertion was sabotage-checked. Two of those checks caught real gaps rather than confirming a guess:

  • Keeping organization_id in the request but removing it from the SWR key left the request-shape test passing while only the cache test failed.
  • The live run found the Upcoming Session card still bleeding after the tabs were fixed, because use-todays-sessions was a separate unscoped call site. Its existing unit test was named "should fetch sessions from all organizations", pinning the old behavior as correct.

Concerns

  • Coordinated deploy required. Shipping this before the backend means organization_id is rejected as an unknown field.
  • A brand-new organization's admin will find the lookup returns nothing, because they share no administered organization with anyone yet. Pinned as a test so a future scope change is deliberate. Discussion is in the backend PR.
  • The Assigned Actions card still shows actions from every organization. It needs GET /users/{user_id}/actions to gain an organization_id first, tracked in Scope remaining user-scoped read endpoints by organization (actions, goals, coaching-relationships) refactor-platform-rs#374.
  • useUserList was deleted; the members page uses the org-scoped hook of the same name from lib/api/organizations/users.ts, which is easy to confuse when reviewing.

jhodapp added 8 commits August 5, 2026 15:53
Adds the UI for the new backend capability: an admin can grant an existing
Refactor account membership of another organization, and can remove a member
from one organization without touching their account.

- UserApi.lookupByEmail hits GET /users?email= and returns 0 or 1 result. A
  null result deliberately covers both "no such email" and "a real user you
  may not see"; the copy says only "No user found with that email." so the UI
  can't be used to enumerate accounts.
- AddMemberDialog gains an "Add existing member" tab for admins and super
  admins, with an explicit Find step, a confirmation card, and a role select
  whose Member option submits the User role.
- MemberCard gains a non-destructive "Remove from organization" action that
  DELETEs the /role sub-route, kept visually distinct from Delete.
- Surfaces the user_already_in_organization, last_organization_admin and
  user_belongs_to_multiple_organizations conflicts with their own messages.
- Drops the dead top-level useUserList/UserApi.list, which called a GET /users
  route that now requires an email parameter.
Adds a Clear button to the lookup result card, so finding the wrong person
is recoverable without closing the dialog.

Also invalidates the match when the email field is edited. Previously the
found user survived an edit, so an admin who searched one address, then
retyped another without pressing Find, would add the first person while the
field displayed the second.
Both the create-new and add-existing flows now offer an optional coach
picker listing the organization's current members. The selection posts a
coaching relationship after the member exists, reusing the existing
POST /organizations/{id}/coaching_relationships endpoint.

The two calls are not atomic, so a failed assignment reports the member as
added with guidance to assign a coach from the member list. The reverse
failure cannot happen, and a coachless member is recoverable from the UI.

A found user is kept off their own coach list.
Adding a member and assigning their coach were two requests, and the
backend sends the invitation email at the end of the first one. A failed
coach assignment therefore left the person invited with no coach, which
the dialog could only report as a warning.

Both endpoints now take an optional coach_id and create the relationship
in the same transaction, so the coach rides along with the member request
and a failure fails the whole add. The partial-failure handling
(assignSelectedCoach, the warning toasts) is gone with the state it
described. coach_id is omitted rather than sent as null when no coach is
chosen.
Drives the real stack rather than mocking the API like the other e2e specs:
creates a member with a coach, creates an organization, attaches an existing
user as its admin, and schedules a session across organizations.

Skipped unless LIVE_E2E=1, since it is not idempotent and needs a freshly
seeded database.

Step d pins a finding rather than a success: a brand new organization's admin
cannot add anyone to it, because the lookup only returns users who share an
organization they already administer, and a new organization is empty.
The Upcoming and Previous lists fetched every session the user participates
in, ignoring the organization switcher, so a user in two organizations saw
both organizations' sessions at once. The defect predates multi-org
membership but was unreachable until a user could belong to two
organizations.

Both hooks now take an optional organizationId and put organization_id in the
params object, which is also the SWR key. The key matters: without it,
switching organizations would serve the previous organization's cached list.
The dashboard card threads its currentOrganizationId down through the bucket
components; omitting the value keeps the previous unscoped behavior.
…ed org

The avatar rendered a hardcoded "RG" at all three sites: the collapsed
sidebar, the expanded trigger, and every row of the dropdown. Every
organization looked like Refactor Group, which only became visible once a
user could belong to more than one.

organizationInitials derives up to two letters from the name, taking the
first letter of the first two words ("Refactor Group" -> "RG") or the first
two letters of a single-word name ("BigTable" -> "BI"), and falls back to
"?" when there is no name.

Also drops the LOGO placeholder constant. It pointed at /placeholder.svg,
which does not exist in public/, so every avatar issued a 404 before
falling through to the initials. Leaving src undefined reaches the fallback
directly.

The switcher's existing tests now scope their assertions to the listbox:
the trigger renders the selected organization's name too, so a bare
getByText matched twice.
Live verification of the session org-scoping fix found the dashboard still
bleeding, just not on the surface that was reported. The Coaching Sessions
card's Upcoming and Previous tabs were correct, but the Upcoming Session
card and the Goals Overview beside it still showed another organization's
session, because useTodaysSessions never passed the selected organization
to the hook that now accepts one.

Also treats internal capitals as word boundaries when deriving avatar
initials, so a camel or Pascal case name reads as its own parts
("BigTable" -> "BT", not "BI"). Multi-word names are unchanged.

Adds a repeatable read-only live e2e spec covering both the avatar and the
absence of cross-organization session bleed. Unlike multi-org-live.spec.ts
it creates nothing, so it can be re-run against an already-seeded database.

join-session-popover needs no change: it filters by coaching_relationship_id,
and a relationship belongs to exactly one organization.
@jhodapp jhodapp self-assigned this Aug 6, 2026
@jhodapp jhodapp changed the title feat: multi-organization membership Multi-organization membership Aug 6, 2026
@jhodapp jhodapp added the enhancement Improves existing functionality or feature label Aug 6, 2026
@jhodapp jhodapp added this to the 1.0.0-beta3 milestone Aug 6, 2026
@jhodapp jhodapp moved this from Review to 🏗 In progress in Refactor Coaching Platform Aug 6, 2026
jhodapp added 2 commits August 6, 2026 16:09
- lookupByEmail returns Option<UserLookupResult> instead of a nullable
  union, per the Strict Typing and Nullability standard. New API boundary,
  so there was no legacy pressure to match.
- TODO(rs#374) at the two remaining unscoped session fetches, both in the
  actions path, recording that they are deliberate and what unblocks them.
- The Find button shows "Finding..." while in flight, matching Remove.
- attachExisting returns Promise<void>. The response was typed User but
  never parsed or consumed, so the type claimed more than it delivered.
- Deleting a member refreshes only on success; the failure path left the
  list unchanged.
- One surrogate-safe idiom in both branches of organizationInitials.
- Drop a stray console.log in member-card, and the isACoach binding it was
  the only consumer of.
The Add Member dialog hardcoded "Refactor", which is not the product name.
Both mentions now read siteConfig.name, and the existing-member copy names
the organization being added to rather than saying "this organization".

Falls back to "you are viewing" for the brief window before the current
organization resolves, so the sentence never renders with a gap in it.
@jhodapp
jhodapp marked this pull request as ready for review August 7, 2026 16:48
@jhodapp jhodapp moved this from 🏗 In progress to Review in Refactor Coaching Platform Aug 7, 2026
@greptile-apps

greptile-apps Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds multi-organization membership management and scopes session data to the selected organization.

  • Adds existing-user lookup, explicit membership roles, optional coach assignment, and organization-only member removal.
  • Includes organization identity in session requests and SWR keys while preventing unscoped cold-load requests.
  • Derives organization avatar initials dynamically and adds focused unit and live end-to-end coverage.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains from the previously reported issues.

No blocking failure remains.

Important Files Changed

Filename Overview
src/components/ui/members/add-member-dialog.tsx Adds guarded existing-user lookup, role selection, coach assignment, and correctly resolves the previously reported stale-response and configuration-import issues.
src/components/ui/dashboard/coaching-sessions-card.tsx Defers session bucket rendering until an organization is selected, resolving the previously reported unscoped cold-load request.
src/lib/api/coaching-sessions.ts Adds organization scoping to session request parameters and cache-key inputs.
src/lib/hooks/use-todays-sessions.ts Scopes today-session fetching to the selected organization and disables fetching before selection.
src/components/ui/organization-switcher.tsx Displays initials derived from the currently selected organization.
src/components/ui/members/member-card.tsx Changes member removal to delete only the organization membership rather than the shared account.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Select organization] --> B[currentOrganizationId available]
  B --> C[Mount session buckets]
  C --> D[Organization-scoped SWR key]
  D --> E[Scoped session request]
  F[Add member dialog] --> G{New or existing user}
  G -->|New| H[Create user membership]
  G -->|Existing| I[Lookup and attach membership]
  H --> J[Optional coach assignment]
  I --> J
Loading

Reviews (8): Last reviewed commit: "fix(dashboard): wait for the organizatio..." | Re-trigger Greptile

Comment thread src/components/ui/members/add-member-dialog.tsx Outdated
Comment thread src/components/ui/members/add-member-dialog.tsx Outdated
jhodapp added 5 commits August 7, 2026 12:21
Editing the email cleared the found user, but an in-flight lookup that
resolved afterwards wrote its result back over that. The confirmation card
then showed an account the email field no longer named, and Add attached
that account instead of the one on screen.

Each lookup now carries a token. Editing the field or starting another
lookup bumps it, and a reply whose token is stale is dropped.

Also takes the product name as a prop rather than importing siteConfig.
The coding standards prohibit leaf components reading global config; the
members page reads it once and threads it down.
The annotation referenced React without importing it. Types are erased and
__tests__ sits outside the tsc include, so it never surfaced.
Finding someone who already belongs to the organization built the whole
confirmation card, so an admin picked a role and a coach before the request
came back 409. The lookup now checks the member list it already has and says
so immediately.

The server check stays: the member list can be stale, and is absent entirely
for callers that do not pass one. Both paths share one message string so
they cannot drift.

This makes the coach picker's self-exclusion unreachable, since the picker
and the new check read the same member list, so a found user can no longer
appear in it. Removed the filter and reframed the test that covered it.
Removing a member with session history returns a 409 the member card had no
handler for, so it fell through to "Error removing member from this
organization" and the actionable message never reached the toast.

Maps user_has_coaching_history like the other organization conflicts.
The lookup behind that tab only returns users in organizations the requester
administers, and adding someone to an organization they already belong to is
rejected. For an admin of a single organization those two sets are exact
complements, so the tab can only ever end in "already a member".

canAddExistingMembers answers whether the flow has candidates: true for a
super admin, or for an admin of two or more organizations. The container
computes it from the session's roles and threads it down, leaving the dialog
free of policy.

Membership in a second organization does not count. Visibility requires Admin
there, so a member-only organization contributes nobody.
Comment thread src/components/ui/dashboard/coaching-sessions-card.tsx Outdated
jhodapp added 2 commits August 7, 2026 14:47
Ten cases against the real backend and the seeded local users, in real
Chrome. The @single-org cases are read-only. The @multi-org cases need an
operator to promote ehab to Admin of Refactor Group first, since no seeded
user administers two organizations, and that is the whole condition under
test.

Verified by reverting ehab to a single-org admin, which fails the tab test
against unchanged code, so the gate is what drives it rather than the
assertion happening to hold.
A cold load fetched sessions before the organization resolved. The hooks kept
valid SWR keys with organization_id omitted, which the backend reads as
unscoped, so eight requests went out and rendered every organization's
sessions before the scoped result replaced them.

The card no longer mounts the buckets until the organization is known, and
the today's-sessions hook passes a null user id until then, which is the
hook's existing way to skip a fetch.

The new e2e asserts no session request ever leaves without an organization.
Reverting both gates fails it with eight offenders.

Pins browser time in that spec. Two assertions depended on where a fixed
seeded session fell relative to the current day, and broke once the date
rolled over.
@jhodapp
jhodapp merged commit 19c5848 into main Aug 7, 2026
8 checks passed
@jhodapp
jhodapp deleted the feat/multi-org-membership branch August 7, 2026 23:09
@github-project-automation github-project-automation Bot moved this from Review to ✅ Done in Refactor Coaching Platform Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improves existing functionality or feature

Projects

Status: ✅ Done

Development

Successfully merging this pull request may close these issues.

1 participant