Skip to content

Commit f617a91

Browse files
icecrasher321claude
andcommitted
fix(invitations): carry the membership outcome in the disclosure token
Empty disclosure skipped membership consent. The token was only the workspace-id list, so a no-join preview and a will-join preview for someone who owns nothing both echoed `[]`. Neither guard could tell those apart: the forward check compares sweep sets, and the reverse check required a non-empty disclosed set. An invitee who left their other organization between preview and accept would therefore be silently made a seat-consuming member after being told they would stay external, and the mirror case could silently demote a promised join. The accept body now also carries `disclosedWillJoinOrganization`, compared against the resolved outcome before any write, so consent covers the membership decision and not just the migration. Both accept surfaces send it. This was widened by the previous commit: keying the membership notice on the preview made the screen promise a join outcome the token never verified. In-app accept errors lacked copy. `getInvitationErrorMessage` omitted `external-requires-paid-plan`, `disclosure-outdated`, and `workspace-not-found`, so those failures fell through to the generic "may have expired" fallback. `disclosure-outdated` became newly reachable in-app the moment that path started sending the token, so the gap arrived with the fix for it. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a617b47 commit f617a91

8 files changed

Lines changed: 100 additions & 1 deletion

File tree

apps/sim/app/api/invitations/[id]/accept/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const POST = withRouteHandler(
2828
invitationId: id,
2929
token: parsed.data.body.token ?? null,
3030
disclosedWorkspaceIds: parsed.data.body.disclosedWorkspaceIds,
31+
disclosedWillJoinOrganization: parsed.data.body.disclosedWillJoinOrganization,
3132
request,
3233
})
3334

apps/sim/app/invite/[id]/invite.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ export default function Invite() {
266266
* must also conflict if acceptance would sweep anything.
267267
*/
268268
disclosedWorkspaceIds: joinPreview ? joinPreview.workspaceIdsToMove : undefined,
269+
disclosedWillJoinOrganization: joinPreview ? joinPreview.willJoinOrganization : undefined,
269270
},
270271
})
271272

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export function ViewInvitationsModal({ open, onOpenChange }: ViewInvitationsModa
9393
const result = await acceptInvitation.mutateAsync({
9494
invitationId: inv.id,
9595
disclosedWorkspaceIds: inv.joinPreview?.workspaceIdsToMove,
96+
disclosedWillJoinOrganization: inv.joinPreview?.willJoinOrganization,
9697
})
9798
toast.success(`Joined ${invitationLabel(inv)}`)
9899
onOpenChange(false)

apps/sim/hooks/queries/invitations.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,13 +166,15 @@ export function useAcceptMyInvitation() {
166166
mutationFn: async ({
167167
invitationId,
168168
disclosedWorkspaceIds,
169+
disclosedWillJoinOrganization,
169170
}: {
170171
invitationId: string
171172
disclosedWorkspaceIds?: string[]
173+
disclosedWillJoinOrganization?: boolean
172174
}) =>
173175
requestJson(acceptInvitationContract, {
174176
params: { id: invitationId },
175-
body: { disclosedWorkspaceIds },
177+
body: { disclosedWorkspaceIds, disclosedWillJoinOrganization },
176178
}),
177179
onSuccess: () => {
178180
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })

apps/sim/lib/api/contracts/invitations.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ export const invitationActionBodySchema = z.object({
107107
* set the user saw.
108108
*/
109109
disclosedWorkspaceIds: z.array(z.string()).max(DISCLOSED_WORKSPACE_ID_LIMIT).optional(),
110+
/**
111+
* The membership outcome the accept screen disclosed. The workspace-id list
112+
* alone cannot express it — a no-join preview and a will-join preview for
113+
* someone who owns nothing both disclose `[]` — so consent to becoming a
114+
* seat-consuming member is carried explicitly.
115+
*/
116+
disclosedWillJoinOrganization: z.boolean().optional(),
110117
})
111118

112119
export const invitationDetailsSchema = z.object({

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,65 @@ describe('acceptInvitation', () => {
147147
})
148148
})
149149

150+
it('rejects when the disclosed join outcome no longer matches (empty sweep set)', async () => {
151+
/**
152+
* The workspace-id token cannot express this: the invitee owns nothing, so
153+
* both a no-join and a will-join preview disclose []. Without the explicit
154+
* membership half of the token, leaving another org between preview and
155+
* accept would silently create a seat-consuming membership the screen said
156+
* would stay external.
157+
*/
158+
queueWhereResponses([
159+
[
160+
{
161+
id: 'inv-1',
162+
kind: 'workspace',
163+
email: 'invitee@example.com',
164+
organizationId: 'org-1',
165+
membershipIntent: 'internal',
166+
inviterId: 'inviter-1',
167+
role: 'member',
168+
status: 'pending',
169+
token: 'tok-1',
170+
expiresAt: new Date(Date.now() + 60_000),
171+
createdAt: new Date(),
172+
updatedAt: new Date(),
173+
},
174+
],
175+
[
176+
{
177+
id: 'grant-1',
178+
workspaceId: 'workspace-1',
179+
permission: 'write',
180+
workspaceName: 'Workspace',
181+
},
182+
],
183+
[{ name: 'Acme' }],
184+
[{ name: 'Inviter', email: 'inviter@example.com' }],
185+
[],
186+
[],
187+
[{ variables: {} }],
188+
])
189+
190+
const result = await acceptInvitation({
191+
userId: 'invitee-user',
192+
userEmail: 'invitee@example.com',
193+
invitationId: 'inv-1',
194+
token: 'tok-1',
195+
actorName: 'Invitee',
196+
// The screen promised "you will not join" — acceptance resolves to a join.
197+
disclosedWorkspaceIds: [],
198+
disclosedWillJoinOrganization: false,
199+
request: new Request('http://localhost/api/invitations/inv-1/accept'),
200+
})
201+
202+
expect(result.success).toBe(false)
203+
if (!result.success) {
204+
expect(result.kind).toBe('disclosure-outdated')
205+
}
206+
expect(mockEnsureUserInOrganization).not.toHaveBeenCalled()
207+
})
208+
150209
it('accepts a forced-external invitation from a free invitee already in another org', async () => {
151210
/**
152211
* Cross-org invitees are stamped external regardless of the inviter's

apps/sim/lib/invitations/core.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,13 @@ export interface AcceptInvitationInput {
396396
* differs — the user must see the refreshed notice before consenting.
397397
*/
398398
disclosedWorkspaceIds?: string[]
399+
/**
400+
* Whether the accept screen told the invitee they would join the
401+
* organization. Verified against the resolved outcome so a membership the
402+
* user was never shown can never be created, and a membership they were
403+
* promised can never be silently downgraded.
404+
*/
405+
disclosedWillJoinOrganization?: boolean
399406
request?: { headers: { get(name: string): string | null } }
400407
}
401408

@@ -719,6 +726,22 @@ async function acceptLockedInvitation(
719726
return { success: false, kind: 'external-requires-paid-plan' }
720727
}
721728

729+
/**
730+
* Membership consent guard. The workspace-id token cannot distinguish "you
731+
* 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.
737+
*/
738+
if (
739+
input.disclosedWillJoinOrganization !== undefined &&
740+
input.disclosedWillJoinOrganization !== shouldJoinOrganization
741+
) {
742+
return { success: false, kind: 'disclosure-outdated' }
743+
}
744+
722745
/**
723746
* A member-role organization invite whose grants ALL left the stamped
724747
* organization can never land its member anywhere — fail before any

apps/sim/lib/invitations/error-messages.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ const INVITATION_ERROR_MESSAGES: Record<string, string> = {
1919
'This organization has reached its seat limit. Ask an admin to add seats, then try again.',
2020
'upgrade-required':
2121
'The workspace owner needs an active paid plan before you can join. Ask them to update it, then try again.',
22+
'external-requires-paid-plan':
23+
'External collaborators need their own paid Sim plan. Upgrade your plan, or ask the organization to re-invite you as a member — that uses one of their seats instead.',
24+
'disclosure-outdated':
25+
'What accepting does changed since this list loaded. Reopen your invitations to see the updated details, then accept again.',
26+
'workspace-not-found': 'The workspace this invitation points at could not be found.',
2227
'server-error': 'Something went wrong processing the invitation. Please try again.',
2328
}
2429

0 commit comments

Comments
 (0)