Skip to content

Commit 4eb7725

Browse files
icecrasher321claude
andcommitted
fix(invitations): compare the join disclosure against new-membership creation
The membership consent guard compared the disclosed outcome against `shouldJoinOrganization`, which stays true for an invitee who already belongs to the target organization — the invitation's intent is still internal. The join preview reports no-join for exactly that case, because nothing changes for them. Every such acceptance therefore failed `disclosure-outdated`, and the retry re-rendered the same preview, so the invitation became permanently unacceptable. The guard now compares against whether acceptance creates a NEW membership (`shouldJoinOrganization && !alreadyMemberOfTargetOrganization`), which is what the disclosure actually promises and what the preview reports. The already-a-member predicate is hoisted and shared with the join block below so the guard and the billing path cannot disagree about it. Regression test asserts a pre-existing member accepts with a no-join disclosure; it fails with `disclosure-outdated` against the previous comparison. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent f617a91 commit 4eb7725

2 files changed

Lines changed: 101 additions & 10 deletions

File tree

apps/sim/lib/invitations/core.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1391,6 +1391,81 @@ describe('acceptInvitation', () => {
13911391
)
13921392
})
13931393

1394+
it('lets a pre-existing member accept a no-join disclosure without looping', async () => {
1395+
/**
1396+
* The preview reports no-join for someone already in the target
1397+
* organization — nothing changes for them — while the invitation's intent
1398+
* stays internal, so `shouldJoinOrganization` remains true. Comparing the
1399+
* disclosure against that raw flag rejected every such acceptance as
1400+
* `disclosure-outdated`, and the retry re-rendered the same preview, so the
1401+
* invitation could never be accepted. The guard compares against whether a
1402+
* NEW membership is created instead.
1403+
*/
1404+
mockGetWorkspaceWithOwner.mockResolvedValue({
1405+
id: 'workspace-1',
1406+
name: 'Workspace',
1407+
ownerId: 'owner-1',
1408+
organizationId: 'org-1',
1409+
workspaceMode: 'organization',
1410+
billedAccountUserId: 'owner-1',
1411+
})
1412+
mockGetUserOrganization.mockResolvedValue({
1413+
organizationId: 'org-1',
1414+
role: 'member',
1415+
memberId: 'member-1',
1416+
})
1417+
mockEnsureUserInOrganization.mockResolvedValueOnce({
1418+
success: true,
1419+
alreadyMember: true,
1420+
billingActions: { proUsageSnapshotted: false, proCancelledAtPeriodEnd: false },
1421+
})
1422+
1423+
queueWhereResponses([
1424+
[
1425+
{
1426+
id: 'inv-1',
1427+
kind: 'workspace',
1428+
email: 'invitee@example.com',
1429+
organizationId: 'org-1',
1430+
membershipIntent: 'internal',
1431+
inviterId: 'owner-1',
1432+
role: 'member',
1433+
status: 'pending',
1434+
token: 'tok-1',
1435+
expiresAt: new Date(Date.now() + 60_000),
1436+
createdAt: new Date(),
1437+
updatedAt: new Date(),
1438+
},
1439+
],
1440+
[
1441+
{
1442+
id: 'grant-1',
1443+
workspaceId: 'workspace-1',
1444+
permission: 'write',
1445+
workspaceName: 'Workspace',
1446+
},
1447+
],
1448+
[{ name: 'Acme' }],
1449+
[{ name: 'Owner', email: 'owner@example.com' }],
1450+
[],
1451+
[{ id: 'member-1' }],
1452+
])
1453+
1454+
const result = await acceptInvitation({
1455+
userId: 'invitee-user',
1456+
userEmail: 'invitee@example.com',
1457+
invitationId: 'inv-1',
1458+
token: 'tok-1',
1459+
disclosedWorkspaceIds: [],
1460+
disclosedWillJoinOrganization: false,
1461+
})
1462+
1463+
expect(result.success ? 'ok' : result.kind).toBe('ok')
1464+
if (result.success) {
1465+
expect(result.membershipAlreadyExists).toBe(true)
1466+
}
1467+
})
1468+
13941469
it('does not reconcile seats for an Enterprise organization (fixed seats)', async () => {
13951470
mockGetWorkspaceWithOwner.mockResolvedValue({
13961471
id: 'workspace-1',

apps/sim/lib/invitations/core.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -726,18 +726,37 @@ async function acceptLockedInvitation(
726726
return { success: false, kind: 'external-requires-paid-plan' }
727727
}
728728

729+
/**
730+
* Already in the organization the invitation lands in, so acceptance grants
731+
* the workspaces without creating a membership or taking a seat. Shared with
732+
* the join block below so the disclosure guard and the billing path cannot
733+
* disagree about whether this acceptance creates a member.
734+
*/
735+
const alreadyMemberOfTargetOrganization =
736+
!!existingMembership &&
737+
!!workspaceOrganizationId &&
738+
existingMembership.organizationId === workspaceOrganizationId
739+
729740
/**
730741
* Membership consent guard. The workspace-id token cannot distinguish "you
731742
* will join, and nothing of yours moves" from "you will not join at all" —
732-
* both disclose an empty set — so the disclosed join outcome is compared
733-
* directly. Catches an invitee who left their other organization between
734-
* preview and accept (promised external, would now consume a seat) and the
735-
* mirror case (promised membership, would now be external). Runs before any
736-
* write, so a plain failure return needs no rollback.
743+
* both disclose an empty set — so the disclosed outcome is compared directly.
744+
*
745+
* Compared against whether a NEW membership gets created, not against
746+
* `shouldJoinOrganization`: the preview reports no-join for an invitee who
747+
* already belongs to the target organization (nothing changes for them), and
748+
* `shouldJoinOrganization` stays true there because the invitation's intent is
749+
* still internal. Comparing the raw flag would reject every such acceptance
750+
* as `disclosure-outdated`, and the retry would re-render the same preview —
751+
* an unacceptable invitation. Catches the real drift in both directions: an
752+
* invitee who left their other organization between preview and accept
753+
* (promised external, would now consume a seat) and the mirror case. Runs
754+
* before any write, so a plain failure return needs no rollback.
737755
*/
756+
const willCreateMembership = shouldJoinOrganization && !alreadyMemberOfTargetOrganization
738757
if (
739758
input.disclosedWillJoinOrganization !== undefined &&
740-
input.disclosedWillJoinOrganization !== shouldJoinOrganization
759+
input.disclosedWillJoinOrganization !== willCreateMembership
741760
) {
742761
return { success: false, kind: 'disclosure-outdated' }
743762
}
@@ -758,10 +777,7 @@ async function acceptLockedInvitation(
758777
let targetOrganizationId = workspaceOrganizationId
759778

760779
if (shouldJoinOrganization) {
761-
const alreadyMemberOfTarget =
762-
!!existingMembership &&
763-
!!workspaceOrganizationId &&
764-
existingMembership.organizationId === workspaceOrganizationId
780+
const alreadyMemberOfTarget = alreadyMemberOfTargetOrganization
765781

766782
let fixedSeats = false
767783

0 commit comments

Comments
 (0)