feat(organizations): sweep a joiner's owned workspaces into the org on join, disclose it at accept, and add external workspace invites - #5918
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryHigh Risk Overview Inviting consolidates on a shared Safety and parity: personal workspace creation re-checks org membership under a billing lock to avoid racing a join; admin member add mirrors invitation acceptance (transactional attach + Enterprise seat cap only). New Minor: docs chat tool uses Reviewed by Cursor Bugbot for commit ea71c3c. Configure here. |
Greptile SummaryThis PR expands organization membership and invitation behavior.
Confidence Score: 5/5The PR appears safe to merge because no blocking failures remain within the scope of the previous review threads. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| apps/sim/lib/invitations/core.ts | Implements invitation previews, disclosure validation, acceptance outcomes, and organization-join workspace migration orchestration. |
| apps/sim/lib/workspaces/organization-workspaces.ts | Adds transactional helpers for attaching a joining user’s owned workspaces to an organization. |
| apps/sim/lib/billing/organizations/membership.ts | Extends organization membership lifecycle and locking behavior used by join and removal flows. |
| apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts | Adds an atomic organization-membership and owned-workspace sweep to the administrative member-add path. |
| apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx | Adds explicit internal-member versus external-collaborator invitation controls. |
| apps/sim/app/invite/[id]/invite.tsx | Displays acceptance consequences and submits the disclosed workspace and membership outcome. |
| apps/sim/app/api/invitations/[id]/route.ts | Adds invitation join previews and workspace-scoped revocation with corresponding authorization checks. |
| apps/sim/app/api/workspaces/route.ts | Coordinates personal workspace creation with organization joins and handles default-creation races by refreshing workspace visibility. |
| apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx | Adds credential-impact loading and degraded-warning states to organization-member removal. |
| apps/sim/lib/api/contracts/invitations.ts | Expands invitation request and response contracts for grants, membership outcomes, and acceptance disclosures. |
Sequence Diagram
sequenceDiagram
participant Admin
participant Invitations
participant Invitee
participant Membership
participant Workspaces
Admin->>Invitations: Invite as Member/Admin or External
Invitations-->>Invitee: Show workspace grants and join disclosure
Invitee->>Invitations: Accept disclosed outcome
alt Internal organization member
Invitations->>Membership: Add organization membership
Membership->>Workspaces: Move owned personal workspaces into organization
Workspaces-->>Membership: Preserve collaborators as external members
else External collaborator
Invitations->>Workspaces: Grant selected workspace access only
end
Invitations-->>Invitee: Open granted workspace
Reviews (25): Last reviewed commit: "fix(invitations): restore the invite adm..." | Re-trigger Greptile
e915b65 to
7509184
Compare
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 7509184. Configure here.
|
@cursor review |
|
@cursor review |
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 5010800. Configure here.
|
@cursor review |
…l refetch Two corrections to this branch's own review fixes. The pending-invitations list computed each row's join preview with Promise.all. Every preview issues several queries, and the endpoint is hit whenever the workspace switcher opens, so that held one pooled connection per pending invitation for as long as the slowest one took. The loop is sequential now; the list is a handful of rows, so the latency is not worth the pool pressure. The removal-impact refetch on confirm is reverted. It changed behaviour — a click could silently do nothing — and it did not actually close the window it targeted: the credential set can still change between the refetch and the independent DELETE, because the removal endpoint neither receives nor revalidates the disclosed set. Closing that properly means passing the disclosure to the endpoint and revalidating there, which is a feature rather than a review fix, so the prior behaviour stands until it is done deliberately. Co-Authored-By: Claude <noreply@anthropic.com>
Both accept surfaces scoped the membership notice on `organizationId` or the preview's `organizationName`. A personal-workspace invite has neither until acceptance runs — it creates the organization by converting the billed owner's Pro to Team — so the seat and membership disclosure was suppressed for exactly the case that creates the membership. They now also scope on the preview's `willJoinOrganization`, which is the authoritative signal. Co-Authored-By: Claude <noreply@anthropic.com>
The preview returned one no-join shape for five different results — external intent, already a member, a membership in another organization, a dead-grant rejection, and a billing rejection. Two accumulating booleans could not separate them, and the accept screen rendered the external copy for all of them: it told people whose acceptance would fail with `upgrade-required` or `workspace-not-found` that they were getting workspace access without a seat. It now reports one `outcome`: `will-join` (a seat is taken), `already-member` (only workspace access changes), `external` (never a seat), or `blocked` (acceptance fails, so nothing is promised). `blocked` renders no membership notice — silence is accurate where the external claim was false. The accept button is deliberately left enabled: those cases already fail closed with the correct error, and choosing what to actively tell someone whose organization's payment lapsed is a product decision, not a review fix. This also fixes a live mis-attribution the previous commit's guard introduced. The consent check ran before the gates that produce the real cause, so a blocked invitation returned `disclosure-outdated` — and the retry re-rendered the same preview, leaving the invitee looping with no explanation. The guard now sits after the dead-grant gate, and a disclosed `blocked` skips the comparison so the billing gate below can surface `upgrade-required` instead. The accept body carries `disclosedOutcome` in place of the boolean; the membership comparison is unchanged (`will-join` versus a new membership being created), so no acceptance that previously succeeded now fails. Co-Authored-By: Claude <noreply@anthropic.com>
|
@cursor review |
The consent guard derived "a membership will be created" from `shouldJoinOrganization`, which is still true at that point — it is only cleared much later, after provisioning fails to yield a target organization. With billing disabled and no organization on the workspace there is nothing to provision and nothing to join, so the preview correctly reports `external` while the guard computed `will-join`, rejecting every personal and grandfathered workspace invite as `disclosure-outdated`. The retry rendered the same preview, so those invites could not be accepted at all on billing-disabled deployments. The predicate now mirrors the preview's own condition, so the two cannot drift. Regression test asserts acceptance succeeds with billing off; it fails with `disclosure-outdated` against the previous predicate. Co-Authored-By: Claude <noreply@anthropic.com>
|
@cursor review |
… invites blocked The External paid-plan requirement is seat economics — an external collaborator takes no seat, so somebody else must be paying for them. With billing disabled there are no seats and no subscription rows at all, so every account resolves as `free` and choosing External failed at send time and would have failed at accept. Member and Admin still worked, so a self-hosted deployment had no way to grant workspace-only access without an organization join and a workspace sweep. Both the invite-time and accept-time gates now short-circuit when billing is off. A route test asserted that rejection without setting `isBillingEnabled`, which the shared mock defaults to false — it passed only because the gate ignored the flag. It now opts in explicitly, since the rule it covers is billing-only. Separately, the preview reported `external` for any invitee already in a different organization, but acceptance only downgrades a workspace-kind invite with live grants; an organization-kind invite hard-fails with `already-in-organization`. Those now report `blocked`, so the screen stops promising external access that acceptance can never grant. Legacy organization-kind rows still exist and coalescing preserves that kind, so this is reachable. Co-Authored-By: Claude <noreply@anthropic.com>
|
@cursor review |
…osure gaps Privilege escalation. `createWorkspaceInvitation` stamped organization role `admin` whenever the caller passed `membership: 'admin'`, but authorization only checked workspace admin access — and unifying the invite modals exposed the Admin option to any workspace admin, where it had previously been reachable only from organization settings. A workspace-scoped administrator could therefore invite someone who joins as an organization Admin, gaining admin on every workspace the organization owns plus member and billing management. The inviter must now already hold organization owner/admin, checked server-side because the batch endpoint is reachable without the modal, and the modal no longer offers Admin to anyone else. The preview promised external access without mirroring acceptance's `external-requires-paid-plan` gate, so a free invitee — one who cancelled Pro, or left the organization that forced the external invite — was told they had workspace access and then refused. It now mirrors that gate, including its exemptions (billing on, organization-owned workspace, externality not imposed), and reports `blocked`. The modal's Enterprise seat check counted every non-External email as a seat. The server does not: an existing organization member is granted access directly, and an invitee already in another organization is forced external. The hard block refused batches the API would have accepted, so it is advisory now — per-email failures already come back with reasons. Co-Authored-By: Claude <noreply@anthropic.com>
|
@cursor review |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1d19581. Configure here.
…come
Consolidating the modals dropped a permission check. The old workspace-header
modal derived `canInviteMembers` from `userPermissions.canAdmin` internally and
disabled its field and button; the shared modal takes `canInvite` as a prop that
defaults to true, and no call site passed it. Non-admins therefore saw a fully
enabled invite form and only learned otherwise when the server refused the send.
All three entry points now supply it: workspace admin for the header, the
existing `canManage` for the workspace Teammates page, and organization
owner/admin for organization settings.
When the join preview cannot be computed the outcome is unknown, and the callers
send no disclosure token — so acceptance runs without the consent guards. The
membership notice nevertheless asserted a seat-taking join from the sent intent,
which acceptance may resolve to external, already-a-member, or a failure. It is
conditional now ("If you're added to X as a member, that uses one of their
seats"), so the consequence is still disclosed without being claimed as settled.
Co-Authored-By: Claude <noreply@anthropic.com>
|
@cursor review |

Summary
Accepting an org invite now moves every workspace the invitee owns (archived included) into the organization — disclosed on the accept screen — with collaborators kept as seat-free external members and stale pre-move invites downgraded instead of escalating.
Invites land directly in the granted workspace, member-role org invites must carry workspace access (enforced route + domain layer), and the invite modal gains an explicit Member vs External choice.
The remove-member dialog now lists which owner-bound credentials will stop working, workspace-less members get a proper in-product empty state, and the membership docs reflect the new model.
Type of Change
Testing
Manually
Checklist