Skip to content

feat(organizations): sweep a joiner's owned workspaces into the org on join, disclose it at accept, and add external workspace invites - #5918

Merged
icecrasher321 merged 29 commits into
stagingfrom
staging-v8
Jul 30, 2026
Merged

feat(organizations): sweep a joiner's owned workspaces into the org on join, disclose it at accept, and add external workspace invites#5918
icecrasher321 merged 29 commits into
stagingfrom
staging-v8

Conversation

@icecrasher321

@icecrasher321 icecrasher321 commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

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

  • New feature

Testing

Manually

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 30, 2026 2:17am

Request Review

@cursor

cursor Bot commented Jul 24, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes org membership, invitation acceptance, workspace ownership transfer, seat/external billing rules, and removes legacy org invite APIs—high blast radius for access control and billing.

Overview
Organization membership and invitations are reworked so joining as an internal member moves the invitee’s owned workspaces (including archived) into the organization, with collaborators kept as external members. Accept flows (/invite, workspace switcher) show join previews (membership, seat use, workspaces that will move) and send disclosure tokens on accept; the server returns disclosure-outdated (409) if the sweep set changed since load.

Inviting consolidates on a shared InviteModal: multi-workspace grants, workspace access level, and Member / Admin / External membership. Organization-only POST invite routes are removed in favor of the workspace batch path. External invites require the invitee to already be on a paid plan (when billing is on); users in another org are forced external. Scoped revocation (DELETE with workspaceId) drops one grant from a multi-workspace pending invite; full cancel needs org admin or admin on every granted workspace. Member-role org invites must include at least one workspace.

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 GET …/removal-impact and UI warn which identity-bound credentials break on removal. Docs (permissions, workspaces, costs) describe the new model.

Minor: docs chat tool uses jsonSchema instead of Zod; breadcrumb typings in docs.

Reviewed by Cursor Bugbot for commit ea71c3c. Configure here.

@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR expands organization membership and invitation behavior.

  • Moves a joining member’s owned workspaces into the organization while preserving collaborators as external members.
  • Adds acceptance-time migration disclosures and explicit member-versus-external workspace invitations.
  • Adds scoped invitation revocation, workspace landing behavior, and removal-impact disclosure.
  • Updates API contracts, billing and workspace policies, UI flows, tests, and membership documentation.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failures remain within the scope of the previous review threads.

No blocking failure remains.

Important Files Changed

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
Loading

Reviews (25): Last reviewed commit: "fix(invitations): restore the invite adm..." | Re-trigger Greptile

Comment thread apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts
Comment thread apps/sim/app/api/invitations/[id]/route.ts
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/invitations/core.ts
Comment thread apps/sim/app/workspace/page.tsx Outdated
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/invitations/core.ts
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/workspaces/route.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/invitations/[id]/accept/route.ts
Comment thread apps/sim/app/invite/[id]/invite.tsx
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>
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/invitations/core.ts
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>
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/invitations/workspace-invitations.ts
Comment thread apps/sim/lib/invitations/core.ts
… 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>
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/invitations/core.ts Outdated
Comment thread apps/sim/lib/invitations/workspace-invitations.ts
…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>
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/invitations/disclosure-copy.ts Outdated
…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>
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ea71c3c. Configure here.

Comment thread apps/sim/lib/invitations/core.ts
Comment thread apps/sim/lib/invitations/send.ts
@icecrasher321
icecrasher321 merged commit 11c0d3b into staging Jul 30, 2026
26 of 27 checks passed
@icecrasher321
icecrasher321 deleted the staging-v8 branch July 30, 2026 03:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant