Skip to content

Commit aed4106

Browse files
icecrasher321claude
andcommitted
fix(invitations): let billing-disabled personal invites be accepted
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>
1 parent 4297c05 commit aed4106

2 files changed

Lines changed: 80 additions & 1 deletion

File tree

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,70 @@ describe('acceptInvitation', () => {
399399
)
400400
})
401401

402+
it('accepts a personal-workspace invite on a billing-disabled deployment', async () => {
403+
/**
404+
* With billing off and no organization on the workspace there is nothing to
405+
* provision and nothing to join, so the preview reports `external`. The
406+
* consent guard must agree: `shouldJoinOrganization` is still true at that
407+
* point (it is only cleared much later), so deriving the predicate from it
408+
* alone rejected every self-hosted personal invite as `disclosure-outdated`,
409+
* with a retry that rendered the same preview.
410+
*/
411+
setEnvFlags({ isBillingEnabled: false })
412+
mockGetWorkspaceWithOwner.mockResolvedValue({
413+
id: 'workspace-1',
414+
name: 'Workspace',
415+
ownerId: 'owner-1',
416+
organizationId: null,
417+
workspaceMode: 'personal',
418+
billedAccountUserId: 'owner-1',
419+
})
420+
queueWhereResponses([
421+
[
422+
{
423+
id: 'inv-1',
424+
kind: 'workspace',
425+
email: 'invitee@example.com',
426+
organizationId: null,
427+
membershipIntent: 'internal',
428+
inviterId: 'owner-1',
429+
role: 'member',
430+
status: 'pending',
431+
token: 'tok-1',
432+
expiresAt: new Date(Date.now() + 60_000),
433+
createdAt: new Date(),
434+
updatedAt: new Date(),
435+
},
436+
],
437+
[
438+
{
439+
id: 'grant-1',
440+
workspaceId: 'workspace-1',
441+
permission: 'write',
442+
workspaceName: 'Workspace',
443+
},
444+
],
445+
[],
446+
[{ name: 'Owner', email: 'owner@example.com' }],
447+
[],
448+
[],
449+
[{ variables: {} }],
450+
])
451+
452+
const result = await acceptInvitation({
453+
userId: 'invitee-user',
454+
userEmail: 'invitee@example.com',
455+
invitationId: 'inv-1',
456+
token: 'tok-1',
457+
disclosedWorkspaceIds: [],
458+
disclosedOutcome: 'external',
459+
request: new Request('http://localhost/api/invitations/inv-1/accept'),
460+
})
461+
462+
expect(result.success ? 'ok' : result.kind).toBe('ok')
463+
expect(mockEnsureUserInOrganization).not.toHaveBeenCalled()
464+
})
465+
402466
it('preserves a personal workspace organization null for external invitations', async () => {
403467
mockGetWorkspaceWithOwner.mockResolvedValue({
404468
id: 'workspace-1',

apps/sim/lib/invitations/core.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,22 @@ async function acceptLockedInvitation(
793793
*
794794
* Runs before any write, so a plain failure return needs no rollback.
795795
*/
796-
const willCreateMembership = shouldJoinOrganization && !alreadyMemberOfTargetOrganization
796+
/**
797+
* Whether acceptance will actually create a member row, decided from the same
798+
* conditions the join block below uses — `shouldJoinOrganization` alone is not
799+
* enough here, because it is only cleared much later (after provisioning fails
800+
* to yield a target organization), by which point a write has happened.
801+
*
802+
* The last term mirrors the preview: with no organization on the workspace and
803+
* billing disabled there is nothing to provision and nothing to join, so a
804+
* personal or grandfathered workspace invite creates no membership. Omitting it
805+
* rejected every such acceptance as `disclosure-outdated` on billing-disabled
806+
* deployments, with a retry that rendered the same preview.
807+
*/
808+
const willCreateMembership =
809+
shouldJoinOrganization &&
810+
!alreadyMemberOfTargetOrganization &&
811+
(!!workspaceOrganizationId || isBillingEnabled)
797812
if (input.disclosedOutcome !== undefined && input.disclosedOutcome !== 'blocked') {
798813
if ((input.disclosedOutcome === 'will-join') !== willCreateMembership) {
799814
return { success: false, kind: 'disclosure-outdated' }

0 commit comments

Comments
 (0)