Skip to content

Commit 7509184

Browse files
icecrasher321claude
andcommitted
fix(organizations): atomic admin workspace sweep, removal-impact status in dialog, and preview-unavailable disclosure
Review round 1: the v1 admin add-member now commits membership and the workspace sweep in one transaction; the remove-member dialog holds confirm while the credential-impact check loads and shows a caution when it fails; a failed join preview flags joinPreviewUnavailable so the accept screen falls back to a generic migration notice. Also aligns the invite test's react-query mock and repairs two pre-existing docs type errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 306cc9d commit 7509184

9 files changed

Lines changed: 195 additions & 58 deletions

File tree

apps/docs/app/[lang]/[[...slug]]/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
121121
const urlParts = page.url.split('/').filter(Boolean)
122122
let currentPath = ''
123123

124-
urlParts.forEach((part, index) => {
124+
urlParts.forEach((part: string, index: number) => {
125125
if (index === 0 && SUPPORTED_LANGUAGES.has(part)) {
126126
currentPath = `/${part}`
127127
return
@@ -131,7 +131,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l
131131

132132
const name = part
133133
.split('-')
134-
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
134+
.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
135135
.join(' ')
136136

137137
if (index === urlParts.length - 1) {

apps/docs/app/api/chat/route.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { openai } from '@ai-sdk/openai'
2-
import { convertToModelMessages, stepCountIs, streamText, tool, type UIMessage } from 'ai'
2+
import {
3+
convertToModelMessages,
4+
jsonSchema,
5+
stepCountIs,
6+
streamText,
7+
tool,
8+
type UIMessage,
9+
} from 'ai'
310
import { sql } from 'drizzle-orm'
4-
import { z } from 'zod'
511
import { db, docsEmbeddings } from '@/lib/db'
612
import { generateSearchEmbedding } from '@/lib/embeddings'
713

@@ -332,8 +338,21 @@ export async function POST(req: Request) {
332338
searchDocs: tool({
333339
description:
334340
'Search the Sim documentation for relevant content. Use this before answering any question about Sim.',
335-
inputSchema: z.object({
336-
query: z.string().describe('A focused natural-language search query.'),
341+
/**
342+
* The SDK's own schema helper instead of a zod schema: the `ai`
343+
* package's zod-v4 typings lag the workspace zod version, so a zod
344+
* object here fails the tool() overloads whenever the two drift.
345+
*/
346+
inputSchema: jsonSchema<{ query: string }>({
347+
type: 'object',
348+
properties: {
349+
query: {
350+
type: 'string',
351+
description: 'A focused natural-language search query.',
352+
},
353+
},
354+
required: ['query'],
355+
additionalProperties: false,
337356
}),
338357
execute: async ({ query }) => searchDocs(query, locale),
339358
}),

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,18 @@ export const GET = withRouteHandler(
6666

6767
/**
6868
* Disclosure-only: a preview failure must never block viewing or
69-
* accepting the invitation itself. Expired-but-still-pending rows get
70-
* no preview — acceptance deterministically rejects them.
69+
* accepting the invitation itself — but it also must not read as
70+
* "nothing moves", so failures are flagged for the client to show a
71+
* generic migration notice. Expired-but-still-pending rows get no
72+
* preview — acceptance deterministically rejects them.
7173
*/
7274
let joinPreview = null
75+
let joinPreviewUnavailable = false
7376
if (isInvitee && inv.status === 'pending' && !isInvitationExpired(inv)) {
7477
try {
7578
joinPreview = await getInvitationJoinPreview(session.user.id, inv)
7679
} catch (previewError) {
80+
joinPreviewUnavailable = true
7781
logger.warn('Failed to compute invitation join preview', {
7882
invitationId: id,
7983
error: previewError,
@@ -83,6 +87,7 @@ export const GET = withRouteHandler(
8387

8488
return NextResponse.json({
8589
joinPreview,
90+
joinPreviewUnavailable,
8691
invitation: {
8792
id: inv.id,
8893
kind: inv.kind,

apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts

Lines changed: 83 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030

3131
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
3232
import { db } from '@sim/db'
33-
import { member, organization, user, userStats } from '@sim/db/schema'
33+
import { member, organization, user, userStats, workspace } from '@sim/db/schema'
3434
import { createLogger } from '@sim/logger'
3535
import { count, eq } from 'drizzle-orm'
3636
import {
@@ -39,10 +39,15 @@ import {
3939
} from '@/lib/api/contracts/v1/admin'
4040
import { parseRequest } from '@/lib/api/server'
4141
import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization'
42-
import { addUserToOrganization } from '@/lib/billing/organizations/membership'
42+
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
43+
import { ensureUserInOrganizationTx } from '@/lib/billing/organizations/membership'
4344
import { isBillingEnabled } from '@/lib/core/config/env-flags'
4445
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
45-
import { attachOwnedWorkspacesToOrganization } from '@/lib/workspaces/organization-workspaces'
46+
import { acquireInvitationMutationLocks } from '@/lib/invitations/locks'
47+
import {
48+
attachOwnedWorkspacesToOrganizationTx,
49+
ownedAttachableWorkspacesWhere,
50+
} from '@/lib/workspaces/organization-workspaces'
4651
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
4752
import {
4853
adminInvalidJsonResponse,
@@ -254,46 +259,84 @@ export const POST = withRouteHandler(
254259
)
255260
}
256261

257-
const result = await addUserToOrganization({
258-
userId,
259-
organizationId,
260-
role,
261-
skipBillingLogic: !isBillingEnabled,
262-
})
263-
264-
if (!result.success) {
265-
return badRequestResponse(result.error || 'Failed to add member')
266-
}
267-
268262
/**
269-
* A new member's owned personal workspaces follow them into the org
270-
* (collaborators stay external). Best-effort: membership is already
271-
* committed, and an attach failure leaves the pre-join status quo.
263+
* Membership and the workspace sweep commit or roll back together:
264+
* every workspace the new member owns follows them into the org
265+
* (collaborators stay external), and an attach failure aborts the whole
266+
* add instead of leaving a member whose workspaces escaped the sweep.
267+
* Lock order mirrors invitation acceptance: workspace advisory locks
268+
* first, then the organization lock inside ensureUserInOrganizationTx.
272269
*/
273-
try {
274-
const attach = await attachOwnedWorkspacesToOrganization({
270+
const result = await db.transaction(async (tx) => {
271+
const ownedWorkspaceIds = (
272+
await tx
273+
.select({ id: workspace.id })
274+
.from(workspace)
275+
.where(ownedAttachableWorkspacesWhere({ userId, includeArchived: true }))
276+
).map((row) => row.id)
277+
if (ownedWorkspaceIds.length > 0) {
278+
await acquireInvitationMutationLocks(tx, {
279+
invitationIds: [],
280+
workspaceIds: ownedWorkspaceIds,
281+
})
282+
}
283+
284+
const membership = await ensureUserInOrganizationTx(tx, {
285+
userId,
286+
organizationId,
287+
role,
288+
skipBillingLogic: !isBillingEnabled,
289+
})
290+
if (!membership.success || !membership.memberId || membership.alreadyMember) {
291+
return { membership, attachedWorkspaceIds: [], usageLimitUserIds: [] }
292+
}
293+
294+
if (ownedWorkspaceIds.length === 0) {
295+
return { membership, attachedWorkspaceIds: [], usageLimitUserIds: [] }
296+
}
297+
const attach = await attachOwnedWorkspacesToOrganizationTx(tx, {
275298
ownerUserId: userId,
276299
organizationId,
300+
workspaceIds: ownedWorkspaceIds,
277301
externalMemberPolicy: 'external-all',
302+
ownerMatch: 'owner',
278303
includeArchived: true,
279304
})
280-
if (attach.attachedWorkspaceIds.length > 0) {
281-
logger.info('Attached new member workspaces to organization', {
282-
userId,
283-
organizationId,
284-
attachedWorkspaceCount: attach.attachedWorkspaceIds.length,
285-
})
305+
return {
306+
membership,
307+
attachedWorkspaceIds: attach.attachedWorkspaceIds,
308+
usageLimitUserIds: attach.usageLimitUserIds,
286309
}
287-
} catch (attachError) {
288-
logger.error('Failed to attach new member workspaces to organization', {
310+
})
311+
312+
if (!result.membership.success || !result.membership.memberId) {
313+
return badRequestResponse(result.membership.error || 'Failed to add member')
314+
}
315+
if (result.membership.alreadyMember) {
316+
return badRequestResponse('User is already a member of this organization')
317+
}
318+
319+
if (result.attachedWorkspaceIds.length > 0) {
320+
logger.info('Attached new member workspaces to organization', {
289321
userId,
290322
organizationId,
291-
error: attachError,
323+
attachedWorkspaceCount: result.attachedWorkspaceIds.length,
292324
})
293325
}
326+
for (const limitUserId of new Set(result.usageLimitUserIds)) {
327+
try {
328+
await syncUsageLimitsFromSubscription(limitUserId)
329+
} catch (syncError) {
330+
logger.error('Failed to sync usage limits after admin member add', {
331+
userId: limitUserId,
332+
organizationId,
333+
error: syncError,
334+
})
335+
}
336+
}
294337

295338
const data: AdminMember = {
296-
id: result.memberId!,
339+
id: result.membership.memberId,
297340
userId,
298341
organizationId,
299342
role,
@@ -304,8 +347,9 @@ export const POST = withRouteHandler(
304347

305348
logger.info(`Admin API: Added user ${userId} to organization ${organizationId}`, {
306349
role,
307-
memberId: result.memberId,
308-
billingActions: result.billingActions,
350+
memberId: result.membership.memberId,
351+
billingActions: result.membership.billingActions,
352+
attachedWorkspaceCount: result.attachedWorkspaceIds.length,
309353
})
310354

311355
recordAudit({
@@ -315,16 +359,21 @@ export const POST = withRouteHandler(
315359
resourceType: AuditResourceType.ORGANIZATION,
316360
resourceId: organizationId,
317361
description: `Admin API added member to organization as ${role}`,
318-
metadata: { targetUserId: userId, role, memberId: result.memberId },
362+
metadata: {
363+
targetUserId: userId,
364+
role,
365+
memberId: result.membership.memberId,
366+
attachedWorkspaceIds: result.attachedWorkspaceIds,
367+
},
319368
request,
320369
})
321370

322371
return singleResponse({
323372
...data,
324373
action: 'created' as const,
325374
billingActions: {
326-
proUsageSnapshotted: result.billingActions.proUsageSnapshotted,
327-
proCancelledAtPeriodEnd: result.billingActions.proCancelledAtPeriodEnd,
375+
proUsageSnapshotted: result.membership.billingActions.proUsageSnapshotted,
376+
proCancelledAtPeriodEnd: result.membership.billingActions.proCancelledAtPeriodEnd,
328377
},
329378
})
330379
} catch (error) {

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

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,48 @@ vi.mock('next/navigation', () => ({
4646
useSearchParams: () => mockSearchParams,
4747
}))
4848

49-
vi.mock('@tanstack/react-query', () => ({
50-
useQueryClient: () => ({
51-
cancelQueries: mockCancelQueries,
52-
invalidateQueries: mockInvalidateQueries,
53-
setQueryData: mockSetQueryData,
54-
}),
55-
}))
49+
vi.mock('@tanstack/react-query', async () => {
50+
const React = await import('react')
51+
return {
52+
useQueryClient: () => ({
53+
cancelQueries: mockCancelQueries,
54+
invalidateQueries: mockInvalidateQueries,
55+
setQueryData: mockSetQueryData,
56+
}),
57+
/**
58+
* Minimal useQuery stand-in: runs the queryFn once when enabled and
59+
* exposes { data, error, isPending } — enough for the invitation fetch.
60+
*/
61+
useQuery: (options: {
62+
queryFn: (context: { signal?: AbortSignal }) => Promise<unknown>
63+
enabled?: boolean
64+
}) => {
65+
const [state, setState] = React.useState<{
66+
data: unknown
67+
error: unknown
68+
isPending: boolean
69+
}>({ data: undefined, error: null, isPending: true })
70+
const enabled = options.enabled !== false
71+
React.useEffect(() => {
72+
if (!enabled) return
73+
let cancelled = false
74+
options.queryFn({}).then(
75+
(data) => {
76+
if (!cancelled) setState({ data, error: null, isPending: false })
77+
},
78+
(error) => {
79+
if (!cancelled) setState({ data: undefined, error, isPending: false })
80+
}
81+
)
82+
return () => {
83+
cancelled = true
84+
}
85+
// eslint-disable-next-line react-hooks/exhaustive-deps
86+
}, [enabled])
87+
return state
88+
},
89+
}
90+
})
5691

5792
vi.mock('@/lib/api/client/request', () => ({
5893
requestJson: mockRequestJson,

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ export default function Invite() {
237237
})
238238
const invitation = invitationQuery.data?.invitation ?? null
239239
const joinPreview = invitationQuery.data?.joinPreview ?? null
240+
const joinPreviewUnavailable = invitationQuery.data?.joinPreviewUnavailable === true
240241
const isLoading = Boolean(session?.user) && invitationQuery.isPending
241242

242243
const fetchError = invitationQuery.error
@@ -455,7 +456,15 @@ export default function Invite() {
455456

456457
const isOrg = invitation?.kind === 'organization'
457458
const organizationLabel = invitation?.organizationName || 'the organization'
458-
const migrationNotice = buildWorkspaceMigrationNotice(joinPreview, organizationLabel)
459+
/**
460+
* When the server could not compute the preview, fall back to a generic
461+
* migration notice for membership invites — a missing preview must never
462+
* read as "nothing moves".
463+
*/
464+
const migrationNotice =
465+
joinPreviewUnavailable && invitation?.membershipIntent !== 'external'
466+
? ` If you own personal workspaces, accepting membership moves them into ${organizationLabel}: its admins get full access, and they stay with the organization if you leave.`
467+
: buildWorkspaceMigrationNotice(joinPreview, organizationLabel)
459468

460469
return (
461470
<InviteLayout>

apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ interface RemoveMemberDialogProps {
1616
* never blocking.
1717
*/
1818
breakingCredentials?: string[]
19+
/** The impact check is still loading — confirm is held until it resolves. */
20+
credentialImpactPending?: boolean
21+
/** The impact check failed — removal stays possible, with a caution shown. */
22+
credentialImpactFailed?: boolean
1923
isSubmitting?: boolean
2024
error?: Error | null
2125
onOpenChange: (open: boolean) => void
@@ -33,6 +37,8 @@ export function RemoveMemberDialog({
3337
isSelfRemoval = false,
3438
isExternalRemoval = false,
3539
breakingCredentials = [],
40+
credentialImpactPending = false,
41+
credentialImpactFailed = false,
3642
isSubmitting = false,
3743
}: RemoveMemberDialogProps) {
3844
const title = isSelfRemoval
@@ -43,8 +49,9 @@ export function RemoveMemberDialog({
4349

4450
const errorMessage = error ? getErrorMessage(error) || null : null
4551

46-
const credentialWarning =
47-
breakingCredentials.length > 0
52+
const credentialWarning = credentialImpactFailed
53+
? `Couldn't check which credentials ${isSelfRemoval ? 'you own' : 'they own'} will be affected — connected accounts backed by ${isSelfRemoval ? 'your' : 'their'} identity may stop working after removal.`
54+
: breakingCredentials.length > 0
4855
? `${breakingCredentials.length === 1 ? 'A credential' : `${breakingCredentials.length} credentials`} ${
4956
isSelfRemoval ? 'you own' : 'they own'
5057
} (${formatQuotedNameList(breakingCredentials, MAX_LISTED_CREDENTIALS)}) will stop working in organization workspaces until another member reconnects ${
@@ -79,7 +86,9 @@ export function RemoveMemberDialog({
7986
confirm={{
8087
label: isSelfRemoval ? 'Leave Organization' : 'Remove',
8188
onClick: () => onConfirmRemove(),
82-
pending: isSubmitting,
89+
pending: isSubmitting || credentialImpactPending,
90+
pendingLabel:
91+
credentialImpactPending && !isSubmitting ? 'Checking credentials…' : undefined,
8392
}}
8493
>
8594
{credentialWarning ? (

0 commit comments

Comments
 (0)