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>
This commit is contained in:
Vikhyath Mondreti
2026-07-29 17:22:24 -07:00
parent f617a913fe
commit 4eb7725f1a
2 changed files with 101 additions and 10 deletions
+75
View File
@@ -1391,6 +1391,81 @@ describe('acceptInvitation', () => {
)
})
it('lets a pre-existing member accept a no-join disclosure without looping', async () => {
/**
* The preview reports no-join for someone already in the target
* organization — nothing changes for them — while the invitation's intent
* stays internal, so `shouldJoinOrganization` remains true. Comparing the
* disclosure against that raw flag rejected every such acceptance as
* `disclosure-outdated`, and the retry re-rendered the same preview, so the
* invitation could never be accepted. The guard compares against whether a
* NEW membership is created instead.
*/
mockGetWorkspaceWithOwner.mockResolvedValue({
id: 'workspace-1',
name: 'Workspace',
ownerId: 'owner-1',
organizationId: 'org-1',
workspaceMode: 'organization',
billedAccountUserId: 'owner-1',
})
mockGetUserOrganization.mockResolvedValue({
organizationId: 'org-1',
role: 'member',
memberId: 'member-1',
})
mockEnsureUserInOrganization.mockResolvedValueOnce({
success: true,
alreadyMember: true,
billingActions: { proUsageSnapshotted: false, proCancelledAtPeriodEnd: false },
})
queueWhereResponses([
[
{
id: 'inv-1',
kind: 'workspace',
email: 'invitee@example.com',
organizationId: 'org-1',
membershipIntent: 'internal',
inviterId: 'owner-1',
role: 'member',
status: 'pending',
token: 'tok-1',
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
updatedAt: new Date(),
},
],
[
{
id: 'grant-1',
workspaceId: 'workspace-1',
permission: 'write',
workspaceName: 'Workspace',
},
],
[{ name: 'Acme' }],
[{ name: 'Owner', email: 'owner@example.com' }],
[],
[{ id: 'member-1' }],
])
const result = await acceptInvitation({
userId: 'invitee-user',
userEmail: 'invitee@example.com',
invitationId: 'inv-1',
token: 'tok-1',
disclosedWorkspaceIds: [],
disclosedWillJoinOrganization: false,
})
expect(result.success ? 'ok' : result.kind).toBe('ok')
if (result.success) {
expect(result.membershipAlreadyExists).toBe(true)
}
})
it('does not reconcile seats for an Enterprise organization (fixed seats)', async () => {
mockGetWorkspaceWithOwner.mockResolvedValue({
id: 'workspace-1',
+26 -10
View File
@@ -726,18 +726,37 @@ async function acceptLockedInvitation(
return { success: false, kind: 'external-requires-paid-plan' }
}
/**
* Already in the organization the invitation lands in, so acceptance grants
* the workspaces without creating a membership or taking a seat. Shared with
* the join block below so the disclosure guard and the billing path cannot
* disagree about whether this acceptance creates a member.
*/
const alreadyMemberOfTargetOrganization =
!!existingMembership &&
!!workspaceOrganizationId &&
existingMembership.organizationId === workspaceOrganizationId
/**
* Membership consent guard. The workspace-id token cannot distinguish "you
* will join, and nothing of yours moves" from "you will not join at all" —
* both disclose an empty set — so the disclosed join outcome is compared
* directly. Catches an invitee who left their other organization between
* preview and accept (promised external, would now consume a seat) and the
* mirror case (promised membership, would now be external). Runs before any
* write, so a plain failure return needs no rollback.
* both disclose an empty set — so the disclosed outcome is compared directly.
*
* Compared against whether a NEW membership gets created, not against
* `shouldJoinOrganization`: the preview reports no-join for an invitee who
* already belongs to the target organization (nothing changes for them), and
* `shouldJoinOrganization` stays true there because the invitation's intent is
* still internal. Comparing the raw flag would reject every such acceptance
* as `disclosure-outdated`, and the retry would re-render the same preview —
* an unacceptable invitation. Catches the real drift in both directions: an
* invitee who left their other organization between preview and accept
* (promised external, would now consume a seat) and the mirror case. Runs
* before any write, so a plain failure return needs no rollback.
*/
const willCreateMembership = shouldJoinOrganization && !alreadyMemberOfTargetOrganization
if (
input.disclosedWillJoinOrganization !== undefined &&
input.disclosedWillJoinOrganization !== shouldJoinOrganization
input.disclosedWillJoinOrganization !== willCreateMembership
) {
return { success: false, kind: 'disclosure-outdated' }
}
@@ -758,10 +777,7 @@ async function acceptLockedInvitation(
let targetOrganizationId = workspaceOrganizationId
if (shouldJoinOrganization) {
const alreadyMemberOfTarget =
!!existingMembership &&
!!workspaceOrganizationId &&
existingMembership.organizationId === workspaceOrganizationId
const alreadyMemberOfTarget = alreadyMemberOfTargetOrganization
let fixedSeats = false