Skip to content

Commit d5eb0b1

Browse files
icecrasher321claude
andcommitted
fix(invitations): gate all-stale member joins before disclosure and always refetch removal impact
The all-stale check for member-role org invites now runs before any mutation and before the disclosure comparison, so an invite whose grants all left the org fails with workspace-not-found instead of trapping owners of personal workspaces in a disclosure-outdated retry loop. The removal-impact query drops its stale window (staleTime 0): every dialog open refetches while the confirm is held on isFetching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5e56b1d commit d5eb0b1

3 files changed

Lines changed: 44 additions & 14 deletions

File tree

apps/sim/hooks/queries/organization.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,12 @@ export const ORGANIZATION_SUBSCRIPTION_STALE_TIME = 30 * 1000
5656
export const ORGANIZATION_BILLING_STALE_TIME = 30 * 1000
5757
export const ORGANIZATION_MEMBERS_STALE_TIME = 30 * 1000
5858
export const ORGANIZATION_MEMBER_USAGE_LIMIT_STALE_TIME = 30 * 1000
59-
export const ORGANIZATION_REMOVAL_IMPACT_STALE_TIME = 30 * 1000
59+
/**
60+
* Zero: removal impact is a consent disclosure, so every dialog open must
61+
* refetch — a cached list may omit credentials added moments ago, and the
62+
* dialog holds its confirm on `isFetching` until fresh data lands.
63+
*/
64+
export const ORGANIZATION_REMOVAL_IMPACT_STALE_TIME = 0
6065

6166
type OrganizationSubscriptionCandidate = {
6267
id: string

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

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -692,12 +692,6 @@ describe('acceptInvitation', () => {
692692
workspaceMode: 'organization',
693693
billedAccountUserId: 'owner-1',
694694
})
695-
mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({
696-
success: true,
697-
organizationId: 'org-1',
698-
fixedSeats: false,
699-
})
700-
701695
queueWhereResponses([
702696
[
703697
{
@@ -727,14 +721,8 @@ describe('acceptInvitation', () => {
727721
[{ name: 'Owner', email: 'owner@example.com' }],
728722
// Invitee-owned personal workspaces for the acceptance lock plan.
729723
[],
730-
// Post-join owned-set re-check under the billing-identity lock.
731-
[],
732-
// Grant-txn membership re-check under the lock: member still present.
733-
[{ id: 'member-1' }],
734-
// Invitation status update under the lock.
724+
// Pre-join staleness gate: no grant workspace remains in the stamped org.
735725
[],
736-
// The granted workspace left the stamped organization.
737-
[{ organizationId: 'org-elsewhere' }],
738726
])
739727

740728
const result = await acceptInvitation({
@@ -745,6 +733,7 @@ describe('acceptInvitation', () => {
745733
})
746734

747735
expect(result).toEqual({ success: false, kind: 'workspace-not-found' })
736+
expect(mockEnsureUserInOrganization).not.toHaveBeenCalled()
748737
expect(auditMock.recordAudit).not.toHaveBeenCalled()
749738
})
750739

@@ -986,6 +975,8 @@ describe('acceptInvitation', () => {
986975
[{ name: 'Owner', email: 'owner@example.com' }],
987976
// Invitee-owned personal workspaces for the acceptance lock plan.
988977
[],
978+
// Pre-join staleness gate: the granted workspace is still in the org.
979+
[{ id: 'workspace-1' }],
989980
// Post-join owned-set re-check under the billing-identity lock.
990981
[],
991982
// Grant-txn membership re-check under the lock: member still present.

apps/sim/lib/invitations/core.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,40 @@ async function acceptLockedInvitation(
710710
shouldJoinOrganization = false
711711
}
712712

713+
/**
714+
* A member-role organization invite whose grants ALL left the stamped
715+
* organization can never land its member anywhere — fail before any
716+
* mutation. This must precede the disclosure check: the preview mirrors
717+
* this gate with an empty disclosure, and rejecting on disclosure first
718+
* would loop the client on disclosure-outdated instead of surfacing the
719+
* real cause. The grant rows are advisory-locked, so this read cannot
720+
* change for the rest of the transaction.
721+
*/
722+
if (
723+
shouldJoinOrganization &&
724+
inv.kind === 'organization' &&
725+
inv.organizationId &&
726+
!isOrgAdminRole(inv.role) &&
727+
inv.grants.length > 0
728+
) {
729+
const [liveGrant] = await tx
730+
.select({ id: workspace.id })
731+
.from(workspace)
732+
.where(
733+
and(
734+
inArray(
735+
workspace.id,
736+
inv.grants.map((grant) => grant.workspaceId)
737+
),
738+
eq(workspace.organizationId, inv.organizationId)
739+
)
740+
)
741+
.limit(1)
742+
if (!liveGrant) {
743+
return { success: false, kind: 'workspace-not-found' }
744+
}
745+
}
746+
713747
let targetOrganizationId = workspaceOrganizationId
714748

715749
if (shouldJoinOrganization) {

0 commit comments

Comments
 (0)