Skip to content

Commit 4297c05

Browse files
icecrasher321claude
andcommitted
fix(invitations): make the join preview a discriminated outcome
The preview returned one no-join shape for five different results — external intent, already a member, a membership in another organization, a dead-grant rejection, and a billing rejection. Two accumulating booleans could not separate them, and the accept screen rendered the external copy for all of them: it told people whose acceptance would fail with `upgrade-required` or `workspace-not-found` that they were getting workspace access without a seat. It now reports one `outcome`: `will-join` (a seat is taken), `already-member` (only workspace access changes), `external` (never a seat), or `blocked` (acceptance fails, so nothing is promised). `blocked` renders no membership notice — silence is accurate where the external claim was false. The accept button is deliberately left enabled: those cases already fail closed with the correct error, and choosing what to actively tell someone whose organization's payment lapsed is a product decision, not a review fix. This also fixes a live mis-attribution the previous commit's guard introduced. The consent check ran before the gates that produce the real cause, so a blocked invitation returned `disclosure-outdated` — and the retry re-rendered the same preview, leaving the invitee looping with no explanation. The guard now sits after the dead-grant gate, and a disclosed `blocked` skips the comparison so the billing gate below can surface `upgrade-required` instead. The accept body carries `disclosedOutcome` in place of the boolean; the membership comparison is unchanged (`will-join` versus a new membership being created), so no acceptance that previously succeeded now fails. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ea6d205 commit 4297c05

9 files changed

Lines changed: 223 additions & 111 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +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,
31+
disclosedOutcome: parsed.data.body.disclosedOutcome,
3232
request,
3333
})
3434

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +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,
269+
disclosedOutcome: joinPreview?.outcome,
270270
},
271271
})
272272

@@ -521,14 +521,14 @@ export default function Invite() {
521521
/**
522522
* A personal-workspace invite has no organization id and no organization
523523
* name yet — acceptance creates one by converting the billed owner's Pro to
524-
* Team — so `willJoinOrganization` is the authoritative signal that a
524+
* Team — so a `will-join` outcome is the authoritative signal that a
525525
* membership and seat are involved. Gating on the ids alone silenced the
526526
* disclosure for exactly the case that creates the membership.
527527
*/
528528
isOrganizationScoped: Boolean(
529529
invitation?.organizationId ||
530530
joinPreview?.organizationName ||
531-
joinPreview?.willJoinOrganization
531+
joinPreview?.outcome === 'will-join'
532532
),
533533
})
534534

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,12 @@ function invitationDisclosure(inv: MyInvitation): string {
5757
membershipIntent: inv.membershipIntent,
5858
isOrganizationAdminRole: isOrgAdminRole(inv.role),
5959
organizationLabel,
60-
/** `willJoinOrganization` also covers a personal-workspace invite, which has
60+
/** A `will-join` outcome also covers a personal-workspace invite, which has
6161
* no organization id or name until acceptance creates one. */
6262
isOrganizationScoped: Boolean(
6363
inv.organizationId ||
6464
inv.joinPreview?.organizationName ||
65-
inv.joinPreview?.willJoinOrganization
65+
inv.joinPreview?.outcome === 'will-join'
6666
),
6767
})
6868
const migration = buildWorkspaceMigrationNotice({
@@ -99,7 +99,7 @@ export function ViewInvitationsModal({ open, onOpenChange }: ViewInvitationsModa
9999
const result = await acceptInvitation.mutateAsync({
100100
invitationId: inv.id,
101101
disclosedWorkspaceIds: inv.joinPreview?.workspaceIdsToMove,
102-
disclosedWillJoinOrganization: inv.joinPreview?.willJoinOrganization,
102+
disclosedOutcome: inv.joinPreview?.outcome,
103103
})
104104
toast.success(`Joined ${invitationLabel(inv)}`)
105105
onOpenChange(false)

apps/sim/hooks/queries/invitations.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
batchWorkspaceInvitationsContract,
88
cancelInvitationContract,
99
getInvitationContract,
10+
type InvitationJoinOutcome,
1011
listMyInvitationsContract,
1112
listWorkspaceInvitationsContract,
1213
type MyInvitation,
@@ -166,15 +167,15 @@ export function useAcceptMyInvitation() {
166167
mutationFn: async ({
167168
invitationId,
168169
disclosedWorkspaceIds,
169-
disclosedWillJoinOrganization,
170+
disclosedOutcome,
170171
}: {
171172
invitationId: string
172173
disclosedWorkspaceIds?: string[]
173-
disclosedWillJoinOrganization?: boolean
174+
disclosedOutcome?: InvitationJoinOutcome
174175
}) =>
175176
requestJson(acceptInvitationContract, {
176177
params: { id: invitationId },
177-
body: { disclosedWorkspaceIds, disclosedWillJoinOrganization },
178+
body: { disclosedWorkspaceIds, disclosedOutcome },
178179
}),
179180
onSuccess: () => {
180181
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })

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

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,21 @@ export const invitationActionParamsSchema = z.object({
9898
id: z.string({ error: 'Invitation ID is required' }).min(1, 'Invitation ID is required'),
9999
})
100100

101+
/**
102+
* What accepting an invitation will actually do. Four outcomes rather than a
103+
* boolean, because each needs different disclosure: `will-join` takes a seat,
104+
* `already-member` changes nothing but workspace access, `external` never takes
105+
* a seat, and `blocked` means acceptance fails so nothing is promised.
106+
*/
107+
export const invitationJoinOutcomeSchema = z.enum([
108+
'will-join',
109+
'already-member',
110+
'external',
111+
'blocked',
112+
])
113+
114+
export type InvitationJoinOutcome = z.output<typeof invitationJoinOutcomeSchema>
115+
101116
export const invitationActionBodySchema = z.object({
102117
token: z.string().min(1).optional(),
103118
/**
@@ -108,12 +123,14 @@ export const invitationActionBodySchema = z.object({
108123
*/
109124
disclosedWorkspaceIds: z.array(z.string()).max(DISCLOSED_WORKSPACE_ID_LIMIT).optional(),
110125
/**
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.
126+
* The outcome the accept screen disclosed. The workspace-id list alone cannot
127+
* express it — a no-join preview and a will-join preview for someone who owns
128+
* nothing both disclose `[]` — so consent to becoming a seat-consuming member
129+
* is carried explicitly. Sending `blocked` tells acceptance the screen already
130+
* said this would fail, so it should surface the real cause rather than a
131+
* consent mismatch.
115132
*/
116-
disclosedWillJoinOrganization: z.boolean().optional(),
133+
disclosedOutcome: invitationJoinOutcomeSchema.optional(),
117134
})
118135

119136
export const invitationDetailsSchema = z.object({
@@ -139,14 +156,7 @@ export const invitationDetailsSchema = z.object({
139156
})
140157

141158
export const invitationJoinPreviewSchema = z.object({
142-
willJoinOrganization: z.boolean(),
143-
/**
144-
* The invitee is already a member of the organization acceptance lands in, so
145-
* only workspace access changes. Reported separately because it shares
146-
* `willJoinOrganization: false` with the external case, which means something
147-
* different to the person accepting.
148-
*/
149-
alreadyMemberOfOrganization: z.boolean(),
159+
outcome: invitationJoinOutcomeSchema,
150160
/** Name of the organization acceptance will actually join. */
151161
organizationName: z.string().nullable(),
152162
workspacesToMove: z.array(z.string()).max(DISCLOSED_WORKSPACE_ID_LIMIT),

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

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ describe('acceptInvitation', () => {
195195
actorName: 'Invitee',
196196
// The screen promised "you will not join" — acceptance resolves to a join.
197197
disclosedWorkspaceIds: [],
198-
disclosedWillJoinOrganization: false,
198+
disclosedOutcome: 'external' as const,
199199
request: new Request('http://localhost/api/invitations/inv-1/accept'),
200200
})
201201

@@ -940,6 +940,65 @@ describe('acceptInvitation', () => {
940940
expect(mockSyncUsageLimitsFromSubscription).toHaveBeenCalledWith('invitee-user')
941941
})
942942

943+
it('surfaces the real cause, not a consent mismatch, when the disclosure said blocked', async () => {
944+
/**
945+
* The preview reports `blocked` for dead grants, so the screen promised
946+
* nothing. Acceptance must return the real cause (`workspace-not-found`)
947+
* rather than `disclosure-outdated` — the latter renders the same preview on
948+
* retry, so the invitee would loop with no explanation.
949+
*/
950+
mockGetWorkspaceWithOwner.mockResolvedValue({
951+
id: 'workspace-1',
952+
name: 'Workspace',
953+
ownerId: 'owner-1',
954+
organizationId: 'org-1',
955+
workspaceMode: 'organization',
956+
billedAccountUserId: 'owner-1',
957+
})
958+
queueWhereResponses([
959+
[
960+
{
961+
id: 'inv-1',
962+
kind: 'organization',
963+
email: 'invitee@example.com',
964+
organizationId: 'org-1',
965+
membershipIntent: 'internal',
966+
inviterId: 'owner-1',
967+
role: 'member',
968+
status: 'pending',
969+
token: 'tok-1',
970+
expiresAt: new Date(Date.now() + 60_000),
971+
createdAt: new Date(),
972+
updatedAt: new Date(),
973+
},
974+
],
975+
[
976+
{
977+
id: 'grant-1',
978+
workspaceId: 'workspace-1',
979+
permission: 'write',
980+
workspaceName: 'Workspace',
981+
},
982+
],
983+
[{ name: 'Acme' }],
984+
[{ name: 'Owner', email: 'owner@example.com' }],
985+
[],
986+
[],
987+
])
988+
989+
const result = await acceptInvitation({
990+
userId: 'invitee-user',
991+
userEmail: 'invitee@example.com',
992+
invitationId: 'inv-1',
993+
token: 'tok-1',
994+
disclosedWorkspaceIds: [],
995+
disclosedOutcome: 'blocked',
996+
})
997+
998+
expect(result).toEqual({ success: false, kind: 'workspace-not-found' })
999+
expect(mockEnsureUserInOrganization).not.toHaveBeenCalled()
1000+
})
1001+
9431002
it('rolls back a member-role org acceptance when every grant turned stale', async () => {
9441003
mockGetWorkspaceWithOwner.mockResolvedValue({
9451004
id: 'workspace-1',
@@ -1457,7 +1516,7 @@ describe('acceptInvitation', () => {
14571516
invitationId: 'inv-1',
14581517
token: 'tok-1',
14591518
disclosedWorkspaceIds: [],
1460-
disclosedWillJoinOrganization: false,
1519+
disclosedOutcome: 'external' as const,
14611520
})
14621521

14631522
expect(result.success ? 'ok' : result.kind).toBe('ok')

0 commit comments

Comments
 (0)