mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-02 07:30:01 +08:00
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:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user