From 991f71e9612f1d0c49668ee015f77e8587d6c841 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 23 Jul 2026 20:30:07 -0700 Subject: [PATCH 01/16] feat(invites): explicit external members --- apps/sim/app/api/invitations/[id]/route.ts | 63 +++- .../[id]/invitations/route.test.ts | 30 +- .../organizations/[id]/invitations/route.ts | 34 +- .../api/organizations/[id]/members/route.ts | 19 +- .../[id]/removal-impact/route.ts | 52 +++ .../admin/organizations/[id]/members/route.ts | 28 ++ .../api/workspaces/invitations/batch/route.ts | 1 + apps/sim/app/invite/[id]/invite.tsx | 86 +++-- .../remove-member-dialog.tsx | 27 +- .../team-management/team-management.tsx | 10 + .../components/invite-modal/invite-modal.tsx | 37 +- apps/sim/app/workspace/page.tsx | 133 ++++--- apps/sim/hooks/queries/invitations.ts | 38 ++ apps/sim/hooks/queries/organization.ts | 36 ++ apps/sim/lib/admin/dashboard.ts | 17 +- apps/sim/lib/api/contracts/invitations.ts | 13 + apps/sim/lib/api/contracts/organization.ts | 42 ++- .../lib/api/contracts/v1/admin/dashboard.ts | 4 +- apps/sim/lib/invitations/core.test.ts | 246 +++++++++++++ apps/sim/lib/invitations/core.ts | 338 ++++++++++++++---- apps/sim/lib/invitations/send.test.ts | 20 ++ apps/sim/lib/invitations/send.ts | 21 ++ .../lib/invitations/workspace-invitations.ts | 69 +++- apps/sim/lib/workspaces/admin-move.test.ts | 8 +- apps/sim/lib/workspaces/admin-move.ts | 10 +- .../lib/workspaces/organization-workspaces.ts | 107 ++++-- packages/utils/src/string.test.ts | 22 +- packages/utils/src/string.ts | 18 + 28 files changed, 1287 insertions(+), 242 deletions(-) create mode 100644 apps/sim/app/api/organizations/[id]/removal-impact/route.ts create mode 100644 apps/sim/lib/invitations/send.test.ts diff --git a/apps/sim/app/api/invitations/[id]/route.ts b/apps/sim/app/api/invitations/[id]/route.ts index f17d2e10a59..929681af4ac 100644 --- a/apps/sim/app/api/invitations/[id]/route.ts +++ b/apps/sim/app/api/invitations/[id]/route.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { invitation, invitationWorkspaceGrant } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { normalizeEmail } from '@sim/utils/string' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -14,7 +15,12 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { cancelInvitation, getInvitationById } from '@/lib/invitations/core' +import { + cancelInvitation, + getInvitationById, + getInvitationJoinPreview, + isInvitationExpired, +} from '@/lib/invitations/core' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InvitationsAPI') @@ -42,22 +48,41 @@ export const GET = withRouteHandler( const isInvitee = normalizeEmail(session.user.email || '') === normalizeEmail(inv.email) const tokenMatches = !!token && token === inv.token - let hasAdminView = false - if (inv.organizationId) { - hasAdminView = await isOrganizationOwnerOrAdmin(session.user.id, inv.organizationId) - } - if (!hasAdminView && inv.grants.length > 0) { - const adminChecks = await Promise.all( - inv.grants.map((grant) => hasWorkspaceAdminAccess(session.user.id, grant.workspaceId)) - ) - hasAdminView = adminChecks.some(Boolean) + if (!isInvitee && !tokenMatches) { + let hasAdminView = false + if (inv.organizationId) { + hasAdminView = await isOrganizationOwnerOrAdmin(session.user.id, inv.organizationId) + } + if (!hasAdminView && inv.grants.length > 0) { + const adminChecks = await Promise.all( + inv.grants.map((grant) => hasWorkspaceAdminAccess(session.user.id, grant.workspaceId)) + ) + hasAdminView = adminChecks.some(Boolean) + } + if (!hasAdminView) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } } - if (!isInvitee && !tokenMatches && !hasAdminView) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + /** + * Disclosure-only: a preview failure must never block viewing or + * accepting the invitation itself. Expired-but-still-pending rows get + * no preview — acceptance deterministically rejects them. + */ + let joinPreview = null + if (isInvitee && inv.status === 'pending' && !isInvitationExpired(inv)) { + try { + joinPreview = await getInvitationJoinPreview(session.user.id, inv) + } catch (previewError) { + logger.warn('Failed to compute invitation join preview', { + invitationId: id, + error: previewError, + }) + } } return NextResponse.json({ + joinPreview, invitation: { id: inv.id, kind: inv.kind, @@ -128,6 +153,20 @@ export const PATCH = withRouteHandler( { status: 403 } ) } + /** + * A member-role invite without workspace grants would leave the + * invitee workspace-less after accepting (admins derive access to + * every organization workspace; members do not). + */ + if (!isOrgAdminRole(role) && inv.grants.length === 0) { + return NextResponse.json( + { + error: + 'Member invitations must include at least one workspace. Keep the admin role or send a new invitation with workspace access.', + }, + { status: 400 } + ) + } } const grantsToApply = grants ?? [] diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts index e4592e1318f..c85402224e5 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts @@ -112,7 +112,7 @@ describe('POST /api/organizations/[id]/invitations', () => { const response = await POST( createMockRequest( 'POST', - { emails: ['invitee@example.com'] }, + { emails: ['invitee@example.com'], role: 'admin' }, {}, 'http://localhost/api/organizations/org-1/invitations' ), @@ -125,7 +125,7 @@ describe('POST /api/organizations/[id]/invitations', () => { kind: 'organization', email: 'invitee@example.com', organizationId: 'org-1', - role: 'member', + role: 'admin', grants: [], }) ) @@ -135,6 +135,28 @@ describe('POST /api/organizations/[id]/invitations', () => { expect(mockCancelPendingInvitation).not.toHaveBeenCalled() }) + it('rejects grant-less member-role invitations on the non-batch path', async () => { + mockGetSession.mockResolvedValue( + createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) + ) + queueOwnerAndOrg() + + const response = await POST( + createMockRequest( + 'POST', + { emails: ['invitee@example.com'] }, + {}, + 'http://localhost/api/organizations/org-1/invitations' + ), + { params: Promise.resolve({ id: 'org-1' }) } + ) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toContain('Member invitations must include at least one workspace') + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + }) + it('adds an existing member directly to selected workspaces they lack (no invitation/email)', async () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) @@ -356,7 +378,7 @@ describe('POST /api/organizations/[id]/invitations', () => { const response = await POST( createMockRequest( 'POST', - { emails: ['member@example.com'] }, + { emails: ['member@example.com'], role: 'admin' }, {}, 'http://localhost/api/organizations/org-1/invitations' ), @@ -385,7 +407,7 @@ describe('POST /api/organizations/[id]/invitations', () => { const response = await POST( createMockRequest( 'POST', - { emails: ['invitee@example.com'] }, + { emails: ['invitee@example.com'], role: 'admin' }, {}, 'http://localhost/api/organizations/org-1/invitations' ), diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index 650aa0f7e90..bd27290831c 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -155,6 +155,31 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } + /** + * Member-role invites must carry workspace access so the invitee has a + * workspace to land in after accepting — a member with no workspace + * grants (and no admin-derived access) would hit a workspace-less dead + * end. Admin invites are exempt: admins derive access to every + * organization workspace. Both checks run before the validate-only path + * so validation never approves a request the send would reject; the + * domain-layer backstop lives in createPendingInvitation. + */ + if (!isBatch && !isOrgAdminRole(role)) { + return NextResponse.json( + { + error: + 'Member invitations must include at least one workspace. Send batch=true with workspaceInvitations so the invitee has a workspace to land in.', + }, + { status: 400 } + ) + } + if (isBatch && (!Array.isArray(workspaceInvitations) || workspaceInvitations.length === 0)) { + return NextResponse.json( + { error: 'Select at least one organization workspace for this invitation.' }, + { status: 400 } + ) + } + if (validateOnly) { const validationResult = await validateBulkInvitations(organizationId, invitationEmails) return NextResponse.json({ @@ -192,14 +217,7 @@ export const POST = withRouteHandler( const validGrants: WorkspaceGrantPayload[] = [] const workspaceNameById = new Map() - if (isBatch) { - if (!Array.isArray(workspaceInvitations) || workspaceInvitations.length === 0) { - return NextResponse.json( - { error: 'Select at least one organization workspace for this invitation.' }, - { status: 400 } - ) - } - + if (isBatch && Array.isArray(workspaceInvitations)) { for (const wsInvitation of workspaceInvitations) { if (validGrants.some((grant) => grant.workspaceId === wsInvitation.workspaceId)) { continue diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 74cb552e8e7..04e62e00b7e 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -210,7 +210,7 @@ export const POST = withRouteHandler( await validateInvitationsAllowed(session.user.id, { organizationId }) - const { email, role = 'member' } = parsed.data.body + const { email, role } = parsed.data.body // Validate and normalize email const normalizedEmail = normalizeEmail(email) @@ -240,6 +240,23 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } + /** + * Member-role invites must carry workspace access so the invitee has a + * workspace to land in after accepting — a member with no workspace + * grants (and no admin-derived access) would hit a workspace-less dead + * end. Admin invites are exempt: admins derive access to every + * organization workspace. + */ + if (!isOrgAdminRole(role)) { + return NextResponse.json( + { + error: + 'Member invitations must include at least one workspace. Use the invitations endpoint with workspaceInvitations so the invitee has a workspace to land in.', + }, + { status: 400 } + ) + } + // Check seat availability const seatValidation = await validateSeatAvailability(organizationId, 1) if (!seatValidation.canInvite) { diff --git a/apps/sim/app/api/organizations/[id]/removal-impact/route.ts b/apps/sim/app/api/organizations/[id]/removal-impact/route.ts new file mode 100644 index 00000000000..8b3e9c8a3a9 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/removal-impact/route.ts @@ -0,0 +1,52 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { getMemberRemovalImpactContract } from '@/lib/api/contracts/organization' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' +import { getOrganizationTransferCredentialDependencies } from '@/lib/billing/organizations/membership' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('OrganizationRemovalImpactAPI') + +/** + * Identity-bound credentials the target user owns in this organization's + * workspaces — the set that stops working when their workspace access is + * revoked. Readable by org admins (removing someone) and by the user + * themself (leaving). + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getMemberRemovalImpactContract, request, context) + if (!parsed.success) return parsed.response + + const { id: organizationId } = parsed.data.params + const { userId: targetUserId } = parsed.data.query + + try { + const isSelf = targetUserId === session.user.id + if (!isSelf && !(await isOrganizationOwnerOrAdmin(session.user.id, organizationId))) { + return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) + } + + const credentials = await getOrganizationTransferCredentialDependencies( + targetUserId, + organizationId + ) + + return NextResponse.json({ credentials }) + } catch (error) { + logger.error('Failed to compute member removal impact', { + organizationId, + targetUserId, + error, + }) + return NextResponse.json({ error: 'Failed to compute removal impact' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts index 5f77c3aa1e9..287e5196374 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts @@ -42,6 +42,7 @@ import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization' import { addUserToOrganization } from '@/lib/billing/organizations/membership' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { attachOwnedWorkspacesToOrganization } from '@/lib/workspaces/organization-workspaces' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { adminInvalidJsonResponse, @@ -264,6 +265,33 @@ export const POST = withRouteHandler( return badRequestResponse(result.error || 'Failed to add member') } + /** + * A new member's owned personal workspaces follow them into the org + * (collaborators stay external). Best-effort: membership is already + * committed, and an attach failure leaves the pre-join status quo. + */ + try { + const attach = await attachOwnedWorkspacesToOrganization({ + ownerUserId: userId, + organizationId, + externalMemberPolicy: 'external-all', + includeArchived: true, + }) + if (attach.attachedWorkspaceIds.length > 0) { + logger.info('Attached new member workspaces to organization', { + userId, + organizationId, + attachedWorkspaceCount: attach.attachedWorkspaceIds.length, + }) + } + } catch (attachError) { + logger.error('Failed to attach new member workspaces to organization', { + userId, + organizationId, + error: attachError, + }) + } + const data: AdminMember = { id: result.memberId!, userId, diff --git a/apps/sim/app/api/workspaces/invitations/batch/route.ts b/apps/sim/app/api/workspaces/invitations/batch/route.ts index 391a1cb8952..e15bce1aa0f 100644 --- a/apps/sim/app/api/workspaces/invitations/batch/route.ts +++ b/apps/sim/app/api/workspaces/invitations/batch/route.ts @@ -82,6 +82,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { context, email: item.email, permission: item.permission, + membershipIntent: body.membershipIntent, request: req, }) if (invitation.instantAdd) { diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index 09391178790..9f156548ff1 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -3,17 +3,18 @@ import { useEffect, useState } from 'react' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { formatQuotedNameList } from '@sim/utils/string' import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { acceptInvitationContract, - getInvitationContract, - type InvitationDetails, + type InvitationJoinPreview, } from '@/lib/api/contracts/invitations' import { client, useSession } from '@/lib/auth/auth-client' import { InviteLayout, InviteStatusCard } from '@/app/invite/components' +import { useInvitationDetails } from '@/hooks/queries/invitations' import { organizationKeys } from '@/hooks/queries/organization' import { refreshSessionQuery } from '@/hooks/queries/session' import { subscriptionKeys } from '@/hooks/queries/subscription' @@ -155,6 +156,28 @@ function getInviteError(code: string): InviteError { ) } +const MAX_LISTED_WORKSPACE_NAMES = 3 + +/** + * Disclosure appended to the accept copy when accepting moves the invitee's + * own workspaces into the organization — said where the decision happens, so + * accepting never silently changes who controls their work. + */ +function buildWorkspaceMigrationNotice( + joinPreview: InvitationJoinPreview | null, + organizationLabel: string +): string { + if (!joinPreview?.willJoinOrganization || joinPreview.workspacesToMove.length === 0) { + return '' + } + + const names = joinPreview.workspacesToMove + const nameList = formatQuotedNameList(names, MAX_LISTED_WORKSPACE_NAMES) + const single = names.length === 1 + + return ` Accepting also moves your ${single ? 'workspace' : 'workspaces'} ${nameList} into ${organizationLabel}: its admins get full access, and ${single ? 'it stays' : 'they stay'} with the organization if you leave.` +} + function codeFromStatus(status: number): InviteErrorCode { if (status === 401) return 'unauthorized' if (status === 403) return 'forbidden' @@ -181,9 +204,8 @@ export default function Invite() { const searchParams = useSearchParams() const { data: session, isPending } = useSession() const queryClient = useQueryClient() - const [invitation, setInvitation] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) + const [actionError, setActionError] = useState(null) + const [urlError, setUrlError] = useState(null) const [isAccepting, setIsAccepting] = useState(false) const [accepted, setAccepted] = useState(false) const [isNewUser, setIsNewUser] = useState(false) @@ -206,37 +228,29 @@ export default function Invite() { } if (errorReason) { - setError(getInviteError(errorReason)) - setIsLoading(false) + setUrlError(getInviteError(errorReason)) } }, [searchParams, inviteId, inviteTokenStorageKey]) - useEffect(() => { - if (!session?.user) return - - async function fetchInvitation() { - setIsLoading(true) - try { - const data = await requestJson(getInvitationContract, { - params: { id: inviteId }, - query: { token: token ?? undefined }, - }) - setInvitation(data.invitation) - setError(null) - } catch (fetchError) { - logger.error('Error fetching invitation:', fetchError) - const code = - fetchError instanceof ApiClientError - ? codeFromApiClientError(fetchError) - : 'network-error' - setError(getInviteError(code)) - } finally { - setIsLoading(false) - } - } - - fetchInvitation() - }, [session?.user, inviteId, token]) + const invitationQuery = useInvitationDetails(inviteId, token, { + enabled: Boolean(session?.user), + }) + const invitation = invitationQuery.data?.invitation ?? null + const joinPreview = invitationQuery.data?.joinPreview ?? null + const isLoading = Boolean(session?.user) && invitationQuery.isPending + + const fetchError = invitationQuery.error + ? getInviteError( + invitationQuery.error instanceof ApiClientError + ? codeFromApiClientError(invitationQuery.error) + : 'network-error' + ) + : null + /** + * Action errors (accept failures) outrank fetch errors; the URL error param + * only shows until the invitation loads successfully. + */ + const error = actionError ?? fetchError ?? (invitationQuery.data ? null : urlError) const handleAcceptInvitation = async () => { if (!session?.user || !invitation) return @@ -265,7 +279,7 @@ export default function Invite() { acceptError instanceof ApiClientError ? codeFromApiClientError(acceptError) : 'network-error' - setError(getInviteError(code)) + setActionError(getInviteError(code)) setIsAccepting(false) } } @@ -440,13 +454,15 @@ export default function Invite() { } const isOrg = invitation?.kind === 'organization' + const organizationLabel = invitation?.organizationName || 'the organization' + const migrationNotice = buildWorkspaceMigrationNotice(joinPreview, organizationLabel) return ( void @@ -21,6 +32,7 @@ export function RemoveMemberDialog({ onCancel, isSelfRemoval = false, isExternalRemoval = false, + breakingCredentials = [], isSubmitting = false, }: RemoveMemberDialogProps) { const title = isSelfRemoval @@ -29,8 +41,16 @@ export function RemoveMemberDialog({ ? 'Remove External Member' : 'Remove Team Member' - const errorMessage = - error instanceof Error && error.message ? error.message : error ? String(error) : null + const errorMessage = error ? getErrorMessage(error) || null : null + + const credentialWarning = + breakingCredentials.length > 0 + ? `${breakingCredentials.length === 1 ? 'A credential' : `${breakingCredentials.length} credentials`} ${ + isSelfRemoval ? 'you own' : 'they own' + } (${formatQuotedNameList(breakingCredentials, MAX_LISTED_CREDENTIALS)}) will stop working in organization workspaces until another member reconnects ${ + breakingCredentials.length === 1 ? 'it' : 'them' + }.` + : null return ( + {credentialWarning ? ( +

{credentialWarning}

+ ) : null} {errorMessage ? (

{errorMessage} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index 1f43f1cc594..57d5817616e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -18,6 +18,7 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/team-management/components' import { useCreateOrganization, + useMemberRemovalImpact, useOrganization, useOrganizationBilling, useOrganizationRoster, @@ -75,6 +76,12 @@ export function TeamManagement({ const [orgName, setOrgName] = useState('') const [orgSlug, setOrgSlug] = useState('') + const { data: removalImpactCredentials } = useMemberRemovalImpact( + organizationId, + removeMemberDialog.memberId, + { enabled: removeMemberDialog.open } + ) + const totalSeats = organizationBillingData?.data?.totalSeats ?? 0 const usedSeats = organizationBillingData?.data?.members?.length ?? 0 const reservedSeats = organizationBillingData?.data?.usedSeats ?? 0 @@ -375,6 +382,9 @@ export function TeamManagement({ memberName={removeMemberDialog.memberName} isSelfRemoval={removeMemberDialog.isSelfRemoval} isExternalRemoval={removeMemberDialog.isExternalRemoval} + breakingCredentials={[ + ...new Set(removalImpactCredentials?.map((c) => c.displayName) ?? []), + ]} isSubmitting={removeMemberMutation.isPending} error={removeMemberMutation.error} onOpenChange={(open: boolean) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx index 22923d5cfb7..3d45321f774 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx @@ -29,6 +29,16 @@ const ROLE_OPTIONS = [ { value: 'read', label: 'Read' }, ] as const +const MEMBERSHIP_OPTIONS = [ + { value: 'internal', label: 'Member' }, + { value: 'external', label: 'External collaborator' }, +] as const + +type MembershipIntent = (typeof MEMBERSHIP_OPTIONS)[number]['value'] + +const EXTERNAL_MEMBERSHIP_HINT = + 'External collaborators get access to this workspace only — no organization membership or seat.' + interface InviteModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -46,6 +56,7 @@ export function InviteModal({ }: InviteModalProps) { const [emails, setEmails] = useState([]) const [inviteRole, setInviteRole] = useState('admin') + const [membershipIntent, setMembershipIntent] = useState('internal') const [errorMessage, setErrorMessage] = useState(null) const params = useParams() @@ -75,7 +86,9 @@ export function InviteModal({ // seats are provisioned automatically when an invitee accepts. const isEnterpriseOrg = isEnterprise(organizationBillingData?.data?.subscriptionPlan) const hasSeatData = canViewOrganizationBilling && isEnterpriseOrg && totalSeats > 0 - const exceedsSeatCapacity = hasSeatData && userPerms.canAdmin && emails.length > availableSeats + const isExternalInvite = Boolean(organizationId) && membershipIntent === 'external' + const exceedsSeatCapacity = + hasSeatData && userPerms.canAdmin && !isExternalInvite && emails.length > availableSeats const seatLimitReason = exceedsSeatCapacity ? `Only ${availableSeats} internal seat${availableSeats === 1 ? '' : 's'} available. External workspace invites do not require seats.` : null @@ -109,7 +122,12 @@ export function InviteModal({ const invitations = emails.map((email) => ({ email, permission: inviteRole })) batchSendInvitations.mutate( - { workspaceId, organizationId, invitations }, + { + workspaceId, + organizationId, + invitations, + membershipIntent: isExternalInvite ? 'external' : undefined, + }, { onSuccess: (result) => { const parts: string[] = [] @@ -151,13 +169,14 @@ export function InviteModal({ workspaceId, organizationId, inviteRole, - batchSendInvitations, + isExternalInvite, onOpenChange, ]) const resetState = useCallback(() => { setEmails([]) setInviteRole('admin') + setMembershipIntent('internal') setErrorMessage(null) }, []) @@ -206,6 +225,18 @@ export function InviteModal({ align='start' onChange={(role) => setInviteRole(role as PermissionType)} /> + {organizationId && ( + setMembershipIntent(intent as MembershipIntent)} + /> + )} handleOpenChange(false)} diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index 6137b205d19..f9a441486d6 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -12,7 +12,7 @@ import { createWorkspaceContract } from '@/lib/api/contracts/workspaces' import { useSession } from '@/lib/auth/auth-client' import { recoverFromStaleSession } from '@/lib/auth/stale-session-recovery' import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage' -import { useWorkspacesWithMetadata, type WorkspaceCreationPolicy } from '@/hooks/queries/workspace' +import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace' const logger = createLogger('WorkspacePage') @@ -27,12 +27,47 @@ function isStaleSessionError(error: unknown): boolean { return isApiClientError(error) && error.status === 401 } +interface WorkspaceStatusCardProps { + title: string + description: string + primaryLabel: string + onPrimary: () => void +} + +function WorkspaceStatusCard({ + title, + description, + primaryLabel, + onPrimary, +}: WorkspaceStatusCardProps) { + return ( +

+
+
+ +
+
+

{title}

+

{description}

+
+
+ + {primaryLabel} + + void recoverFromStaleSession()}>Sign out +
+
+
+ ) +} + export default function WorkspacePage() { const router = useRouter() const { data: session, isPending: isSessionPending, error: sessionError } = useSession() const isAuthenticated = !isSessionPending && !!session?.user const hasRedirectedRef = useRef(false) const isRecoveringRef = useRef(false) + const blockedLoggedRef = useRef(false) const [recoveryFailed, setRecoveryFailed] = useState(false) const { @@ -75,18 +110,35 @@ export default function WorkspacePage() { if (isWorkspacesLoading || workspacesError || !data) return - hasRedirectedRef.current = true - - const urlParams = new URLSearchParams(window.location.search) - const redirectWorkflowId = urlParams.get('redirect_workflow') - const { workspaces, lastActiveWorkspaceId, creationPolicy } = data if (workspaces.length === 0) { - handleNoWorkspaces(router, creationPolicy) + /** + * Blocked state is derived in render and deliberately does NOT set + * hasRedirectedRef: a later refetch that shows granted access resumes + * the normal redirect path, so the screen self-heals. + */ + if (creationPolicy && !creationPolicy.canCreate) { + if (!blockedLoggedRef.current) { + blockedLoggedRef.current = true + logger.warn('No workspaces found and workspace creation is blocked', { + reason: creationPolicy.reason, + workspaceMode: creationPolicy.workspaceMode, + organizationId: creationPolicy.organizationId, + }) + } + return + } + hasRedirectedRef.current = true + handleNoWorkspaces(router) return } + hasRedirectedRef.current = true + + const urlParams = new URLSearchParams(window.location.search) + const redirectWorkflowId = urlParams.get('redirect_workflow') + const localRecentId = WorkspaceRecencyStorage.getMostRecent() const findWorkspace = (id: string | null) => id ? workspaces.find((w) => w.id === id) : undefined @@ -103,6 +155,30 @@ export default function WorkspacePage() { router.replace(`/workspace/${targetWorkspace.id}/home`) }, [session, isSessionPending, sessionError, isWorkspacesLoading, workspacesError, data, router]) + const blockedPolicy = + isAuthenticated && + data && + data.workspaces.length === 0 && + data.creationPolicy && + !data.creationPolicy.canCreate + ? data.creationPolicy + : null + + if (blockedPolicy) { + return ( + window.location.reload()} + /> + ) + } + const failedToLoad = recoveryFailed || (Boolean(sessionError) && !session?.user) || @@ -110,28 +186,12 @@ export default function WorkspacePage() { if (failedToLoad) { return ( -
-
-
- -
-
-

- Could not load your workspaces -

-

- Something went wrong while loading your account. Try again, or sign out and log back - in. -

-
-
- window.location.reload()}> - Try again - - void recoverFromStaleSession()}>Sign out -
-
-
+ window.location.reload()} + /> ) } @@ -172,20 +232,7 @@ async function handleWorkflowRedirect( router.replace(`/workspace/${fallbackWorkspaceId}/home`) } -async function handleNoWorkspaces( - router: ReturnType, - creationPolicy: WorkspaceCreationPolicy | null -): Promise { - if (creationPolicy && !creationPolicy.canCreate) { - logger.warn('No workspaces found and workspace creation is blocked', { - reason: creationPolicy.reason, - workspaceMode: creationPolicy.workspaceMode, - organizationId: creationPolicy.organizationId, - }) - router.replace('/') - return - } - +async function handleNoWorkspaces(router: ReturnType): Promise { logger.warn('No workspaces found, creating default workspace') try { const data = await requestJson(createWorkspaceContract, { diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index 410bf83ff04..abd023fafaf 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -5,6 +5,7 @@ import { type BatchInvitationResult as BatchInvitationResultContract, batchWorkspaceInvitationsContract, cancelInvitationContract, + getInvitationContract, listWorkspaceInvitationsContract, type PendingInvitationRow, removeWorkspaceMemberContract, @@ -19,9 +20,44 @@ export const invitationKeys = { all: ['invitations'] as const, lists: () => [...invitationKeys.all, 'list'] as const, list: (workspaceId: string) => [...invitationKeys.lists(), workspaceId] as const, + details: () => [...invitationKeys.all, 'detail'] as const, + detail: (invitationId: string, token: string | null) => + [...invitationKeys.details(), invitationId, token ?? ''] as const, } export const WORKSPACE_INVITATION_LIST_STALE_TIME = 30 * 1000 +export const INVITATION_DETAILS_STALE_TIME = 30 * 1000 + +async function fetchInvitationDetails( + invitationId: string, + token: string | null, + signal?: AbortSignal +) { + return requestJson(getInvitationContract, { + params: { id: invitationId }, + query: { token: token ?? undefined }, + signal, + }) +} + +/** + * Fetches an invitation (with the invitee-only join preview) for the accept + * screen. `retry: false` preserves one-shot semantics — 403/404/expired + * responses drive UX states and must surface immediately, not after backoff. + */ +export function useInvitationDetails( + invitationId: string | undefined, + token: string | null, + options?: { enabled?: boolean } +) { + return useQuery({ + queryKey: invitationKeys.detail(invitationId ?? '', token), + queryFn: ({ signal }) => fetchInvitationDetails(invitationId as string, token, signal), + enabled: Boolean(invitationId) && (options?.enabled ?? true), + staleTime: INVITATION_DETAILS_STALE_TIME, + retry: false, + }) +} export interface WorkspaceInvitation { email: string @@ -89,11 +125,13 @@ export function useBatchSendWorkspaceInvitations() { mutationFn: async ({ workspaceId, invitations, + membershipIntent, }: BatchSendInvitationsParams): Promise => { const result = await requestJson(batchWorkspaceInvitationsContract, { body: { workspaceId, invitations, + membershipIntent, }, }) diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index 601ee9aed49..f1b145d6f9f 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -16,6 +16,7 @@ import { } from '@/lib/api/contracts/invitations' import { createOrganizationContract, + getMemberRemovalImpactContract, getOrganizationMemberUsageLimitContract, getOrganizationRosterContract, inviteOrganizationMembersContract, @@ -23,6 +24,7 @@ import { type OrganizationMembersResponse, type OrganizationMemberUsageLimitData, type OrganizationRoster, + type RemovalImpactCredential, type RosterMember, type RosterPendingInvitation, type RosterWorkspaceAccess, @@ -54,6 +56,7 @@ export const ORGANIZATION_SUBSCRIPTION_STALE_TIME = 30 * 1000 export const ORGANIZATION_BILLING_STALE_TIME = 30 * 1000 export const ORGANIZATION_MEMBERS_STALE_TIME = 30 * 1000 export const ORGANIZATION_MEMBER_USAGE_LIMIT_STALE_TIME = 30 * 1000 +export const ORGANIZATION_REMOVAL_IMPACT_STALE_TIME = 30 * 1000 type OrganizationSubscriptionCandidate = { id: string @@ -115,6 +118,8 @@ export const organizationKeys = { memberUsageLimit: (id: string, userId: string) => [...organizationKeys.detail(id), 'member-usage-limit', userId] as const, roster: (id: string) => [...organizationKeys.detail(id), 'roster'] as const, + removalImpact: (id: string, userId: string) => + [...organizationKeys.detail(id), 'removal-impact', userId] as const, } export type { OrganizationRoster, RosterMember, RosterPendingInvitation, RosterWorkspaceAccess } @@ -148,6 +153,37 @@ export function useOrganizationRoster(orgId: string | undefined | null) { }) } +async function fetchMemberRemovalImpact( + orgId: string, + userId: string, + signal?: AbortSignal +): Promise { + const data = await requestJson(getMemberRemovalImpactContract, { + params: { id: orgId }, + query: { userId }, + signal, + }) + return data.credentials +} + +/** + * Identity-bound credentials the target user owns in organization workspaces — + * the set that stops working after removal. Fetched lazily while the + * remove-member dialog is open. + */ +export function useMemberRemovalImpact( + orgId: string | undefined | null, + userId: string | undefined | null, + options?: { enabled?: boolean } +) { + return useQuery({ + queryKey: organizationKeys.removalImpact(orgId ?? '', userId ?? ''), + queryFn: ({ signal }) => fetchMemberRemovalImpact(orgId as string, userId as string, signal), + enabled: Boolean(orgId) && Boolean(userId) && (options?.enabled ?? true), + staleTime: ORGANIZATION_REMOVAL_IMPACT_STALE_TIME, + }) +} + /** * Fetches the current viewer's account-scoped organizations. * diff --git a/apps/sim/lib/admin/dashboard.ts b/apps/sim/lib/admin/dashboard.ts index d39f6b8405b..bbb983d567e 100644 --- a/apps/sim/lib/admin/dashboard.ts +++ b/apps/sim/lib/admin/dashboard.ts @@ -937,15 +937,9 @@ export async function getDashboardMemberTransferPreflight( .where(eq(user.id, userId)) .limit(1), db - .select({ id: workspace.id, name: workspace.name }) + .select({ id: workspace.id, name: workspace.name, archivedAt: workspace.archivedAt }) .from(workspace) - .where( - and( - eq(workspace.ownerId, userId), - isNull(workspace.archivedAt), - ne(workspace.workspaceMode, 'organization') - ) - ) + .where(and(eq(workspace.ownerId, userId), ne(workspace.workspaceMode, 'organization'))) .orderBy(workspace.name, workspace.id), ]) if (!destination) throw new Error('Destination organization not found') @@ -969,7 +963,11 @@ export async function getDashboardMemberTransferPreflight( target.organizationId && target.organizationName ? { id: target.organizationId, name: target.organizationName, role: target.role } : null, - personalWorkspaces, + personalWorkspaces: personalWorkspaces.map((row) => ({ + id: row.id, + name: row.name, + archived: row.archivedAt !== null, + })), credentialDependencies, canAdd: reason === null, reason, @@ -995,7 +993,6 @@ export async function addDashboardOrganizationMember( and( inArray(workspace.id, selectedWorkspaceIds), eq(workspace.ownerId, values.userId), - isNull(workspace.archivedAt), ne(workspace.workspaceMode, 'organization') ) ) diff --git a/apps/sim/lib/api/contracts/invitations.ts b/apps/sim/lib/api/contracts/invitations.ts index b1fa8871523..8de6485a967 100644 --- a/apps/sim/lib/api/contracts/invitations.ts +++ b/apps/sim/lib/api/contracts/invitations.ts @@ -47,6 +47,11 @@ export const batchWorkspaceInvitationBodySchema = z.object({ }) ) .min(1, 'At least one invitation is required'), + /** + * Only valid on organization workspaces. `external` invites workspace-only + * collaborators: no org membership, no seat. + */ + membershipIntent: z.enum(['internal', 'external']).optional(), }) export const batchInvitationResultSchema = z @@ -93,6 +98,11 @@ export const invitationDetailsSchema = z.object({ ), }) +export const invitationJoinPreviewSchema = z.object({ + willJoinOrganization: z.boolean(), + workspacesToMove: z.array(z.string()), +}) + export const acceptInvitationResponseSchema = z.object({ success: z.literal(true), redirectPath: z.string(), @@ -151,6 +161,8 @@ export const getInvitationContract = defineRouteContract({ mode: 'json', schema: z.object({ invitation: invitationDetailsSchema, + /** Invitee-only preview of what accepting will do; null for other viewers. */ + joinPreview: invitationJoinPreviewSchema.nullable(), }), }, }) @@ -211,3 +223,4 @@ export const removeWorkspaceMemberContract = defineRouteContract({ export type PendingInvitationRow = z.infer export type BatchInvitationResult = z.infer export type InvitationDetails = z.infer +export type InvitationJoinPreview = z.infer diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts index c3ac2f94e85..41e112d654c 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -87,10 +87,18 @@ export const updateOrganizationMemberRoleBodySchema = z.object({ role: organizationRoleSchema, }) +/** + * Role is required: member-role invites need workspace grants, which this + * endpoint cannot carry — send those through the invitations endpoint with + * workspaceInvitations. Only admin-role invites succeed here. + */ export const inviteOrganizationMemberBodySchema = z .object({ email: z.string({ error: 'Email is required' }).min(1, 'Email is required'), - role: z.enum(['admin', 'member'], { error: 'Invalid role' }).optional(), + role: z.enum(['admin', 'member'], { + error: + 'Role is required. Member invitations must include workspace access — use the invitations endpoint with workspaceInvitations.', + }), }) .passthrough() @@ -338,6 +346,38 @@ export const getOrganizationRosterContract = defineRouteContract({ }, }) +export const removalImpactCredentialSchema = z.object({ + id: z.string(), + displayName: z.string(), + type: z.string(), + workspaceId: z.string(), +}) + +export const memberRemovalImpactQuerySchema = z.object({ + userId: z.string().min(1, 'User ID is required'), +}) + +/** + * Identity-bound credentials (OAuth accounts, personal env keys) the user owns + * in organization workspaces. These stop working when the user's workspace + * access is revoked and must be reconnected by a remaining member — removal is + * never blocked, only disclosed. + */ +export const getMemberRemovalImpactContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/removal-impact', + params: organizationParamsSchema, + query: memberRemovalImpactQuerySchema, + response: { + mode: 'json', + schema: z.object({ + credentials: z.array(removalImpactCredentialSchema), + }), + }, +}) + +export type RemovalImpactCredential = z.infer + export const listOrganizationMembersContract = defineRouteContract({ method: 'GET', path: '/api/organizations/[id]/members', diff --git a/apps/sim/lib/api/contracts/v1/admin/dashboard.ts b/apps/sim/lib/api/contracts/v1/admin/dashboard.ts index a7315ed4b94..06f154fa569 100644 --- a/apps/sim/lib/api/contracts/v1/admin/dashboard.ts +++ b/apps/sim/lib/api/contracts/v1/admin/dashboard.ts @@ -172,7 +172,9 @@ export const adminDashboardMemberPreflightQuerySchema = z.object({ export const adminDashboardMemberPreflightSchema = z.object({ user: z.object({ id: z.string(), name: z.string(), email: z.string() }), currentOrganization: z.object({ id: z.string(), name: z.string(), role: z.string() }).nullable(), - personalWorkspaces: z.array(z.object({ id: z.string(), name: z.string() })), + personalWorkspaces: z.array( + z.object({ id: z.string(), name: z.string(), archived: z.boolean() }) + ), credentialDependencies: z.array( z.object({ id: z.string(), diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index 0535f6632cd..ebc0635f5d6 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -23,6 +23,7 @@ const { mockSyncUsageLimitsFromSubscription, mockSyncWorkspaceEnvCredentials, mockIsWorkspaceOnEnterprisePlan, + mockAttachOwnedWorkspacesToOrganizationTx, } = vi.hoisted(() => ({ mockEnsureUserInOrganization: vi.fn(), mockGetUserOrganization: vi.fn(), @@ -35,6 +36,7 @@ const { mockSyncUsageLimitsFromSubscription: vi.fn(), mockSyncWorkspaceEnvCredentials: vi.fn(), mockIsWorkspaceOnEnterprisePlan: vi.fn(async () => true), + mockAttachOwnedWorkspacesToOrganizationTx: vi.fn(), })) vi.mock('@/lib/billing/organizations/membership', () => ({ @@ -72,6 +74,11 @@ vi.mock('@/lib/credentials/environment', () => ({ syncWorkspaceEnvCredentials: mockSyncWorkspaceEnvCredentials, })) +vi.mock('@/lib/workspaces/organization-workspaces', () => ({ + attachOwnedWorkspacesToOrganizationTx: mockAttachOwnedWorkspacesToOrganizationTx, + ownedAttachableWorkspacesWhere: vi.fn(), +})) + vi.mock('@sim/audit', () => auditMock) import { acceptInvitation } from '@/lib/invitations/core' @@ -125,6 +132,12 @@ describe('acceptInvitation', () => { alreadyMember: false, billingActions: { proUsageSnapshotted: false, proCancelledAtPeriodEnd: false }, }) + mockAttachOwnedWorkspacesToOrganizationTx.mockResolvedValue({ + attachedWorkspaceIds: [], + addedMemberIds: [], + skippedMembers: [], + usageLimitUserIds: [], + }) }) it('accepts external workspace invitations without joining the organization', async () => { @@ -306,6 +319,8 @@ describe('acceptInvitation', () => { ], [{ name: 'Acme' }], [{ name: 'Inviter', email: 'inviter@example.com' }], + // Invitee-owned personal workspaces for the acceptance lock plan. + [], [], [], [{ variables: {} }], @@ -444,6 +459,8 @@ describe('acceptInvitation', () => { ], [{ name: 'Stale organization' }], [{ name: 'Owner', email: 'owner@example.com' }], + // Invitee-owned personal workspaces for the acceptance lock plan. + [], // Candidate personal workspaces covered by the acceptance lock set. [], // Grant-txn membership re-check under the lock: member still present. @@ -516,6 +533,15 @@ describe('acceptInvitation', () => { workspaceMode: 'organization', billedAccountUserId: 'destination-owner', }) + // The stamped organization (null) no longer matches the post-lock + // workspace organization, so escalation requires the inviter to hold + // admin standing in the destination org — as the conversion's billing + // owner does. + mockGetUserOrganization.mockImplementation(async (userId: string) => + userId === 'owner-1' + ? { organizationId: 'org-1', role: 'owner', memberId: 'member-owner' } + : null + ) queueWhereResponses([ [ @@ -543,6 +569,8 @@ describe('acceptInvitation', () => { }, ], [{ name: 'Owner', email: 'owner@example.com' }], + // Invitee-owned personal workspaces for the acceptance lock plan. + [], [], [{ id: 'member-1' }], [], @@ -570,6 +598,218 @@ describe('acceptInvitation', () => { }) }) + it('attaches the invitee-owned personal workspaces when joining the organization', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + name: 'Workspace', + ownerId: 'owner-1', + organizationId: 'org-1', + workspaceMode: 'organization', + billedAccountUserId: 'owner-1', + }) + mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({ + success: true, + organizationId: 'org-1', + fixedSeats: false, + }) + mockAttachOwnedWorkspacesToOrganizationTx.mockResolvedValueOnce({ + attachedWorkspaceIds: ['joiner-ws-1'], + addedMemberIds: [], + skippedMembers: [], + usageLimitUserIds: ['invitee-user'], + }) + + queueWhereResponses([ + [ + { + id: 'inv-1', + kind: 'workspace', + email: 'invitee@example.com', + organizationId: 'org-1', + membershipIntent: 'internal', + inviterId: 'owner-1', + role: 'member', + status: 'pending', + token: 'tok-1', + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + [ + { + id: 'grant-1', + workspaceId: 'workspace-1', + permission: 'write', + workspaceName: 'Workspace', + }, + ], + [{ name: 'Acme' }], + [{ name: 'Owner', email: 'owner@example.com' }], + // Invitee-owned personal workspaces for the acceptance lock plan. + [{ id: 'joiner-ws-1' }], + // Grant-txn membership re-check under the lock: member still present. + [{ id: 'member-1' }], + ]) + + const result = await acceptInvitation({ + userId: 'invitee-user', + userEmail: 'invitee@example.com', + invitationId: 'inv-1', + token: 'tok-1', + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.redirectPath).toBe('/workspace/workspace-1/home') + } + expect(mockAttachOwnedWorkspacesToOrganizationTx).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ownerUserId: 'invitee-user', + organizationId: 'org-1', + workspaceIds: ['joiner-ws-1'], + externalMemberPolicy: 'external-all', + ownerMatch: 'owner', + includeArchived: true, + }) + ) + expect(mockSyncUsageLimitsFromSubscription).toHaveBeenCalledWith('invitee-user') + }) + + it('grants external access without joining when the workspace entered an org after the invite was sent', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + name: 'Workspace', + ownerId: 'owner-1', + organizationId: 'org-1', + workspaceMode: 'organization', + billedAccountUserId: 'org-owner', + }) + // Inviter is a plain member of org-1 (their own join attached this + // workspace), so the stale-stamped invite must not escalate to membership. + mockGetUserOrganization.mockImplementation(async (userId: string) => + userId === 'inviter-1' + ? { organizationId: 'org-1', role: 'member', memberId: 'member-inviter' } + : null + ) + + queueWhereResponses([ + [ + { + id: 'inv-1', + kind: 'workspace', + email: 'invitee@example.com', + organizationId: null, + membershipIntent: 'internal', + inviterId: 'inviter-1', + role: 'member', + status: 'pending', + token: 'tok-1', + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + [ + { + id: 'grant-1', + workspaceId: 'workspace-1', + permission: 'write', + workspaceName: 'Workspace', + }, + ], + [{ name: 'Inviter', email: 'inviter@example.com' }], + // Invitee-owned personal workspaces for the acceptance lock plan. + [], + [], + [], + [{ variables: {} }], + ]) + + const result = await acceptInvitation({ + userId: 'invitee-user', + userEmail: 'invitee@example.com', + invitationId: 'inv-1', + token: 'tok-1', + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.invitation.membershipIntent).toBe('external') + expect(result.acceptedWorkspaceIds).toEqual(['workspace-1']) + } + expect(mockEnsureTeamOrganizationForAcceptance).not.toHaveBeenCalled() + expect(mockEnsureUserInOrganization).not.toHaveBeenCalled() + expect(mockAttachOwnedWorkspacesToOrganizationTx).not.toHaveBeenCalled() + expect(mockSetActiveOrganizationForCurrentSession).not.toHaveBeenCalled() + }) + + it('redirects organization invitations with grants into the first granted workspace', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + name: 'Workspace', + ownerId: 'owner-1', + organizationId: 'org-1', + workspaceMode: 'organization', + billedAccountUserId: 'owner-1', + }) + mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({ + success: true, + organizationId: 'org-1', + fixedSeats: false, + }) + + queueWhereResponses([ + [ + { + id: 'inv-1', + kind: 'organization', + email: 'invitee@example.com', + organizationId: 'org-1', + membershipIntent: 'internal', + inviterId: 'owner-1', + role: 'member', + status: 'pending', + token: 'tok-1', + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + [ + { + id: 'grant-1', + workspaceId: 'workspace-1', + permission: 'write', + workspaceName: 'Workspace', + }, + ], + [{ name: 'Acme' }], + [{ name: 'Owner', email: 'owner@example.com' }], + // Invitee-owned personal workspaces for the acceptance lock plan. + [], + // Grant-txn membership re-check under the lock: member still present. + [{ id: 'member-1' }], + // Invitation status update under the lock. + [], + // Live workspace organization for the org-invite grant staleness check. + [{ organizationId: 'org-1' }], + ]) + + const result = await acceptInvitation({ + userId: 'invitee-user', + userEmail: 'invitee@example.com', + invitationId: 'inv-1', + token: 'tok-1', + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.redirectPath).toBe('/workspace/workspace-1/home') + } + }) + it('does not record an ORG_MEMBER_ADDED audit for a user who is already a member', async () => { mockGetWorkspaceWithOwner.mockResolvedValue({ id: 'workspace-1', @@ -617,6 +857,8 @@ describe('acceptInvitation', () => { ], [{ name: 'Acme' }], [{ name: 'Owner', email: 'owner@example.com' }], + // Invitee-owned personal workspaces for the acceptance lock plan. + [], [{ id: 'member-1' }], ]) @@ -678,6 +920,8 @@ describe('acceptInvitation', () => { ], [{ name: 'Acme' }], [{ name: 'Owner', email: 'owner@example.com' }], + // Invitee-owned personal workspaces for the acceptance lock plan. + [], // Grant-txn membership re-check under the lock: member still present. [{ id: 'member-1' }], ]) @@ -750,6 +994,8 @@ describe('acceptInvitation', () => { ], [{ name: 'Acme' }], [{ name: 'Owner', email: 'owner@example.com' }], + // Invitee-owned personal workspaces for the acceptance lock plan. + [], [{ id: 'member-1' }], ]) diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index a743765f9f4..5cd3c416a0d 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -14,12 +14,14 @@ import { workspaceEnvironment, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { PERMISSION_RANK, type PermissionType } from '@sim/platform-authz/workspace' +import { isOrgAdminRole, PERMISSION_RANK, type PermissionType } from '@sim/platform-authz/workspace' import { generateId } from '@sim/utils/id' import { normalizeEmail } from '@sim/utils/string' -import { and, eq, inArray, isNull, lte, ne, sql } from 'drizzle-orm' +import { and, asc, eq, inArray, lte, sql } from 'drizzle-orm' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' import { applySessionPolicyToNewMember } from '@/lib/auth/session-policy' +import { getOrganizationSubscription } from '@/lib/billing/core/billing' +import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { acquireOrganizationMutationLock, @@ -32,13 +34,18 @@ import { ensureTeamOrganizationForAcceptance, } from '@/lib/billing/organizations/provision-seat' import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' +import { isPro, isTeam } from '@/lib/billing/plan-helpers' +import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' import type { DbOrTx } from '@/lib/db/types' import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' import { captureServerEvent } from '@/lib/posthog/server' +import { + attachOwnedWorkspacesToOrganizationTx, + ownedAttachableWorkspacesWhere, +} from '@/lib/workspaces/organization-workspaces' import { getWorkspaceWithOwner, type WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' -import { WORKSPACE_MODE } from '@/lib/workspaces/policy' const logger = createLogger('InvitationCore') @@ -150,6 +157,119 @@ export function isInvitationExpired(inv: Pick return new Date() > new Date(inv.expiresAt) } +/** + * A workspace invitation only escalates into an EXISTING organization when + * that organization matches what was stamped at send time — a workspace that + * entered an organization after the invite went out (a member's owned + * workspaces attaching on join, an admin move) never asked that org for a + * seat, so escalation requires the inviter to currently hold admin standing + * there. A workspace with no current organization is deliberately exempt: + * acceptance trusts the live workspace over stale stamped metadata and falls + * back to the standard personal-workspace regime (Pro→Team conversion of the + * current billed account), matching long-standing tested behavior. + * Organization-kind invitations always join their STAMPED organization (the + * join target is never re-derived from a granted workspace, whose org can + * change after send), so they pass trivially here. Acceptance and the + * accept-screen preview both consume this predicate so the disclosure can + * never contradict the accepted outcome. + */ +async function stampedOrganizationAllowsEscalation( + inv: InvitationWithGrants, + workspaceOrganizationId: string | null, + executor: DbOrTx = db +): Promise { + if (inv.kind !== 'workspace') return true + if (!workspaceOrganizationId) return true + if (inv.organizationId === workspaceOrganizationId) return true + const inviterMembership = await getUserOrganization(inv.inviterId, executor) + return ( + inviterMembership?.organizationId === workspaceOrganizationId && + isOrgAdminRole(inviterMembership.role) + ) +} + +export interface InvitationJoinPreviewResult { + willJoinOrganization: boolean + workspacesToMove: string[] +} + +/** + * Best-effort preview of what accepting will do for the invitee: whether a + * member row will be created and which of their owned personal workspaces + * (archived included) will follow them into the organization. Mirrors the + * acceptance decision flow without taking locks — races resolve at accept + * time; the preview only feeds disclosure copy. + */ +export async function getInvitationJoinPreview( + inviteeUserId: string, + inv: InvitationWithGrants +): Promise { + const noJoin: InvitationJoinPreviewResult = { willJoinOrganization: false, workspacesToMove: [] } + if (inv.membershipIntent === 'external') return noJoin + + let workspaceOrganizationId = inv.organizationId + let billedAccountUserId: string | null = null + const primaryGrantWorkspaceId = inv.grants[0]?.workspaceId + if (primaryGrantWorkspaceId) { + const primaryWorkspace = await getWorkspaceWithOwner(primaryGrantWorkspaceId) + if (primaryWorkspace) { + billedAccountUserId = primaryWorkspace.billedAccountUserId + if (inv.kind === 'workspace') { + workspaceOrganizationId = primaryWorkspace.organizationId + } + } + } + + /** + * Personal-workspace invites only produce an organization through billing's + * Pro→Team provisioning; with billing disabled there is nothing to join. + */ + if (!workspaceOrganizationId && !isBillingEnabled) return noJoin + + /** + * Already in the target organization (nothing changes) or in a different + * one (acceptance downgrades to external or rejects). + */ + const existingMembership = await getUserOrganization(inviteeUserId) + if (existingMembership) return noJoin + + if (!(await stampedOrganizationAllowsEscalation(inv, workspaceOrganizationId))) return noJoin + + /** + * Mirror acceptance's billing gates: an unusable organization subscription + * (or, for personal-workspace invites, a billed owner without a convertible + * paid plan) makes acceptance fail with upgrade-required — the disclosure + * must not promise a migration that cannot happen. + */ + if (isBillingEnabled) { + if (workspaceOrganizationId) { + const orgSub = await getOrganizationSubscription(workspaceOrganizationId) + if (!orgSub || !hasUsableSubscriptionStatus(orgSub.status)) return noJoin + } else { + const payerUserId = billedAccountUserId ?? inv.inviterId + const personalSub = await getHighestPriorityPersonalSubscription(payerUserId) + if ( + !personalSub || + !hasUsableSubscriptionStatus(personalSub.status) || + !(isPro(personalSub.plan) || isTeam(personalSub.plan)) + ) { + return noJoin + } + } + } + + const ownedWorkspaces = await db + .select({ name: workspace.name }) + .from(workspace) + .where(ownedAttachableWorkspacesWhere({ userId: inviteeUserId, includeArchived: true })) + .orderBy(asc(workspace.name)) + + return { + willJoinOrganization: true, + workspacesToMove: ownedWorkspaces.map((row) => row.name), + } +} + /** * Flip any still-pending invitations for the given organization whose * `expiresAt` has already passed to `expired`. Best-effort housekeeping @@ -268,50 +388,83 @@ interface InvitationAcceptancePostCommitEffects { memberRole: string | null reconcileSeats: boolean acceptedWorkspaceIds: string[] + /** Owned personal workspaces that followed the invitee into the org. */ + attachedWorkspaceIds: string[] syncUsageLimitUserIds: string[] planConversions: AcceptancePlanConversion[] acceptedInvitation: InvitationWithGrants | null membershipAlreadyExists: boolean } -/** - * Compute the complete workspace lock set before taking any workspace lock. - * A personal Pro→Team conversion attaches the billing owner's other personal - * workspaces in the same transaction, so those rows must participate in the - * same deterministic lock ordering as the invitation grants. - */ +interface InvitationAcceptanceLockPlan { + /** + * Invitation grant workspaces plus the billing owner's attachable + * workspaces (a personal Pro→Team conversion attaches those in the same + * transaction). Passed through to acceptance provisioning unchanged. + */ + workspaceIds: string[] + /** + * Workspaces the invitee owns outside any organization. When acceptance + * joins them into an organization, these rows attach in the same + * transaction, so they participate in the same deterministic lock ordering. + */ + joinerAttachWorkspaceIds: string[] + primaryWorkspace: WorkspaceWithOwner | null +} + +/** Compute the complete workspace lock set before taking any workspace lock. */ async function getInvitationAcceptanceWorkspaceLockIds( tx: DbOrTx, - inv: InvitationWithGrants -): Promise<{ workspaceIds: string[]; primaryWorkspace: WorkspaceWithOwner | null }> { + inv: InvitationWithGrants, + inviteeUserId: string +): Promise { const grantWorkspaceIds = inv.grants.map((grant) => grant.workspaceId) const primaryWorkspace = grantWorkspaceIds[0] ? await getWorkspaceWithOwner(grantWorkspaceIds[0], { executor: tx }) : null - if ( - !isBillingEnabled || - inv.membershipIntent === 'external' || - !primaryWorkspace || - primaryWorkspace.organizationId - ) { - return { workspaceIds: [...new Set(grantWorkspaceIds)].sort(), primaryWorkspace } - } - const attachableWorkspaces = await tx - .select({ id: workspace.id }) - .from(workspace) - .where( - and( - eq(workspace.billedAccountUserId, primaryWorkspace.billedAccountUserId), - isNull(workspace.organizationId), - ne(workspace.workspaceMode, WORKSPACE_MODE.ORGANIZATION) - ) - ) + /** + * Computed for every non-external invite. The post-lock workspace re-read + * can reveal an organization this pre-lock snapshot does not have (a + * concurrent attach or move), and the join-attach sweep must already hold + * these locks in that case — so no billing/organization short-circuit is + * safe here. Only external intent (immutable: it is never upgraded + * in-flight) provably rules a join out. + */ + const joinerAttachWorkspaceIds = + inv.membershipIntent === 'external' + ? [] + : ( + await tx + .select({ id: workspace.id }) + .from(workspace) + .where(ownedAttachableWorkspacesWhere({ userId: inviteeUserId, includeArchived: true })) + ).map((row) => row.id) + + const billingOwnerCanAttach = + isBillingEnabled && + inv.membershipIntent !== 'external' && + primaryWorkspace !== null && + !primaryWorkspace.organizationId + + const billingOwnerWorkspaceIds = billingOwnerCanAttach + ? ( + await tx + .select({ id: workspace.id }) + .from(workspace) + .where( + ownedAttachableWorkspacesWhere({ + userId: primaryWorkspace.billedAccountUserId, + ownerMatch: 'billing-account', + includeArchived: true, + }) + ) + ).map((row) => row.id) + : [] return { - workspaceIds: [ - ...new Set([...grantWorkspaceIds, ...attachableWorkspaces.map((row) => row.id)]), - ].sort(), + workspaceIds: [...new Set([...grantWorkspaceIds, ...billingOwnerWorkspaceIds])].sort(), + joinerAttachWorkspaceIds: [...new Set(joinerAttachWorkspaceIds)].sort(), primaryWorkspace, } } @@ -324,6 +477,7 @@ export async function acceptInvitation( memberRole: null, reconcileSeats: false, acceptedWorkspaceIds: [], + attachedWorkspaceIds: [], syncUsageLimitUserIds: [], planConversions: [], acceptedInvitation: null, @@ -342,10 +496,33 @@ export async function acceptInvitation( return { success: false, kind: 'not-found' } } - const lockPlan = await getInvitationAcceptanceWorkspaceLockIds(tx, inv) + /** + * Cheap validity checks run before the workspace lock plan so replayed, + * expired, or mismatched accepts pay no workspace queries or advisory + * locks. The invitation row is already advisory- and row-locked above, + * so these reads cannot race a concurrent acceptance. + */ + if (input.token && inv.token !== input.token) { + return { success: false, kind: 'invalid-token' } + } + if (inv.status !== 'pending') { + return { success: false, kind: 'already-processed' } + } + if (isInvitationExpired(inv)) { + await tx + .update(invitation) + .set({ status: 'expired', updatedAt: new Date() }) + .where(and(eq(invitation.id, inv.id), eq(invitation.status, 'pending'))) + return { success: false, kind: 'expired' } + } + if (normalizeEmail(input.userEmail) !== normalizeEmail(inv.email)) { + return { success: false, kind: 'email-mismatch' } + } + + const lockPlan = await getInvitationAcceptanceWorkspaceLockIds(tx, inv, input.userId) await acquireInvitationMutationLocks(tx, { invitationIds: [], - workspaceIds: lockPlan.workspaceIds, + workspaceIds: [...new Set([...lockPlan.workspaceIds, ...lockPlan.joinerAttachWorkspaceIds])], }) // Re-read and row-lock the primary workspace only after the shared @@ -376,42 +553,37 @@ export async function acceptInvitation( async function acceptLockedInvitation( input: AcceptInvitationInput, inv: InvitationWithGrants, - lockPlan: { workspaceIds: string[]; primaryWorkspace: WorkspaceWithOwner | null }, + lockPlan: InvitationAcceptanceLockPlan, tx: DbOrTx, effects: InvitationAcceptancePostCommitEffects ): Promise { - if (input.token && inv.token !== input.token) { - return { success: false, kind: 'invalid-token' } - } - - if (inv.status !== 'pending') { - return { success: false, kind: 'already-processed' } - } - - if (isInvitationExpired(inv)) { - await tx - .update(invitation) - .set({ status: 'expired', updatedAt: new Date() }) - .where(and(eq(invitation.id, inv.id), eq(invitation.status, 'pending'))) - return { success: false, kind: 'expired' } - } - - if (normalizeEmail(input.userEmail) !== normalizeEmail(inv.email)) { - return { success: false, kind: 'email-mismatch' } - } - let membershipAlreadyExists = false let acceptedMembershipIntent = inv.membershipIntent let shouldJoinOrganization = inv.membershipIntent !== 'external' + /** + * Workspace-kind invites derive their join target from the granted + * workspace's LIVE organization (the workspace is what was shared). + * Organization-kind invites always target their STAMPED organization: a + * granted workspace whose org changed after send must never redirect the + * membership into an organization the invitee was not invited to. + */ const primaryGrant = inv.grants[0] let billingOwnerUserId = inv.inviterId let workspaceOrganizationId = inv.organizationId - if (primaryGrant && lockPlan.primaryWorkspace) { + if (primaryGrant && lockPlan.primaryWorkspace && inv.kind === 'workspace') { billingOwnerUserId = lockPlan.primaryWorkspace.billedAccountUserId workspaceOrganizationId = lockPlan.primaryWorkspace.organizationId } + if ( + shouldJoinOrganization && + !(await stampedOrganizationAllowsEscalation(inv, workspaceOrganizationId, tx)) + ) { + acceptedMembershipIntent = 'external' + shouldJoinOrganization = false + } + const existingMembership = await getUserOrganization(input.userId, tx) const inviteeAlreadyInDifferentOrg = !!existingMembership && @@ -498,6 +670,28 @@ async function acceptLockedInvitation( if (billingManagesSeats && !membershipResult.alreadyMember) { effects.reconcileSeats = true } + + /** + * A new member's owned personal workspaces follow them into the + * organization so members never operate outside the org's purview. + * Collaborators on those workspaces stay external (`external-all`) — + * membership and seats never grow as a side effect of someone else's + * join. Fresh joins only: pre-existing members' estates are left + * untouched until an announced backfill. + */ + if (!membershipResult.alreadyMember && lockPlan.joinerAttachWorkspaceIds.length > 0) { + await acquireOrganizationMutationLock(tx, targetOrganizationId) + const attachResult = await attachOwnedWorkspacesToOrganizationTx(tx, { + ownerUserId: input.userId, + organizationId: targetOrganizationId, + workspaceIds: lockPlan.joinerAttachWorkspaceIds, + externalMemberPolicy: 'external-all', + ownerMatch: 'owner', + includeArchived: true, + }) + effects.syncUsageLimitUserIds.push(...attachResult.usageLimitUserIds) + effects.attachedWorkspaceIds = attachResult.attachedWorkspaceIds + } } else { shouldJoinOrganization = false } @@ -534,6 +728,28 @@ async function acceptLockedInvitation( .where(and(eq(invitation.id, inv.id), eq(invitation.status, 'pending'))) for (const grant of inv.grants) { + /** + * Organization-invite grants are only honored while the workspace still + * belongs to the stamped organization: a workspace that detached or + * moved after the invite went out is no longer the org's to share. + */ + if (inv.kind === 'organization' && inv.organizationId) { + const [grantWorkspace] = await tx + .select({ organizationId: workspace.organizationId }) + .from(workspace) + .where(eq(workspace.id, grant.workspaceId)) + .limit(1) + if (!grantWorkspace || grantWorkspace.organizationId !== inv.organizationId) { + logger.warn('Skipping stale organization-invite grant; workspace left the organization', { + invitationId: inv.id, + workspaceId: grant.workspaceId, + stampedOrganizationId: inv.organizationId, + currentOrganizationId: grantWorkspace?.organizationId ?? null, + }) + continue + } + } + const [existingPermission] = await tx .select({ id: permissions.id, permissionType: permissions.permissionType }) .from(permissions) @@ -599,9 +815,7 @@ async function acceptLockedInvitation( effects.membershipAlreadyExists = membershipAlreadyExists const redirectPath = - inv.kind === 'workspace' && acceptedWorkspaceIds.length > 0 - ? `/workspace/${acceptedWorkspaceIds[0]}/home` - : '/workspace' + acceptedWorkspaceIds.length > 0 ? `/workspace/${acceptedWorkspaceIds[0]}/home` : '/workspace' return { success: true, @@ -658,7 +872,11 @@ async function runInvitationAcceptancePostCommitEffects( resourceType: AuditResourceType.ORGANIZATION, resourceId: effects.organizationId, description: `Joined organization as ${effects.memberRole} via invite acceptance`, - metadata: { invitationId: input.invitationId, memberRole: effects.memberRole }, + metadata: { + invitationId: input.invitationId, + memberRole: effects.memberRole, + attachedWorkspaceIds: effects.attachedWorkspaceIds, + }, }) captureServerEvent( input.userId, diff --git a/apps/sim/lib/invitations/send.test.ts b/apps/sim/lib/invitations/send.test.ts new file mode 100644 index 00000000000..7ae01db11eb --- /dev/null +++ b/apps/sim/lib/invitations/send.test.ts @@ -0,0 +1,20 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { createPendingInvitation, GrantlessMemberInvitationError } from '@/lib/invitations/send' + +describe('createPendingInvitation', () => { + it('rejects a member-role organization invitation with no workspace grants', async () => { + await expect( + createPendingInvitation({ + kind: 'organization', + email: 'invitee@example.com', + inviterId: 'inviter-1', + organizationId: 'org-1', + role: 'member', + grants: [], + }) + ).rejects.toThrow(GrantlessMemberInvitationError) + }) +}) diff --git a/apps/sim/lib/invitations/send.ts b/apps/sim/lib/invitations/send.ts index 4ca449cfb5a..14bded7355a 100644 --- a/apps/sim/lib/invitations/send.ts +++ b/apps/sim/lib/invitations/send.ts @@ -8,6 +8,7 @@ import { workspace, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { generateId } from '@sim/utils/id' import { normalizeEmail } from '@sim/utils/string' import { and, eq, inArray, ne, sql } from 'drizzle-orm' @@ -49,9 +50,29 @@ export interface CreatePendingInvitationResult { expiresAt: Date } +/** + * Thrown when an invitation would strand its invitee: a member-role + * organization invite with no workspace grants leaves the accepted member + * with no accessible workspace (admins derive access to every organization + * workspace; members do not). Enforced here so every creation path — routes, + * admin tooling, future callers — hits the same rule. + */ +export class GrantlessMemberInvitationError extends Error { + constructor() { + super( + 'Member invitations must include at least one workspace so the invitee has a workspace to land in.' + ) + this.name = 'GrantlessMemberInvitationError' + } +} + export async function createPendingInvitation( input: CreatePendingInvitationInput ): Promise { + if (input.kind === 'organization' && !isOrgAdminRole(input.role) && input.grants.length === 0) { + throw new GrantlessMemberInvitationError() + } + const invitationId = generateId() const token = generateId() const expiresAt = input.expiresAt ?? computeInvitationExpiry() diff --git a/apps/sim/lib/invitations/workspace-invitations.ts b/apps/sim/lib/invitations/workspace-invitations.ts index 517a0063644..0d828a97dd2 100644 --- a/apps/sim/lib/invitations/workspace-invitations.ts +++ b/apps/sim/lib/invitations/workspace-invitations.ts @@ -117,15 +117,38 @@ export async function prepareWorkspaceInvitationContext({ } } +/** + * Throws the invite-flow seat error when the organization cannot take one + * more internal member. + */ +async function assertSeatAvailable(organizationId: string, email: string): Promise { + const seatValidation = await validateSeatAvailability(organizationId, 1) + if (!seatValidation.canInvite) { + throw new WorkspaceInvitationError({ + message: seatValidation.reason || 'No available seats for this organization.', + status: 400, + email, + }) + } +} + export async function createWorkspaceInvitation({ context, email, permission = 'read', + membershipIntent: requestedIntent, request, }: { context: WorkspaceInvitationContext email: string permission?: string + /** + * Explicit inviter choice for organization workspaces: `external` keeps the + * invitee a workspace-only collaborator — no org membership, no seat, and + * their own workspaces stay theirs. Defaults to the derived intent + * (internal unless the invitee already belongs to another organization). + */ + membershipIntent?: InvitationMembershipIntent request: NextRequest }): Promise { const validPermissions: PermissionType[] = ['admin', 'write', 'read'] @@ -138,8 +161,22 @@ export async function createWorkspaceInvitation({ } const invitationPermission = permission as PermissionType + /** + * External is only meaningful when there is an organization to stay outside + * of. On a personal workspace it would also skip the Pro→Team conversion + * that acceptance performs, so it is rejected rather than ignored. + */ + if (requestedIntent === 'external' && !context.workspaceDetails.organizationId) { + throw new WorkspaceInvitationError({ + message: 'External invitations are only available on organization workspaces.', + status: 400, + email, + }) + } + const normalizedEmail = normalizeEmail(email) - let membershipIntent: InvitationMembershipIntent = 'internal' + let membershipIntent: InvitationMembershipIntent = + requestedIntent === 'external' ? 'external' : 'internal' const existingUser = await db .select() @@ -216,26 +253,20 @@ export async function createWorkspaceInvitation({ if (workspaceOrganizationId) { if (existingMembership && existingMembership.organizationId !== workspaceOrganizationId) { membershipIntent = 'external' - } else if (context.invitePolicy.requiresSeat && !existingMembership) { - const seatValidation = await validateSeatAvailability(workspaceOrganizationId, 1) - if (!seatValidation.canInvite) { - throw new WorkspaceInvitationError({ - message: seatValidation.reason || 'No available seats for this organization.', - status: 400, - email: normalizedEmail, - }) - } + } else if ( + membershipIntent === 'internal' && + context.invitePolicy.requiresSeat && + !existingMembership + ) { + await assertSeatAvailable(workspaceOrganizationId, normalizedEmail) } } - } else if (context.invitePolicy.requiresSeat && context.invitePolicy.organizationId) { - const seatValidation = await validateSeatAvailability(context.invitePolicy.organizationId, 1) - if (!seatValidation.canInvite) { - throw new WorkspaceInvitationError({ - message: seatValidation.reason || 'No available seats for this organization.', - status: 400, - email: normalizedEmail, - }) - } + } else if ( + membershipIntent === 'internal' && + context.invitePolicy.requiresSeat && + context.invitePolicy.organizationId + ) { + await assertSeatAvailable(context.invitePolicy.organizationId, normalizedEmail) } const existingInvitation = await findPendingGrantForWorkspaceEmail({ diff --git a/apps/sim/lib/workspaces/admin-move.test.ts b/apps/sim/lib/workspaces/admin-move.test.ts index d1e29fffbdc..a9572771c9e 100644 --- a/apps/sim/lib/workspaces/admin-move.test.ts +++ b/apps/sim/lib/workspaces/admin-move.test.ts @@ -127,15 +127,13 @@ describe('classifyWorkspaceMoveState', () => { ) }) - it('keeps archived personal workspaces ineligible for a new move', () => { - expect(() => + it('keeps archived personal workspaces movable so they cannot dodge organization purview', () => { + expect( classifyWorkspaceMoveState( { workspaceMode: WORKSPACE_MODE.PERSONAL, organizationId: null, archivedAt: new Date() }, 'org-1' ) - ).toThrowError( - expect.objectContaining>({ code: 'workspace-archived' }) - ) + ).toBe('move') }) }) diff --git a/apps/sim/lib/workspaces/admin-move.ts b/apps/sim/lib/workspaces/admin-move.ts index ea17d7c8635..a26c0a3cfc6 100644 --- a/apps/sim/lib/workspaces/admin-move.ts +++ b/apps/sim/lib/workspaces/admin-move.ts @@ -39,7 +39,6 @@ export class WorkspaceMoveError extends Error { readonly code: | 'workspace-not-found' | 'organization-not-found' - | 'workspace-archived' | 'already-organization-workspace' ) { super(message) @@ -139,7 +138,6 @@ export async function searchWorkspaceMoveCandidates( .innerJoin(user, eq(user.id, workspace.ownerId)) .where( and( - isNull(workspace.archivedAt), ne(workspace.workspaceMode, WORKSPACE_MODE.ORGANIZATION), or(eq(workspace.id, query), ilike(workspace.name, `%${query}%`)) ) @@ -455,10 +453,12 @@ async function searchWorkspaceById(workspaceId: string): Promise { const ownedWorkspaces = await db .select({ id: workspace.id }) .from(workspace) - .where( - and( - eq(workspace.ownerId, ownerUserId), - isNull(workspace.organizationId), - ne(workspace.workspaceMode, WORKSPACE_MODE.ORGANIZATION), - isNull(workspace.archivedAt) - ) - ) + .where(ownedAttachableWorkspacesWhere({ userId: ownerUserId, includeArchived })) const ownedWorkspaceIds = ownedWorkspaces.map((ownedWorkspace) => ownedWorkspace.id) if (ownedWorkspaceIds.length === 0) { return { attachedWorkspaceIds: [], addedMemberIds: [], skippedMembers: [] } @@ -113,6 +147,7 @@ export async function attachOwnedWorkspacesToOrganization({ workspaceIds: ownedWorkspaceIds, externalMemberPolicy, ownerMatch: 'owner', + includeArchived, }) }) @@ -165,12 +200,14 @@ export async function attachOwnedWorkspacesToOrganizationTx( workspaceIds, externalMemberPolicy = 'keep-external', ownerMatch = 'billing-account', + includeArchived = false, }: { ownerUserId: string organizationId: string workspaceIds: string[] externalMemberPolicy?: ExternalMemberPolicy ownerMatch?: 'owner' | 'billing-account' + includeArchived?: boolean } ): Promise { if (workspaceIds.length === 0) { @@ -194,13 +231,8 @@ export async function attachOwnedWorkspacesToOrganizationTx( .from(workspace) .where( and( - ownerMatch === 'owner' - ? eq(workspace.ownerId, ownerUserId) - : eq(workspace.billedAccountUserId, ownerUserId), - inArray(workspace.id, workspaceIds), - isNull(workspace.organizationId), - ne(workspace.workspaceMode, WORKSPACE_MODE.ORGANIZATION), - isNull(workspace.archivedAt) + ownedAttachableWorkspacesWhere({ userId: ownerUserId, ownerMatch, includeArchived }), + inArray(workspace.id, workspaceIds) ) ) .orderBy(asc(workspace.id)) @@ -240,23 +272,34 @@ export async function attachOwnedWorkspacesToOrganizationTx( .where(inArray(member.userId, workspaceMemberIds)) : [] const membershipByUser = new Map(memberships.map((row) => [row.userId, row.organizationId])) - const skippedMembers = workspaceMemberIds - .filter((userId) => { - const currentOrganizationId = membershipByUser.get(userId) - return currentOrganizationId !== undefined && currentOrganizationId !== organizationId - }) - .map((userId) => ({ - userId, - reason: 'Already a member of another organization; kept as external workspace member', - })) - if (externalMemberPolicy === 'reject' && skippedMembers.length > 0) { + const differentOrgMembers = workspaceMemberIds.filter((userId) => { + const currentOrganizationId = membershipByUser.get(userId) + return currentOrganizationId !== undefined && currentOrganizationId !== organizationId + }) + if (externalMemberPolicy === 'reject' && differentOrgMembers.length > 0) { throw new WorkspaceOrganizationMembershipConflictError( - skippedMembers.map(({ userId }) => ({ + differentOrgMembers.map((userId) => ({ userId, organizationId: membershipByUser.get(userId) as string, })) ) } + const skippedMembers: Array<{ userId: string; reason: string }> = differentOrgMembers.map( + (userId) => ({ + userId, + reason: 'Already a member of another organization; kept as external workspace member', + }) + ) + if (externalMemberPolicy === 'external-all') { + for (const userId of workspaceMemberIds) { + if (membershipByUser.get(userId) === undefined) { + skippedMembers.push({ + userId, + reason: 'Not an organization member; kept as external workspace member', + }) + } + } + } const skippedUserIds = new Set(skippedMembers.map((row) => row.userId)) const joinableUserIds = workspaceMemberIds.filter((userId) => !skippedUserIds.has(userId)) diff --git a/packages/utils/src/string.test.ts b/packages/utils/src/string.test.ts index aca1811866f..15a700e481e 100644 --- a/packages/utils/src/string.test.ts +++ b/packages/utils/src/string.test.ts @@ -2,7 +2,13 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { isVersionedType, normalizeEmail, stripVersionSuffix, truncate } from './string.js' +import { + formatQuotedNameList, + isVersionedType, + normalizeEmail, + stripVersionSuffix, + truncate, +} from './string.js' describe('truncate', () => { it('appends the suffix when the string exceeds the slice length', () => { @@ -65,3 +71,17 @@ describe('normalizeEmail', () => { expect(normalizeEmail('user@example.com')).toBe('user@example.com') }) }) + +describe('formatQuotedNameList', () => { + it('lists all names quoted when within the cap', () => { + expect(formatQuotedNameList(['A', 'B'], 3)).toBe('"A", "B"') + }) + + it('truncates to the cap with an overflow tail', () => { + expect(formatQuotedNameList(['A', 'B', 'C', 'D', 'E'], 3)).toBe('"A", "B", "C" and 2 more') + }) + + it('returns an empty string for no names', () => { + expect(formatQuotedNameList([], 3)).toBe('') + }) +}) diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 1cbea71cfbb..b7380fc4c52 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -45,3 +45,21 @@ export function isVersionedType(value: string): boolean { export function normalizeEmail(email: string): string { return email.trim().toLowerCase() } + +/** + * Formats a list of names as quoted values with an overflow tail, listing at + * most `maxListed` names. + * + * @example + * formatQuotedNameList(['A', 'B'], 3) // '"A", "B"' + * formatQuotedNameList(['A', 'B', 'C', 'D'], 3) // '"A", "B", "C" and 1 more' + * formatQuotedNameList([], 3) // '' + */ +export function formatQuotedNameList(names: string[], maxListed: number): string { + const listed = names + .slice(0, maxListed) + .map((name) => `"${name}"`) + .join(', ') + const overflow = names.length - maxListed + return overflow > 0 ? `${listed} and ${overflow} more` : listed +} From fea3fda1d203f9dd9c9ea055b47a2f621a2cf147 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 23 Jul 2026 20:33:51 -0700 Subject: [PATCH 02/16] update docs --- apps/docs/content/docs/en/platform/costs.mdx | 2 +- .../content/docs/en/platform/permissions.mdx | 23 +++++++++++++++---- .../content/docs/en/platform/workspaces.mdx | 4 ++-- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/docs/content/docs/en/platform/costs.mdx b/apps/docs/content/docs/en/platform/costs.mdx index 26c47fe0898..fcb2e25168f 100644 --- a/apps/docs/content/docs/en/platform/costs.mdx +++ b/apps/docs/content/docs/en/platform/costs.mdx @@ -321,7 +321,7 @@ By default, your usage is capped at the credits included in your plan. To allow | **Max** | Up to 10 | — | | **Team / Enterprise** | — | Unlimited (Owners and Admins) | -Team and Enterprise plans unlock shared workspaces that belong to your organization. Every workspace created under a Team or Enterprise plan is organization-owned: Owners and Admins can create unlimited shared workspaces, while organization Members cannot create workspaces (personal workspaces created before joining the organization remain accessible). Internal members invited to a shared workspace join the organization and count toward your seat total — Enterprise invites require an available seat at invite time, while Team plans add a seat automatically when the invitee accepts. Existing Sim users who already belong to another organization can be added as external workspace members; they get workspace access without joining your organization or using one of your seats. When a Team or Enterprise subscription is cancelled or downgraded, existing shared workspaces remain accessible to current members but new invites are disabled until the organization is upgraded again. +Team and Enterprise plans unlock shared workspaces that belong to your organization. Every workspace created under a Team or Enterprise plan is organization-owned: Owners and Admins can create unlimited shared workspaces, while organization Members cannot create workspaces. When someone joins the organization as an internal member, personal workspaces they own move into the organization and their usage is billed to it from then on. Internal members count toward your seat total — Enterprise invites require an available seat at invite time, while Team plans add a seat automatically when the invitee accepts. External workspace members get access to specific workspaces without joining your organization or using one of your seats; invitees who already belong to another organization always join this way. When a Team or Enterprise subscription is cancelled or downgraded, existing shared workspaces remain accessible to current members but new invites are disabled until the organization is upgraded again. ### Rate Limits diff --git a/apps/docs/content/docs/en/platform/permissions.mdx b/apps/docs/content/docs/en/platform/permissions.mdx index 97a7412cc64..87ca9545b36 100644 --- a/apps/docs/content/docs/en/platform/permissions.mdx +++ b/apps/docs/content/docs/en/platform/permissions.mdx @@ -37,7 +37,7 @@ Inherited roles are **automatic and locked**. In member lists they show greyed o Sim has two kinds of workspaces: - **Personal workspaces** live under your individual account. The number you can create depends on your plan. -- **Shared (organization) workspaces** live under an organization and are available on Team and Enterprise plans. Any organization Owner or Admin can create them. Internal members invited to a shared workspace join the organization and count toward your seat total. Existing Sim users who already belong to another organization can be added as external workspace members instead, giving them access to the workspace without adding them to your organization roster or using one of your seats. +- **Shared (organization) workspaces** live under an organization and are available on Team and Enterprise plans. Any organization Owner or Admin can create them. When you invite someone to a shared workspace you choose their membership: **Member** invites them into the organization (they count toward your seat total, and their own workspaces move in with them — see [Joining an organization](#joining-an-organization)), while **External** gives them access to that workspace only. Invitees who already belong to another organization always become external members — Sim accounts belong to at most one organization. ### Workspace Limits by Plan @@ -48,7 +48,7 @@ Sim has two kinds of workspaces: | **Max** | Up to 10 | — | | **Team / Enterprise** | — | Unlimited (Owners and Admins) | -On Team and Enterprise plans, every workspace you create belongs to the organization. Organization Owners and Admins can create unlimited shared workspaces; organization Members cannot create workspaces. Personal workspaces created before joining the organization remain accessible. Enterprise invites require an available seat at invite time; on Team plans, a seat is added automatically when the invitee accepts. +On Team and Enterprise plans, every workspace you create belongs to the organization. Organization Owners and Admins can create unlimited shared workspaces; organization Members cannot create workspaces. Personal workspaces you owned before joining move into the organization when you accept the invite — see [Joining an organization](#joining-an-organization). Enterprise invites require an available seat at invite time; on Team plans, a seat is added automatically when the invitee accepts. When a Team or Enterprise subscription is cancelled or downgraded, existing shared workspaces stay accessible to current members. New invitations are blocked until the organization is upgraded again. @@ -75,10 +75,23 @@ When inviting someone to a workspace, you can assign one of three permission lev Workspace permissions are separate from organization membership: - **Internal organization members** belong to your organization, appear in the organization roster, and count toward your seat total. Invite new teammates this way when they should be part of your company or team in Sim. -- **External workspace members** have access only to the workspace they are invited to. They keep their own organization membership, do not appear in your organization roster, and do not count toward your organization's seats. Use external access for clients, partners, contractors, or collaborators who already use Sim in another organization. +- **External workspace members** have access only to the workspaces they are invited to. They are not part of your organization: they do not count toward your seats, their own workspaces stay theirs, and they appear in your roster with an **External** label so admins can always see and revoke their access. Use external access for clients, partners, and contractors. + +You choose between the two with the **Membership** option when sending a workspace invite. Two rules apply automatically: invitees who already belong to another organization always become external members (an account belongs to at most one organization), and inviting someone as a Member to the organization itself always includes access to at least one workspace, so every new member has somewhere to land. External workspace members still receive a workspace permission level — Read, Write, or Admin — and that permission controls what they can do inside the workspace. +## Joining an organization + +Accepting an invite that makes you an **internal member** does more than grant access — it brings your work under the organization: + +- **Your workspaces move with you.** Every personal workspace you own, archived ones included, becomes an organization workspace. The accept screen lists exactly which workspaces will move before you accept. +- **You keep Admin on them.** You remain their administrator; organization Owners and Admins also gain Admin access through the normal [role inheritance](#how-roles-inherit), and usage in those workspaces is billed to the organization from then on. +- **Your collaborators are not pulled in.** Anyone you had shared those workspaces with keeps their access as an **external member** — they never join the organization or consume a seat as a side effect of your joining. +- **The workspaces stay if you leave.** If you later leave or are removed from the organization, workspaces you brought in remain with the organization. + +Accepting an **external** invite changes none of this: you get access to the invited workspace only, and everything you own stays yours. + ## What Each Permission Level Can Do Here's a detailed breakdown of what users can do with each permission level: @@ -221,11 +234,13 @@ import { FAQ } from '@/components/ui/faq' \ No newline at end of file diff --git a/apps/docs/content/docs/en/platform/workspaces.mdx b/apps/docs/content/docs/en/platform/workspaces.mdx index bdb3529eae9..804b8b65ff6 100644 --- a/apps/docs/content/docs/en/platform/workspaces.mdx +++ b/apps/docs/content/docs/en/platform/workspaces.mdx @@ -66,9 +66,9 @@ Members join with one of three **permission levels**: **Read**, **Write**, or ** Most workspaces are one of two kinds. -A **personal workspace** lives under your own account. Use it for experimentation and individual work. Your plan sets how many you can create. +A **personal workspace** lives under your own account. Use it for experimentation and individual work. Your plan sets how many you can create. When you join an organization as an internal member, personal workspaces you own move into it — the accept screen shows exactly which ones before you accept. -An **organization workspace** (also called a shared workspace) lives under an organization, available on Team and Enterprise plans. You invite members, each with a permission level. Internal members join the organization and count toward its seat total. External members keep their own organization membership and don't use a seat — that's how partners and clients get access. For agencies and enterprises, this is the workspace you hand to the customer: the app they use, own, and maintain. +An **organization workspace** (also called a shared workspace) lives under an organization, available on Team and Enterprise plans. You invite members, each with a permission level. Internal members join the organization, count toward its seat total, and bring their own workspaces with them. External members stay outside the organization — no seat, and everything they own stays theirs — that's how partners and clients get access. For agencies and enterprises, this is the workspace you hand to the customer: the app they use, own, and maintain. Plan limits, seat counts, and the internal-versus-external distinction live in [Roles and permissions](/platform/permissions). This page covers what a workspace is, not how billing works. From 3a35eef92421fc5d491491fb7170b21fa50eedd4 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 23 Jul 2026 21:23:48 -0700 Subject: [PATCH 03/16] 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 --- apps/docs/app/[lang]/[[...slug]]/page.tsx | 4 +- apps/docs/app/api/chat/route.ts | 27 +++- apps/sim/app/api/invitations/[id]/route.ts | 9 +- .../admin/organizations/[id]/members/route.ts | 117 +++++++++++++----- apps/sim/app/invite/[id]/invite.test.tsx | 49 ++++++-- apps/sim/app/invite/[id]/invite.tsx | 11 +- .../remove-member-dialog.tsx | 15 ++- .../team-management/team-management.tsx | 14 ++- apps/sim/lib/api/contracts/invitations.ts | 7 ++ 9 files changed, 195 insertions(+), 58 deletions(-) diff --git a/apps/docs/app/[lang]/[[...slug]]/page.tsx b/apps/docs/app/[lang]/[[...slug]]/page.tsx index 2d9e00a8849..438968103cf 100644 --- a/apps/docs/app/[lang]/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/[[...slug]]/page.tsx @@ -121,7 +121,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l const urlParts = page.url.split('/').filter(Boolean) let currentPath = '' - urlParts.forEach((part, index) => { + urlParts.forEach((part: string, index: number) => { if (index === 0 && SUPPORTED_LANGUAGES.has(part)) { currentPath = `/${part}` return @@ -131,7 +131,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l const name = part .split('-') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' ') if (index === urlParts.length - 1) { diff --git a/apps/docs/app/api/chat/route.ts b/apps/docs/app/api/chat/route.ts index 2150e044980..915fe9a39c4 100644 --- a/apps/docs/app/api/chat/route.ts +++ b/apps/docs/app/api/chat/route.ts @@ -1,7 +1,13 @@ import { openai } from '@ai-sdk/openai' -import { convertToModelMessages, stepCountIs, streamText, tool, type UIMessage } from 'ai' +import { + convertToModelMessages, + jsonSchema, + stepCountIs, + streamText, + tool, + type UIMessage, +} from 'ai' import { sql } from 'drizzle-orm' -import { z } from 'zod' import { db, docsEmbeddings } from '@/lib/db' import { generateSearchEmbedding } from '@/lib/embeddings' @@ -332,8 +338,21 @@ export async function POST(req: Request) { searchDocs: tool({ description: 'Search the Sim documentation for relevant content. Use this before answering any question about Sim.', - inputSchema: z.object({ - query: z.string().describe('A focused natural-language search query.'), + /** + * The SDK's own schema helper instead of a zod schema: the `ai` + * package's zod-v4 typings lag the workspace zod version, so a zod + * object here fails the tool() overloads whenever the two drift. + */ + inputSchema: jsonSchema<{ query: string }>({ + type: 'object', + properties: { + query: { + type: 'string', + description: 'A focused natural-language search query.', + }, + }, + required: ['query'], + additionalProperties: false, }), execute: async ({ query }) => searchDocs(query, locale), }), diff --git a/apps/sim/app/api/invitations/[id]/route.ts b/apps/sim/app/api/invitations/[id]/route.ts index 929681af4ac..65c4974485c 100644 --- a/apps/sim/app/api/invitations/[id]/route.ts +++ b/apps/sim/app/api/invitations/[id]/route.ts @@ -66,14 +66,18 @@ export const GET = withRouteHandler( /** * Disclosure-only: a preview failure must never block viewing or - * accepting the invitation itself. Expired-but-still-pending rows get - * no preview — acceptance deterministically rejects them. + * accepting the invitation itself — but it also must not read as + * "nothing moves", so failures are flagged for the client to show a + * generic migration notice. Expired-but-still-pending rows get no + * preview — acceptance deterministically rejects them. */ let joinPreview = null + let joinPreviewUnavailable = false if (isInvitee && inv.status === 'pending' && !isInvitationExpired(inv)) { try { joinPreview = await getInvitationJoinPreview(session.user.id, inv) } catch (previewError) { + joinPreviewUnavailable = true logger.warn('Failed to compute invitation join preview', { invitationId: id, error: previewError, @@ -83,6 +87,7 @@ export const GET = withRouteHandler( return NextResponse.json({ joinPreview, + joinPreviewUnavailable, invitation: { id: inv.id, kind: inv.kind, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts index 287e5196374..13c01312aa5 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts @@ -30,7 +30,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { member, organization, user, userStats } from '@sim/db/schema' +import { member, organization, user, userStats, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' import { @@ -39,10 +39,15 @@ import { } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization' -import { addUserToOrganization } from '@/lib/billing/organizations/membership' +import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' +import { ensureUserInOrganizationTx } from '@/lib/billing/organizations/membership' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { attachOwnedWorkspacesToOrganization } from '@/lib/workspaces/organization-workspaces' +import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' +import { + attachOwnedWorkspacesToOrganizationTx, + ownedAttachableWorkspacesWhere, +} from '@/lib/workspaces/organization-workspaces' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { adminInvalidJsonResponse, @@ -254,46 +259,84 @@ export const POST = withRouteHandler( ) } - const result = await addUserToOrganization({ - userId, - organizationId, - role, - skipBillingLogic: !isBillingEnabled, - }) - - if (!result.success) { - return badRequestResponse(result.error || 'Failed to add member') - } - /** - * A new member's owned personal workspaces follow them into the org - * (collaborators stay external). Best-effort: membership is already - * committed, and an attach failure leaves the pre-join status quo. + * Membership and the workspace sweep commit or roll back together: + * every workspace the new member owns follows them into the org + * (collaborators stay external), and an attach failure aborts the whole + * add instead of leaving a member whose workspaces escaped the sweep. + * Lock order mirrors invitation acceptance: workspace advisory locks + * first, then the organization lock inside ensureUserInOrganizationTx. */ - try { - const attach = await attachOwnedWorkspacesToOrganization({ + const result = await db.transaction(async (tx) => { + const ownedWorkspaceIds = ( + await tx + .select({ id: workspace.id }) + .from(workspace) + .where(ownedAttachableWorkspacesWhere({ userId, includeArchived: true })) + ).map((row) => row.id) + if (ownedWorkspaceIds.length > 0) { + await acquireInvitationMutationLocks(tx, { + invitationIds: [], + workspaceIds: ownedWorkspaceIds, + }) + } + + const membership = await ensureUserInOrganizationTx(tx, { + userId, + organizationId, + role, + skipBillingLogic: !isBillingEnabled, + }) + if (!membership.success || !membership.memberId || membership.alreadyMember) { + return { membership, attachedWorkspaceIds: [], usageLimitUserIds: [] } + } + + if (ownedWorkspaceIds.length === 0) { + return { membership, attachedWorkspaceIds: [], usageLimitUserIds: [] } + } + const attach = await attachOwnedWorkspacesToOrganizationTx(tx, { ownerUserId: userId, organizationId, + workspaceIds: ownedWorkspaceIds, externalMemberPolicy: 'external-all', + ownerMatch: 'owner', includeArchived: true, }) - if (attach.attachedWorkspaceIds.length > 0) { - logger.info('Attached new member workspaces to organization', { - userId, - organizationId, - attachedWorkspaceCount: attach.attachedWorkspaceIds.length, - }) + return { + membership, + attachedWorkspaceIds: attach.attachedWorkspaceIds, + usageLimitUserIds: attach.usageLimitUserIds, } - } catch (attachError) { - logger.error('Failed to attach new member workspaces to organization', { + }) + + if (!result.membership.success || !result.membership.memberId) { + return badRequestResponse(result.membership.error || 'Failed to add member') + } + if (result.membership.alreadyMember) { + return badRequestResponse('User is already a member of this organization') + } + + if (result.attachedWorkspaceIds.length > 0) { + logger.info('Attached new member workspaces to organization', { userId, organizationId, - error: attachError, + attachedWorkspaceCount: result.attachedWorkspaceIds.length, }) } + for (const limitUserId of new Set(result.usageLimitUserIds)) { + try { + await syncUsageLimitsFromSubscription(limitUserId) + } catch (syncError) { + logger.error('Failed to sync usage limits after admin member add', { + userId: limitUserId, + organizationId, + error: syncError, + }) + } + } const data: AdminMember = { - id: result.memberId!, + id: result.membership.memberId, userId, organizationId, role, @@ -304,8 +347,9 @@ export const POST = withRouteHandler( logger.info(`Admin API: Added user ${userId} to organization ${organizationId}`, { role, - memberId: result.memberId, - billingActions: result.billingActions, + memberId: result.membership.memberId, + billingActions: result.membership.billingActions, + attachedWorkspaceCount: result.attachedWorkspaceIds.length, }) recordAudit({ @@ -315,7 +359,12 @@ export const POST = withRouteHandler( resourceType: AuditResourceType.ORGANIZATION, resourceId: organizationId, description: `Admin API added member to organization as ${role}`, - metadata: { targetUserId: userId, role, memberId: result.memberId }, + metadata: { + targetUserId: userId, + role, + memberId: result.membership.memberId, + attachedWorkspaceIds: result.attachedWorkspaceIds, + }, request, }) @@ -323,8 +372,8 @@ export const POST = withRouteHandler( ...data, action: 'created' as const, billingActions: { - proUsageSnapshotted: result.billingActions.proUsageSnapshotted, - proCancelledAtPeriodEnd: result.billingActions.proCancelledAtPeriodEnd, + proUsageSnapshotted: result.membership.billingActions.proUsageSnapshotted, + proCancelledAtPeriodEnd: result.membership.billingActions.proCancelledAtPeriodEnd, }, }) } catch (error) { diff --git a/apps/sim/app/invite/[id]/invite.test.tsx b/apps/sim/app/invite/[id]/invite.test.tsx index 7e2c3310010..01eb17609e3 100644 --- a/apps/sim/app/invite/[id]/invite.test.tsx +++ b/apps/sim/app/invite/[id]/invite.test.tsx @@ -46,13 +46,48 @@ vi.mock('next/navigation', () => ({ useSearchParams: () => mockSearchParams, })) -vi.mock('@tanstack/react-query', () => ({ - useQueryClient: () => ({ - cancelQueries: mockCancelQueries, - invalidateQueries: mockInvalidateQueries, - setQueryData: mockSetQueryData, - }), -})) +vi.mock('@tanstack/react-query', async () => { + const React = await import('react') + return { + useQueryClient: () => ({ + cancelQueries: mockCancelQueries, + invalidateQueries: mockInvalidateQueries, + setQueryData: mockSetQueryData, + }), + /** + * Minimal useQuery stand-in: runs the queryFn once when enabled and + * exposes { data, error, isPending } — enough for the invitation fetch. + */ + useQuery: (options: { + queryFn: (context: { signal?: AbortSignal }) => Promise + enabled?: boolean + }) => { + const [state, setState] = React.useState<{ + data: unknown + error: unknown + isPending: boolean + }>({ data: undefined, error: null, isPending: true }) + const enabled = options.enabled !== false + React.useEffect(() => { + if (!enabled) return + let cancelled = false + options.queryFn({}).then( + (data) => { + if (!cancelled) setState({ data, error: null, isPending: false }) + }, + (error) => { + if (!cancelled) setState({ data: undefined, error, isPending: false }) + } + ) + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled]) + return state + }, + } +}) vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson, diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index 9f156548ff1..781d3641caf 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -237,6 +237,7 @@ export default function Invite() { }) const invitation = invitationQuery.data?.invitation ?? null const joinPreview = invitationQuery.data?.joinPreview ?? null + const joinPreviewUnavailable = invitationQuery.data?.joinPreviewUnavailable === true const isLoading = Boolean(session?.user) && invitationQuery.isPending const fetchError = invitationQuery.error @@ -455,7 +456,15 @@ export default function Invite() { const isOrg = invitation?.kind === 'organization' const organizationLabel = invitation?.organizationName || 'the organization' - const migrationNotice = buildWorkspaceMigrationNotice(joinPreview, organizationLabel) + /** + * When the server could not compute the preview, fall back to a generic + * migration notice for membership invites — a missing preview must never + * read as "nothing moves". + */ + const migrationNotice = + joinPreviewUnavailable && invitation?.membershipIntent !== 'external' + ? ` 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.` + : buildWorkspaceMigrationNotice(joinPreview, organizationLabel) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx index 2bf33bc9165..8dd701023e7 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx @@ -16,6 +16,10 @@ interface RemoveMemberDialogProps { * never blocking. */ breakingCredentials?: string[] + /** The impact check is still loading — confirm is held until it resolves. */ + credentialImpactPending?: boolean + /** The impact check failed — removal stays possible, with a caution shown. */ + credentialImpactFailed?: boolean isSubmitting?: boolean error?: Error | null onOpenChange: (open: boolean) => void @@ -33,6 +37,8 @@ export function RemoveMemberDialog({ isSelfRemoval = false, isExternalRemoval = false, breakingCredentials = [], + credentialImpactPending = false, + credentialImpactFailed = false, isSubmitting = false, }: RemoveMemberDialogProps) { const title = isSelfRemoval @@ -43,8 +49,9 @@ export function RemoveMemberDialog({ const errorMessage = error ? getErrorMessage(error) || null : null - const credentialWarning = - breakingCredentials.length > 0 + const credentialWarning = credentialImpactFailed + ? `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.` + : breakingCredentials.length > 0 ? `${breakingCredentials.length === 1 ? 'A credential' : `${breakingCredentials.length} credentials`} ${ isSelfRemoval ? 'you own' : 'they own' } (${formatQuotedNameList(breakingCredentials, MAX_LISTED_CREDENTIALS)}) will stop working in organization workspaces until another member reconnects ${ @@ -79,7 +86,9 @@ export function RemoveMemberDialog({ confirm={{ label: isSelfRemoval ? 'Leave Organization' : 'Remove', onClick: () => onConfirmRemove(), - pending: isSubmitting, + pending: isSubmitting || credentialImpactPending, + pendingLabel: + credentialImpactPending && !isSubmitting ? 'Checking credentials…' : undefined, }} > {credentialWarning ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index 57d5817616e..2bc12f0e693 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -76,11 +76,13 @@ export function TeamManagement({ const [orgName, setOrgName] = useState('') const [orgSlug, setOrgSlug] = useState('') - const { data: removalImpactCredentials } = useMemberRemovalImpact( - organizationId, - removeMemberDialog.memberId, - { enabled: removeMemberDialog.open } - ) + const { + data: removalImpactCredentials, + isLoading: isRemovalImpactLoading, + isError: isRemovalImpactError, + } = useMemberRemovalImpact(organizationId, removeMemberDialog.memberId, { + enabled: removeMemberDialog.open, + }) const totalSeats = organizationBillingData?.data?.totalSeats ?? 0 const usedSeats = organizationBillingData?.data?.members?.length ?? 0 @@ -385,6 +387,8 @@ export function TeamManagement({ breakingCredentials={[ ...new Set(removalImpactCredentials?.map((c) => c.displayName) ?? []), ]} + credentialImpactPending={isRemovalImpactLoading} + credentialImpactFailed={isRemovalImpactError} isSubmitting={removeMemberMutation.isPending} error={removeMemberMutation.error} onOpenChange={(open: boolean) => { diff --git a/apps/sim/lib/api/contracts/invitations.ts b/apps/sim/lib/api/contracts/invitations.ts index 8de6485a967..8c79512db92 100644 --- a/apps/sim/lib/api/contracts/invitations.ts +++ b/apps/sim/lib/api/contracts/invitations.ts @@ -163,6 +163,13 @@ export const getInvitationContract = defineRouteContract({ invitation: invitationDetailsSchema, /** Invitee-only preview of what accepting will do; null for other viewers. */ joinPreview: invitationJoinPreviewSchema.nullable(), + /** + * True when the preview could not be computed for a pending + * invitee-viewed invitation. Accepting may still move owned workspaces, + * so the client must fall back to a generic migration notice rather + * than treating the missing preview as "nothing moves". + */ + joinPreviewUnavailable: z.boolean().optional(), }), }, }) From 04541319ab3053bc8219021d1ae9e3e6a077d738 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 23 Jul 2026 21:37:01 -0700 Subject: [PATCH 04/16] fix(organizations): close the concurrent-workspace escape in the join sweep Personal workspace creation now serializes with organization joins on the user's billing-identity lock and re-verifies membership inside its transaction; both join paths (invite acceptance and the v1 admin add) re-read the owned-workspace set under that lock after the member insert and roll the whole join back when it diverged from the advisory-lock plan, so a workspace created mid-join can never land outside the organization. Co-Authored-By: Claude Fable 5 --- .../admin/organizations/[id]/members/route.ts | 35 ++++ apps/sim/app/api/workspaces/route.ts | 43 +++- apps/sim/lib/invitations/core.test.ts | 76 +++++++ apps/sim/lib/invitations/core.ts | 185 +++++++++++------- 4 files changed, 271 insertions(+), 68 deletions(-) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts index 13c01312aa5..db2b6d50da3 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts @@ -64,6 +64,18 @@ import { createPaginationMeta, } from '@/app/api/v1/admin/types' +/** + * The target's owned-workspace set changed between the advisory-lock capture + * and the membership commit; the add is aborted so no workspace escapes the + * sweep. Safe to retry immediately. + */ +class WorkspaceSetChangedDuringAddError extends Error { + constructor() { + super('Owned workspaces changed while adding the member') + this.name = 'WorkspaceSetChangedDuringAddError' + } +} + const logger = createLogger('AdminOrganizationMembersAPI') interface RouteParams { @@ -291,6 +303,24 @@ export const POST = withRouteHandler( return { membership, attachedWorkspaceIds: [], usageLimitUserIds: [] } } + /** + * ensureUserInOrganizationTx holds the user's billing-identity lock, + * which personal workspace creation also takes — so re-reading the + * owned set here is race-free. A set that changed since the pre-lock + * capture means a workspace escaped the advisory-lock plan: abort the + * whole add (rolling back the membership) rather than committing a + * member whose workspace dodged the sweep. + */ + const currentOwnedIds = ( + await tx + .select({ id: workspace.id }) + .from(workspace) + .where(ownedAttachableWorkspacesWhere({ userId, includeArchived: true })) + ).map((row) => row.id) + if ([...currentOwnedIds].sort().join() !== [...ownedWorkspaceIds].sort().join()) { + throw new WorkspaceSetChangedDuringAddError() + } + if (ownedWorkspaceIds.length === 0) { return { membership, attachedWorkspaceIds: [], usageLimitUserIds: [] } } @@ -377,6 +407,11 @@ export const POST = withRouteHandler( }, }) } catch (error) { + if (error instanceof WorkspaceSetChangedDuringAddError) { + return badRequestResponse( + "The user's workspaces changed while adding them — retry the add." + ) + } logger.error('Admin API: Failed to add organization member', { error, organizationId }) return internalErrorResponse('Failed to add organization member') } diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index e63aab34a02..1cb75eaee71 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { permissions, type WorkspaceMode, workflow, workspace } from '@sim/db/schema' +import { member, permissions, type WorkspaceMode, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' @@ -10,6 +10,7 @@ import { createWorkspaceContract } from '@/lib/api/contracts/workspaces' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' +import { acquireUserBillingIdentityLock } from '@/lib/billing/organizations/billing-identity-lock' import { PlatformEvents } from '@/lib/core/telemetry' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' @@ -26,6 +27,17 @@ import { const logger = createLogger('Workspaces') +/** + * Thrown when the creator became an organization member between the + * creation-policy read and the insert — the workspace must not land personal. + */ +class PersonalWorkspaceCreationRacedError extends Error { + constructor() { + super('User joined an organization while creating a personal workspace') + this.name = 'PersonalWorkspaceCreationRacedError' + } +} + // Get all workspaces for the current user export const GET = withRouteHandler(async (request: Request) => { const session = await getSession() @@ -156,6 +168,15 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ workspace: newWorkspace }) } catch (error) { + if (error instanceof PersonalWorkspaceCreationRacedError) { + return NextResponse.json( + { + error: + 'You joined an organization while this workspace was being created. Organization members create organization workspaces — try again.', + }, + { status: 409 } + ) + } logger.error('Error creating workspace:', error) return NextResponse.json({ error: 'Failed to create workspace' }, { status: 500 }) } @@ -207,6 +228,26 @@ async function createWorkspace({ try { await db.transaction(async (tx) => { + /** + * Personal creation serializes with organization joins on the user's + * billing-identity lock: joins hold it while sweeping the joiner's + * owned workspaces, so re-checking membership under it here means a + * workspace can never be created personal after (or while) its owner + * joins an organization — the creation-policy read above this + * transaction can be stale by the time the insert runs. + */ + if (!organizationId) { + await acquireUserBillingIdentityLock(tx, userId) + const [existingMembership] = await tx + .select({ id: member.id }) + .from(member) + .where(eq(member.userId, userId)) + .limit(1) + if (existingMembership) { + throw new PersonalWorkspaceCreationRacedError() + } + } + await tx.insert(workspace).values({ id: workspaceId, name, diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index ebc0635f5d6..367a3a9115f 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -463,6 +463,8 @@ describe('acceptInvitation', () => { [], // Candidate personal workspaces covered by the acceptance lock set. [], + // Post-join owned-set re-check under the billing-identity lock. + [], // Grant-txn membership re-check under the lock: member still present. [{ id: 'member-1' }], ]) @@ -572,6 +574,8 @@ describe('acceptInvitation', () => { // Invitee-owned personal workspaces for the acceptance lock plan. [], [], + // Post-join owned-set re-check under the billing-identity lock. + [], [{ id: 'member-1' }], [], [{ variables: {} }], @@ -648,6 +652,8 @@ describe('acceptInvitation', () => { [{ name: 'Owner', email: 'owner@example.com' }], // Invitee-owned personal workspaces for the acceptance lock plan. [{ id: 'joiner-ws-1' }], + // Post-join owned-set re-check under the billing-identity lock. + [{ id: 'joiner-ws-1' }], // Grant-txn membership re-check under the lock: member still present. [{ id: 'member-1' }], ]) @@ -677,6 +683,70 @@ describe('acceptInvitation', () => { expect(mockSyncUsageLimitsFromSubscription).toHaveBeenCalledWith('invitee-user') }) + it('rolls back acceptance when the owned-workspace set changes concurrently', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + name: 'Workspace', + ownerId: 'owner-1', + organizationId: 'org-1', + workspaceMode: 'organization', + billedAccountUserId: 'owner-1', + }) + mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({ + success: true, + organizationId: 'org-1', + fixedSeats: false, + }) + + queueWhereResponses([ + [ + { + id: 'inv-1', + kind: 'workspace', + email: 'invitee@example.com', + organizationId: 'org-1', + membershipIntent: 'internal', + inviterId: 'owner-1', + role: 'member', + status: 'pending', + token: 'tok-1', + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + [ + { + id: 'grant-1', + workspaceId: 'workspace-1', + permission: 'write', + workspaceName: 'Workspace', + }, + ], + [{ name: 'Acme' }], + [{ name: 'Owner', email: 'owner@example.com' }], + // Invitee-owned personal workspaces for the acceptance lock plan. + [{ id: 'joiner-ws-1' }], + // Post-join re-check sees a workspace created after the lock plan. + [{ id: 'joiner-ws-1' }, { id: 'joiner-ws-2' }], + ]) + + const result = await acceptInvitation({ + userId: 'invitee-user', + userEmail: 'invitee@example.com', + invitationId: 'inv-1', + token: 'tok-1', + }) + + expect(result).toEqual({ + success: false, + kind: 'server-error', + message: 'Your workspaces changed while accepting — please try again.', + }) + expect(mockAttachOwnedWorkspacesToOrganizationTx).not.toHaveBeenCalled() + expect(auditMock.recordAudit).not.toHaveBeenCalled() + }) + it('grants external access without joining when the workspace entered an org after the invite was sent', async () => { mockGetWorkspaceWithOwner.mockResolvedValue({ id: 'workspace-1', @@ -789,6 +859,8 @@ describe('acceptInvitation', () => { [{ name: 'Owner', email: 'owner@example.com' }], // Invitee-owned personal workspaces for the acceptance lock plan. [], + // Post-join owned-set re-check under the billing-identity lock. + [], // Grant-txn membership re-check under the lock: member still present. [{ id: 'member-1' }], // Invitation status update under the lock. @@ -922,6 +994,8 @@ describe('acceptInvitation', () => { [{ name: 'Owner', email: 'owner@example.com' }], // Invitee-owned personal workspaces for the acceptance lock plan. [], + // Post-join owned-set re-check under the billing-identity lock. + [], // Grant-txn membership re-check under the lock: member still present. [{ id: 'member-1' }], ]) @@ -996,6 +1070,8 @@ describe('acceptInvitation', () => { [{ name: 'Owner', email: 'owner@example.com' }], // Invitee-owned personal workspaces for the acceptance lock plan. [], + // Post-join owned-set re-check under the billing-identity lock. + [], [{ id: 'member-1' }], ]) diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 5cd3c416a0d..e40f4ffe192 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -383,6 +383,18 @@ class MembershipRevokedDuringAcceptError extends Error { } } +/** + * Thrown after the member insert when the invitee's owned-workspace set no + * longer matches the pre-lock plan (a workspace was created concurrently and + * would escape the sweep). Rolls the whole acceptance back; safe to retry. + */ +class JoinerWorkspacesChangedDuringAcceptError extends Error { + constructor() { + super('Owned workspaces changed during invite acceptance') + this.name = 'JoinerWorkspacesChangedDuringAcceptError' + } +} + interface InvitationAcceptancePostCommitEffects { organizationId: string | null memberRole: string | null @@ -483,67 +495,84 @@ export async function acceptInvitation( acceptedInvitation: null, membershipAlreadyExists: false, } - const result = await db.transaction(async (tx): Promise => { - await acquireInvitationMutationLocks(tx, { - invitationIds: [input.invitationId], - workspaceIds: [], - }) + const result = await db + .transaction(async (tx): Promise => { + await acquireInvitationMutationLocks(tx, { + invitationIds: [input.invitationId], + workspaceIds: [], + }) - await tx.execute(sql`select id from invitation where id = ${input.invitationId} for update`) + await tx.execute(sql`select id from invitation where id = ${input.invitationId} for update`) - const inv = await getInvitationById(input.invitationId, tx) - if (!inv) { - return { success: false, kind: 'not-found' } - } + const inv = await getInvitationById(input.invitationId, tx) + if (!inv) { + return { success: false, kind: 'not-found' } + } - /** - * Cheap validity checks run before the workspace lock plan so replayed, - * expired, or mismatched accepts pay no workspace queries or advisory - * locks. The invitation row is already advisory- and row-locked above, - * so these reads cannot race a concurrent acceptance. - */ - if (input.token && inv.token !== input.token) { - return { success: false, kind: 'invalid-token' } - } - if (inv.status !== 'pending') { - return { success: false, kind: 'already-processed' } - } - if (isInvitationExpired(inv)) { - await tx - .update(invitation) - .set({ status: 'expired', updatedAt: new Date() }) - .where(and(eq(invitation.id, inv.id), eq(invitation.status, 'pending'))) - return { success: false, kind: 'expired' } - } - if (normalizeEmail(input.userEmail) !== normalizeEmail(inv.email)) { - return { success: false, kind: 'email-mismatch' } - } + /** + * Cheap validity checks run before the workspace lock plan so replayed, + * expired, or mismatched accepts pay no workspace queries or advisory + * locks. The invitation row is already advisory- and row-locked above, + * so these reads cannot race a concurrent acceptance. + */ + if (input.token && inv.token !== input.token) { + return { success: false, kind: 'invalid-token' } + } + if (inv.status !== 'pending') { + return { success: false, kind: 'already-processed' } + } + if (isInvitationExpired(inv)) { + await tx + .update(invitation) + .set({ status: 'expired', updatedAt: new Date() }) + .where(and(eq(invitation.id, inv.id), eq(invitation.status, 'pending'))) + return { success: false, kind: 'expired' } + } + if (normalizeEmail(input.userEmail) !== normalizeEmail(inv.email)) { + return { success: false, kind: 'email-mismatch' } + } - const lockPlan = await getInvitationAcceptanceWorkspaceLockIds(tx, inv, input.userId) - await acquireInvitationMutationLocks(tx, { - invitationIds: [], - workspaceIds: [...new Set([...lockPlan.workspaceIds, ...lockPlan.joinerAttachWorkspaceIds])], - }) + const lockPlan = await getInvitationAcceptanceWorkspaceLockIds(tx, inv, input.userId) + await acquireInvitationMutationLocks(tx, { + invitationIds: [], + workspaceIds: [ + ...new Set([...lockPlan.workspaceIds, ...lockPlan.joinerAttachWorkspaceIds]), + ], + }) - // Re-read and row-lock the primary workspace only after the shared - // workspace advisory lock is held. If a move won the lock first, every - // billing and membership decision below now uses the committed post-move - // organization/billing identity rather than the pre-lock snapshot. - const lockedPrimaryWorkspace = inv.grants[0] - ? await getWorkspaceWithOwner(inv.grants[0].workspaceId, { - executor: tx, - forUpdate: true, + // Re-read and row-lock the primary workspace only after the shared + // workspace advisory lock is held. If a move won the lock first, every + // billing and membership decision below now uses the committed post-move + // organization/billing identity rather than the pre-lock snapshot. + const lockedPrimaryWorkspace = inv.grants[0] + ? await getWorkspaceWithOwner(inv.grants[0].workspaceId, { + executor: tx, + forUpdate: true, + }) + : null + + return acceptLockedInvitation( + input, + inv, + { ...lockPlan, primaryWorkspace: lockedPrimaryWorkspace }, + tx, + effects + ) + }) + .catch((error): AcceptInvitationResult => { + if (error instanceof JoinerWorkspacesChangedDuringAcceptError) { + logger.warn('Invite acceptance rolled back: owned workspaces changed concurrently', { + invitationId: input.invitationId, + userId: input.userId, }) - : null - - return acceptLockedInvitation( - input, - inv, - { ...lockPlan, primaryWorkspace: lockedPrimaryWorkspace }, - tx, - effects - ) - }) + return { + success: false, + kind: 'server-error', + message: 'Your workspaces changed while accepting — please try again.', + } + } + throw error + }) if (result.success) { await runInvitationAcceptancePostCommitEffects(input, effects) } @@ -678,19 +707,41 @@ async function acceptLockedInvitation( * membership and seats never grow as a side effect of someone else's * join. Fresh joins only: pre-existing members' estates are left * untouched until an announced backfill. + * + * ensureUserInOrganizationTx holds the user's billing-identity lock, + * which personal workspace creation also takes — so the owned set is + * re-read here race-free. A set that changed since the pre-lock plan + * means a workspace escaped the advisory locks: the acceptance is + * rolled back (retry succeeds with the fresh set) instead of committing + * a member whose workspace dodged the sweep. */ - if (!membershipResult.alreadyMember && lockPlan.joinerAttachWorkspaceIds.length > 0) { - await acquireOrganizationMutationLock(tx, targetOrganizationId) - const attachResult = await attachOwnedWorkspacesToOrganizationTx(tx, { - ownerUserId: input.userId, - organizationId: targetOrganizationId, - workspaceIds: lockPlan.joinerAttachWorkspaceIds, - externalMemberPolicy: 'external-all', - ownerMatch: 'owner', - includeArchived: true, - }) - effects.syncUsageLimitUserIds.push(...attachResult.usageLimitUserIds) - effects.attachedWorkspaceIds = attachResult.attachedWorkspaceIds + if (!membershipResult.alreadyMember) { + const currentOwnedIds = ( + await tx + .select({ id: workspace.id }) + .from(workspace) + .where(ownedAttachableWorkspacesWhere({ userId: input.userId, includeArchived: true })) + ).map((row) => row.id) + if ( + [...currentOwnedIds].sort().join() !== + [...lockPlan.joinerAttachWorkspaceIds].sort().join() + ) { + throw new JoinerWorkspacesChangedDuringAcceptError() + } + + if (lockPlan.joinerAttachWorkspaceIds.length > 0) { + await acquireOrganizationMutationLock(tx, targetOrganizationId) + const attachResult = await attachOwnedWorkspacesToOrganizationTx(tx, { + ownerUserId: input.userId, + organizationId: targetOrganizationId, + workspaceIds: lockPlan.joinerAttachWorkspaceIds, + externalMemberPolicy: 'external-all', + ownerMatch: 'owner', + includeArchived: true, + }) + effects.syncUsageLimitUserIds.push(...attachResult.usageLimitUserIds) + effects.attachedWorkspaceIds = attachResult.attachedWorkspaceIds + } } } else { shouldJoinOrganization = false From 350b3485163ccc480011fe0d9485b09a9f566974 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 23 Jul 2026 21:45:55 -0700 Subject: [PATCH 05/16] fix(organizations): fail stale-grant member joins and re-resolve the creation race client-side A member-role org acceptance whose grants all turned stale now rolls back with workspace-not-found instead of stranding a workspace-less member, and the workspace resolver treats the creation-vs-join 409 as a signal to re-resolve (the user is authenticated with org workspaces) rather than falling into the login path. Co-Authored-By: Claude Fable 5 --- .../app/api/invitations/[id]/accept/route.ts | 1 + apps/sim/app/workspace/page.tsx | 10 +++ apps/sim/lib/invitations/core.test.ts | 65 +++++++++++++++++++ apps/sim/lib/invitations/core.ts | 38 +++++++++++ 4 files changed, 114 insertions(+) diff --git a/apps/sim/app/api/invitations/[id]/accept/route.ts b/apps/sim/app/api/invitations/[id]/accept/route.ts index 6fb0af7972f..0d2dbe60d83 100644 --- a/apps/sim/app/api/invitations/[id]/accept/route.ts +++ b/apps/sim/app/api/invitations/[id]/accept/route.ts @@ -33,6 +33,7 @@ export const POST = withRouteHandler( if (!result.success) { const statusMap: Record = { 'not-found': 404, + 'workspace-not-found': 404, 'invalid-token': 400, 'already-processed': 400, expired: 400, diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index f9a441486d6..c365060e309 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -245,6 +245,16 @@ async function handleNoWorkspaces(router: ReturnType): Promise } logger.error('Failed to create default workspace') } catch (error) { + /** + * 409 means the user joined an organization while the default workspace + * was being created — they are still authenticated and likely have org + * workspaces now, so re-resolve instead of falling into the login path. + */ + if (isApiClientError(error) && error.status === 409) { + logger.info('Default workspace creation raced an organization join; re-resolving') + window.location.reload() + return + } logger.error('Error creating default workspace:', error) } router.replace('/login') diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index 367a3a9115f..9069bb818b9 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -683,6 +683,71 @@ describe('acceptInvitation', () => { expect(mockSyncUsageLimitsFromSubscription).toHaveBeenCalledWith('invitee-user') }) + it('rolls back a member-role org acceptance when every grant turned stale', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + name: 'Workspace', + ownerId: 'owner-1', + organizationId: 'org-1', + workspaceMode: 'organization', + billedAccountUserId: 'owner-1', + }) + mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({ + success: true, + organizationId: 'org-1', + fixedSeats: false, + }) + + queueWhereResponses([ + [ + { + id: 'inv-1', + kind: 'organization', + email: 'invitee@example.com', + organizationId: 'org-1', + membershipIntent: 'internal', + inviterId: 'owner-1', + role: 'member', + status: 'pending', + token: 'tok-1', + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + [ + { + id: 'grant-1', + workspaceId: 'workspace-1', + permission: 'write', + workspaceName: 'Workspace', + }, + ], + [{ name: 'Acme' }], + [{ name: 'Owner', email: 'owner@example.com' }], + // Invitee-owned personal workspaces for the acceptance lock plan. + [], + // Post-join owned-set re-check under the billing-identity lock. + [], + // Grant-txn membership re-check under the lock: member still present. + [{ id: 'member-1' }], + // Invitation status update under the lock. + [], + // The granted workspace left the stamped organization. + [{ organizationId: 'org-elsewhere' }], + ]) + + const result = await acceptInvitation({ + userId: 'invitee-user', + userEmail: 'invitee@example.com', + invitationId: 'inv-1', + token: 'tok-1', + }) + + expect(result).toEqual({ success: false, kind: 'workspace-not-found' }) + expect(auditMock.recordAudit).not.toHaveBeenCalled() + }) + it('rolls back acceptance when the owned-workspace set changes concurrently', async () => { mockGetWorkspaceWithOwner.mockResolvedValue({ id: 'workspace-1', diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index e40f4ffe192..ec86c564f03 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -340,6 +340,7 @@ export async function expireStalePendingInvitationsForWorkspaces( export type AcceptInvitationFailure = | { kind: 'not-found' } + | { kind: 'workspace-not-found' } | { kind: 'already-processed' } | { kind: 'expired' } | { kind: 'email-mismatch' } @@ -395,6 +396,18 @@ class JoinerWorkspacesChangedDuringAcceptError extends Error { } } +/** + * Thrown when every grant on a member-role organization invite turned stale + * (the workspaces left the stamped organization), which would strand the new + * member with no workspace. Rolls the whole acceptance back. + */ +class AllGrantsStaleDuringAcceptError extends Error { + constructor() { + super('All organization-invite grants turned stale during acceptance') + this.name = 'AllGrantsStaleDuringAcceptError' + } +} + interface InvitationAcceptancePostCommitEffects { organizationId: string | null memberRole: string | null @@ -571,6 +584,13 @@ export async function acceptInvitation( message: 'Your workspaces changed while accepting — please try again.', } } + if (error instanceof AllGrantsStaleDuringAcceptError) { + logger.warn('Invite acceptance rolled back: every grant turned stale', { + invitationId: input.invitationId, + userId: input.userId, + }) + return { success: false, kind: 'workspace-not-found' } + } throw error }) if (result.success) { @@ -839,6 +859,24 @@ async function acceptLockedInvitation( acceptedWorkspaceIds.push(grant.workspaceId) } + + /** + * A member-role organization invite whose grants ALL turned stale would + * create a member with no workspace to land in — the exact dead end the + * invite-time grant requirement exists to prevent. Roll the whole + * acceptance (including the member insert) back instead; admins are + * exempt since they derive access to every organization workspace. + */ + if ( + inv.kind === 'organization' && + shouldJoinOrganization && + !membershipAlreadyExists && + !isOrgAdminRole(inv.role) && + inv.grants.length > 0 && + acceptedWorkspaceIds.length === 0 + ) { + throw new AllGrantsStaleDuringAcceptError() + } } catch (grantError) { if (grantError instanceof MembershipRevokedDuringAcceptError) { logger.warn('Aborted invite acceptance: org membership revoked concurrently', { From c5df35d0691092ba2a29a6087a5aad8b87a378d2 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 23 Jul 2026 21:53:24 -0700 Subject: [PATCH 06/16] fix(invitations): mirror the stale-grant gate in the join preview The accept-screen preview now returns no-join for a member-role org invite whose grants all left the stamped organization, matching the acceptance-side rollback so the disclosure never promises a migration that acceptance would refuse. Co-Authored-By: Claude Fable 5 --- apps/sim/lib/invitations/core.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index ec86c564f03..467557b45a8 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -235,6 +235,33 @@ export async function getInvitationJoinPreview( if (!(await stampedOrganizationAllowsEscalation(inv, workspaceOrganizationId))) return noJoin + /** + * Mirror of acceptance's stale-grant gate: a member-role organization + * invite whose grants ALL left the stamped organization rolls back at + * accept, so the preview must not promise a migration for it. + */ + if ( + inv.kind === 'organization' && + inv.organizationId && + !isOrgAdminRole(inv.role) && + inv.grants.length > 0 + ) { + const [liveGrant] = await db + .select({ id: workspace.id }) + .from(workspace) + .where( + and( + inArray( + workspace.id, + inv.grants.map((grant) => grant.workspaceId) + ), + eq(workspace.organizationId, inv.organizationId) + ) + ) + .limit(1) + if (!liveGrant) return noJoin + } + /** * Mirror acceptance's billing gates: an unusable organization subscription * (or, for personal-workspace invites, a billed owner without a convertible From 845a5f420328625bdd1354eee3c64b3894dfb6a3 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 23 Jul 2026 22:00:14 -0700 Subject: [PATCH 07/16] fix(workspaces): survive the join race on lazy default creation and gate removal on live impact data The workspace list GET now re-lists (returning the join sweep's workspaces) when lazy default creation loses the race to an organization join instead of failing with a 500, and the remove-member dialog gates its confirm on isFetching so a background refetch can never let an admin confirm against a stale credential-impact list. Co-Authored-By: Claude Fable 5 --- apps/sim/app/api/workspaces/route.ts | 31 ++++++++++++++++--- .../team-management/team-management.tsx | 9 ++++-- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 1cb75eaee71..28e06f5dfd4 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -70,11 +70,32 @@ export const GET = withRouteHandler(async (request: Request) => { return NextResponse.json({ workspaces: [], lastActiveWorkspaceId, creationPolicy }) } - const defaultWorkspace = await createDefaultWorkspace( - session.user.id, - session.user.name, - creationPolicy - ) + let defaultWorkspace: Awaited> + try { + defaultWorkspace = await createDefaultWorkspace( + session.user.id, + session.user.name, + creationPolicy + ) + } catch (error) { + /** + * The user joined an organization between the empty list read and the + * default-workspace insert. Their workspaces (the join sweep's output) + * exist now — re-list and return that instead of failing the load. + */ + if (error instanceof PersonalWorkspaceCreationRacedError) { + logger.info('Default workspace creation raced an organization join; re-listing', { + userId: session.user.id, + }) + const refreshedPayload = await listWorkspacesForViewer({ + userId: session.user.id, + activeOrganizationId, + scope, + }) + return NextResponse.json(refreshedPayload) + } + throw error + } await migrateExistingWorkflows(session.user.id, defaultWorkspace.id) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index 2bc12f0e693..5f1a26fa0e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -76,9 +76,14 @@ export function TeamManagement({ const [orgName, setOrgName] = useState('') const [orgSlug, setOrgSlug] = useState('') + /** + * `isFetching` (not `isLoading`) gates the confirm button: a background + * refetch of cached data must also hold removal so the admin never + * confirms against a stale credential-impact list. + */ const { data: removalImpactCredentials, - isLoading: isRemovalImpactLoading, + isFetching: isRemovalImpactFetching, isError: isRemovalImpactError, } = useMemberRemovalImpact(organizationId, removeMemberDialog.memberId, { enabled: removeMemberDialog.open, @@ -387,7 +392,7 @@ export function TeamManagement({ breakingCredentials={[ ...new Set(removalImpactCredentials?.map((c) => c.displayName) ?? []), ]} - credentialImpactPending={isRemovalImpactLoading} + credentialImpactPending={isRemovalImpactFetching} credentialImpactFailed={isRemovalImpactError} isSubmitting={removeMemberMutation.isPending} error={removeMemberMutation.error} From 7345961d5d0605d3147e51309acbc920e91c7d24 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 23 Jul 2026 22:12:43 -0700 Subject: [PATCH 08/16] fix(invitations): surface the accept conflict message and refresh workspace caches post-accept The accept route now carries the human-readable message alongside the machine-readable error kind (the client prefers it for server-error), and a successful accept invalidates workspace queries so the swept workspaces appear immediately instead of after the stale window. Co-Authored-By: Claude Fable 5 --- .../app/api/invitations/[id]/accept/route.ts | 8 ++++++- apps/sim/app/invite/[id]/invite.test.tsx | 2 +- apps/sim/app/invite/[id]/invite.tsx | 22 ++++++++++++++++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/invitations/[id]/accept/route.ts b/apps/sim/app/api/invitations/[id]/accept/route.ts index 0d2dbe60d83..d91a5df7a76 100644 --- a/apps/sim/app/api/invitations/[id]/accept/route.ts +++ b/apps/sim/app/api/invitations/[id]/accept/route.ts @@ -45,7 +45,13 @@ export const POST = withRouteHandler( } const status = statusMap[result.kind] ?? 500 logger.warn('Invitation accept rejected', { invitationId: id, reason: result.kind }) - return NextResponse.json({ error: result.kind }, { status }) + /** + * `error` stays the machine-readable kind (the client maps it to UX + * states); `message` carries the human copy when the failure provides + * one — e.g. the retryable concurrent-workspace-change conflict. + */ + const message = result.kind === 'server-error' ? result.message : undefined + return NextResponse.json({ error: result.kind, ...(message ? { message } : {}) }, { status }) } const inv = result.invitation diff --git a/apps/sim/app/invite/[id]/invite.test.tsx b/apps/sim/app/invite/[id]/invite.test.tsx index 01eb17609e3..83fba300438 100644 --- a/apps/sim/app/invite/[id]/invite.test.tsx +++ b/apps/sim/app/invite/[id]/invite.test.tsx @@ -281,6 +281,6 @@ describe('Invite', () => { cache: 'session', error: 'Session refresh denied', }) - expect(mockLogger.warn).toHaveBeenCalledTimes(3) + expect(mockLogger.warn).toHaveBeenCalledTimes(4) }) }) diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index 781d3641caf..0fb63db00f2 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -18,6 +18,7 @@ import { useInvitationDetails } from '@/hooks/queries/invitations' import { organizationKeys } from '@/hooks/queries/organization' import { refreshSessionQuery } from '@/hooks/queries/session' import { subscriptionKeys } from '@/hooks/queries/subscription' +import { workspaceKeys } from '@/hooks/queries/workspace' const logger = createLogger('InviteById') @@ -274,13 +275,32 @@ export default function Invite() { runBestEffortCacheRefresh('organization', () => queryClient.invalidateQueries({ queryKey: organizationKeys.all }) ) + /** + * Acceptance can attach the invitee's owned workspaces into the org — + * the workspace list must not keep serving the stale personal set. + */ + runBestEffortCacheRefresh('workspaces', () => + queryClient.invalidateQueries({ queryKey: workspaceKeys.all }) + ) } catch (acceptError) { logger.error('Error accepting invitation:', acceptError) const code = acceptError instanceof ApiClientError ? codeFromApiClientError(acceptError) : 'network-error' - setActionError(getInviteError(code)) + const serverMessage = + acceptError instanceof ApiClientError && + acceptError.body && + typeof acceptError.body === 'object' && + typeof (acceptError.body as { message?: unknown }).message === 'string' + ? ((acceptError.body as { message: string }).message as string) + : null + const baseError = getInviteError(code) + setActionError( + code === 'server-error' && serverMessage + ? { ...baseError, message: serverMessage } + : baseError + ) setIsAccepting(false) } } From 60e7832b7893f08a34b0a2e9bf1bf4afd426ed03 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 23 Jul 2026 22:20:04 -0700 Subject: [PATCH 09/16] fix(billing): sweep archived workspaces in Pro-to-Team conversion and org creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every attach call site now passes includeArchived so the archived escape hatch is closed uniformly — join-attach, admin move, subscription-driven org provisioning, and manual org creation all sweep archived personal workspaces into the organization. Co-Authored-By: Claude Fable 5 --- apps/sim/app/api/organizations/route.test.ts | 1 + apps/sim/app/api/organizations/route.ts | 1 + apps/sim/lib/billing/organization.test.ts | 1 + apps/sim/lib/billing/organization.ts | 3 +++ 4 files changed, 6 insertions(+) diff --git a/apps/sim/app/api/organizations/route.test.ts b/apps/sim/app/api/organizations/route.test.ts index bbe8e36990e..a0e076b22dc 100644 --- a/apps/sim/app/api/organizations/route.test.ts +++ b/apps/sim/app/api/organizations/route.test.ts @@ -96,6 +96,7 @@ describe('POST /api/organizations', () => { expect(mockAttachOwnedWorkspacesToOrganization).toHaveBeenCalledWith({ ownerUserId: 'user-1', organizationId: 'legacy-org-id', + includeArchived: true, }) expect(mockCreateOrganizationForTeamPlan).not.toHaveBeenCalled() expect(mockEnsureOrganizationForTeamSubscription).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/organizations/route.ts b/apps/sim/app/api/organizations/route.ts index 839ff3c7617..113f39207da 100644 --- a/apps/sim/app/api/organizations/route.ts +++ b/apps/sim/app/api/organizations/route.ts @@ -189,6 +189,7 @@ export const POST = withRouteHandler(async (request: Request) => { await attachOwnedWorkspacesToOrganization({ ownerUserId: user.id, organizationId, + includeArchived: true, }) } else { const resolvedSubscription = diff --git a/apps/sim/lib/billing/organization.test.ts b/apps/sim/lib/billing/organization.test.ts index 8886f7b2975..aea983dff2b 100644 --- a/apps/sim/lib/billing/organization.test.ts +++ b/apps/sim/lib/billing/organization.test.ts @@ -172,6 +172,7 @@ describe('ensureOrganizationForTeamSubscription', () => { ownerUserId: 'user-1', organizationId: 'org-owned', externalMemberPolicy: 'keep-external', + includeArchived: true, }) expect(mockCreateOrganizationWithOwner).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/billing/organization.ts b/apps/sim/lib/billing/organization.ts index a7af195844b..6c51b8f9ec4 100644 --- a/apps/sim/lib/billing/organization.ts +++ b/apps/sim/lib/billing/organization.ts @@ -253,6 +253,7 @@ export async function ensureOrganizationForTeamSubscription( ownerUserId: userId, organizationId: membership.organizationId, externalMemberPolicy: 'keep-external', + includeArchived: true, }) return { ...subscription, referenceId: membership.organizationId } @@ -326,6 +327,7 @@ export async function ensureOrganizationForTeamSubscription( ownerUserId: userId, organizationId: orgId, externalMemberPolicy: 'keep-external', + includeArchived: true, }) logger.info('Created organization and updated subscription referenceId', { @@ -475,6 +477,7 @@ export async function ensureOrganizationForTeamSubscriptionTx( ownerUserId: userId, organizationId, workspaceIds: subscription.workspaceIdsToAttach, + includeArchived: true, }) return { From 3e732dcad2a7ca3bdc703441e03d5cba3fac6e8e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 23 Jul 2026 22:29:13 -0700 Subject: [PATCH 10/16] fix(invitations): reject acceptance when the sweep set differs from the disclosed set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The join preview now carries the workspace ids it disclosed, the accept screen echoes them back as a disclosure token, and acceptance rolls back with disclosure-outdated (409) whenever the set it would sweep no longer matches — a workspace created after the preview rendered can never move without the user seeing the refreshed notice first. Co-Authored-By: Claude Fable 5 --- .../app/api/invitations/[id]/accept/route.ts | 2 + apps/sim/app/invite/[id]/invite.tsx | 18 +++++- apps/sim/lib/api/contracts/invitations.ts | 13 ++++ apps/sim/lib/invitations/core.test.ts | 62 +++++++++++++++++++ apps/sim/lib/invitations/core.ts | 56 ++++++++++++++++- 5 files changed, 148 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/invitations/[id]/accept/route.ts b/apps/sim/app/api/invitations/[id]/accept/route.ts index d91a5df7a76..1670992fe31 100644 --- a/apps/sim/app/api/invitations/[id]/accept/route.ts +++ b/apps/sim/app/api/invitations/[id]/accept/route.ts @@ -27,6 +27,7 @@ export const POST = withRouteHandler( actorName: session.user.name ?? undefined, invitationId: id, token: parsed.data.body.token ?? null, + disclosedWorkspaceIds: parsed.data.body.disclosedWorkspaceIds, request, }) @@ -34,6 +35,7 @@ export const POST = withRouteHandler( const statusMap: Record = { 'not-found': 404, 'workspace-not-found': 404, + 'disclosure-outdated': 409, 'invalid-token': 400, 'already-processed': 400, expired: 400, diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index 0fb63db00f2..909560cffe8 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -40,6 +40,7 @@ type InviteErrorCode = | 'already-processed' | 'email-mismatch' | 'workspace-not-found' + | 'disclosure-outdated' | 'user-not-found' | 'already-member' | 'already-in-organization' @@ -88,6 +89,12 @@ function getInviteError(code: string): InviteError { code: 'workspace-not-found', message: 'The workspace associated with this invitation could not be found.', }, + 'disclosure-outdated': { + code: 'disclosure-outdated', + message: + 'Your workspaces changed since this page loaded. Review the updated notice and accept again.', + canRetry: true, + }, 'user-not-found': { code: 'user-not-found', message: 'Your user account could not be found. Please try signing out and signing back in.', @@ -261,7 +268,16 @@ export default function Invite() { try { const data = await requestJson(acceptInvitationContract, { params: { id: inviteId }, - body: { token: token ?? undefined }, + body: { + token: token ?? undefined, + /** + * Disclosure token: acceptance rejects with disclosure-outdated if + * the sweep set no longer matches what this screen showed. + */ + disclosedWorkspaceIds: joinPreview?.willJoinOrganization + ? joinPreview.workspaceIdsToMove + : undefined, + }, }) setAccepted(true) diff --git a/apps/sim/lib/api/contracts/invitations.ts b/apps/sim/lib/api/contracts/invitations.ts index 8c79512db92..09b61099a79 100644 --- a/apps/sim/lib/api/contracts/invitations.ts +++ b/apps/sim/lib/api/contracts/invitations.ts @@ -74,6 +74,13 @@ export const invitationActionParamsSchema = z.object({ export const invitationActionBodySchema = z.object({ token: z.string().min(1).optional(), + /** + * The workspace ids the accept screen disclosed as moving (from the join + * preview). When present, acceptance fails with `disclosure-outdated` if + * the set it would actually sweep differs — consent is only valid for the + * set the user saw. + */ + disclosedWorkspaceIds: z.array(z.string()).max(200).optional(), }) export const invitationDetailsSchema = z.object({ @@ -101,6 +108,12 @@ export const invitationDetailsSchema = z.object({ export const invitationJoinPreviewSchema = z.object({ willJoinOrganization: z.boolean(), workspacesToMove: z.array(z.string()), + /** + * Stable ids behind `workspacesToMove`, echoed back on accept as the + * disclosure token: acceptance rejects when the set it would sweep no + * longer matches what this preview disclosed. + */ + workspaceIdsToMove: z.array(z.string()), }) export const acceptInvitationResponseSchema = z.object({ diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index 9069bb818b9..9074ce10b2e 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -748,6 +748,68 @@ describe('acceptInvitation', () => { expect(auditMock.recordAudit).not.toHaveBeenCalled() }) + it('rolls back acceptance when the sweep set differs from the disclosed set', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + name: 'Workspace', + ownerId: 'owner-1', + organizationId: 'org-1', + workspaceMode: 'organization', + billedAccountUserId: 'owner-1', + }) + mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({ + success: true, + organizationId: 'org-1', + fixedSeats: false, + }) + + queueWhereResponses([ + [ + { + id: 'inv-1', + kind: 'workspace', + email: 'invitee@example.com', + organizationId: 'org-1', + membershipIntent: 'internal', + inviterId: 'owner-1', + role: 'member', + status: 'pending', + token: 'tok-1', + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + [ + { + id: 'grant-1', + workspaceId: 'workspace-1', + permission: 'write', + workspaceName: 'Workspace', + }, + ], + [{ name: 'Acme' }], + [{ name: 'Owner', email: 'owner@example.com' }], + // Invitee-owned personal workspaces for the acceptance lock plan. + [{ id: 'joiner-ws-1' }], + // Post-join owned-set re-check under the billing-identity lock. + [{ id: 'joiner-ws-1' }], + ]) + + const result = await acceptInvitation({ + userId: 'invitee-user', + userEmail: 'invitee@example.com', + invitationId: 'inv-1', + token: 'tok-1', + // The accept screen rendered before joiner-ws-1 existed. + disclosedWorkspaceIds: [], + }) + + expect(result).toEqual({ success: false, kind: 'disclosure-outdated' }) + expect(mockAttachOwnedWorkspacesToOrganizationTx).not.toHaveBeenCalled() + expect(auditMock.recordAudit).not.toHaveBeenCalled() + }) + it('rolls back acceptance when the owned-workspace set changes concurrently', async () => { mockGetWorkspaceWithOwner.mockResolvedValue({ id: 'workspace-1', diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 467557b45a8..5fd73d2e4b9 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -191,6 +191,12 @@ async function stampedOrganizationAllowsEscalation( export interface InvitationJoinPreviewResult { willJoinOrganization: boolean workspacesToMove: string[] + /** + * Stable ids behind `workspacesToMove`; the accept screen echoes them back + * as the disclosure token so acceptance can reject when the sweep set no + * longer matches what was disclosed. + */ + workspaceIdsToMove: string[] } /** @@ -204,7 +210,11 @@ export async function getInvitationJoinPreview( inviteeUserId: string, inv: InvitationWithGrants ): Promise { - const noJoin: InvitationJoinPreviewResult = { willJoinOrganization: false, workspacesToMove: [] } + const noJoin: InvitationJoinPreviewResult = { + willJoinOrganization: false, + workspacesToMove: [], + workspaceIdsToMove: [], + } if (inv.membershipIntent === 'external') return noJoin let workspaceOrganizationId = inv.organizationId @@ -286,7 +296,7 @@ export async function getInvitationJoinPreview( } const ownedWorkspaces = await db - .select({ name: workspace.name }) + .select({ id: workspace.id, name: workspace.name }) .from(workspace) .where(ownedAttachableWorkspacesWhere({ userId: inviteeUserId, includeArchived: true })) .orderBy(asc(workspace.name)) @@ -294,6 +304,7 @@ export async function getInvitationJoinPreview( return { willJoinOrganization: true, workspacesToMove: ownedWorkspaces.map((row) => row.name), + workspaceIdsToMove: ownedWorkspaces.map((row) => row.id), } } @@ -368,6 +379,7 @@ export async function expireStalePendingInvitationsForWorkspaces( export type AcceptInvitationFailure = | { kind: 'not-found' } | { kind: 'workspace-not-found' } + | { kind: 'disclosure-outdated' } | { kind: 'already-processed' } | { kind: 'expired' } | { kind: 'email-mismatch' } @@ -395,6 +407,12 @@ export interface AcceptInvitationInput { actorName?: string | null invitationId: string token: string | null + /** + * Workspace ids the accept screen disclosed as moving. When provided, + * acceptance fails with `disclosure-outdated` if the set it would sweep + * differs — the user must see the refreshed notice before consenting. + */ + disclosedWorkspaceIds?: string[] request?: { headers: { get(name: string): string | null } } } @@ -435,6 +453,18 @@ class AllGrantsStaleDuringAcceptError extends Error { } } +/** + * Thrown when the workspace set acceptance would sweep no longer matches the + * set the accept screen disclosed. Rolls the acceptance back so the user + * consents to the refreshed notice instead of a silent migration. + */ +class DisclosureOutdatedDuringAcceptError extends Error { + constructor() { + super('Disclosed workspace set no longer matches the sweep set') + this.name = 'DisclosureOutdatedDuringAcceptError' + } +} + interface InvitationAcceptancePostCommitEffects { organizationId: string | null memberRole: string | null @@ -618,6 +648,13 @@ export async function acceptInvitation( }) return { success: false, kind: 'workspace-not-found' } } + if (error instanceof DisclosureOutdatedDuringAcceptError) { + logger.warn('Invite acceptance rolled back: disclosed workspace set is outdated', { + invitationId: input.invitationId, + userId: input.userId, + }) + return { success: false, kind: 'disclosure-outdated' } + } throw error }) if (result.success) { @@ -776,6 +813,21 @@ async function acceptLockedInvitation( throw new JoinerWorkspacesChangedDuringAcceptError() } + /** + * Consent is only valid for the workspace set the user saw: when the + * client supplies the disclosed ids from the join preview, a sweep + * set that differs (a workspace created or removed since the preview + * rendered) rolls the acceptance back so the refreshed notice is + * shown before any migration happens. + */ + if ( + input.disclosedWorkspaceIds !== undefined && + [...input.disclosedWorkspaceIds].sort().join() !== + [...lockPlan.joinerAttachWorkspaceIds].sort().join() + ) { + throw new DisclosureOutdatedDuringAcceptError() + } + if (lockPlan.joinerAttachWorkspaceIds.length > 0) { await acquireOrganizationMutationLock(tx, targetOrganizationId) const attachResult = await attachOwnedWorkspacesToOrganizationTx(tx, { From b6bf3b110b81f1f2f7b33d6dd951d3be93adf032 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 23 Jul 2026 22:35:55 -0700 Subject: [PATCH 11/16] fix(invitations): send the disclosure token for no-join previews too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A preview that predicted no join still tells the user nothing moves — the empty disclosed set is now echoed on accept, so a join that becomes possible between preview and accept (left another org, billing turned usable, grants un-staled) conflicts with disclosure-outdated instead of sweeping workspaces without a rendered notice. Co-Authored-By: Claude Fable 5 --- apps/sim/app/invite/[id]/invite.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index 909560cffe8..119c5831ee6 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -272,11 +272,12 @@ export default function Invite() { token: token ?? undefined, /** * Disclosure token: acceptance rejects with disclosure-outdated if - * the sweep set no longer matches what this screen showed. + * the sweep set no longer matches what this screen showed. Sent + * whenever a preview rendered — a no-join preview means the user + * was shown that nothing moves (an empty disclosed set), which + * must also conflict if acceptance would sweep anything. */ - disclosedWorkspaceIds: joinPreview?.willJoinOrganization - ? joinPreview.workspaceIdsToMove - : undefined, + disclosedWorkspaceIds: joinPreview ? joinPreview.workspaceIdsToMove : undefined, }, }) From 0cdc45efedc6a9aabf937e640e9952d1b6f73d0c Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 23 Jul 2026 22:52:13 -0700 Subject: [PATCH 12/16] 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 --- apps/sim/hooks/queries/organization.ts | 7 +++++- apps/sim/lib/invitations/core.test.ts | 17 +++---------- apps/sim/lib/invitations/core.ts | 34 ++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index f1b145d6f9f..6ac2512a967 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -56,7 +56,12 @@ export const ORGANIZATION_SUBSCRIPTION_STALE_TIME = 30 * 1000 export const ORGANIZATION_BILLING_STALE_TIME = 30 * 1000 export const ORGANIZATION_MEMBERS_STALE_TIME = 30 * 1000 export const ORGANIZATION_MEMBER_USAGE_LIMIT_STALE_TIME = 30 * 1000 -export const ORGANIZATION_REMOVAL_IMPACT_STALE_TIME = 30 * 1000 +/** + * Zero: removal impact is a consent disclosure, so every dialog open must + * refetch — a cached list may omit credentials added moments ago, and the + * dialog holds its confirm on `isFetching` until fresh data lands. + */ +export const ORGANIZATION_REMOVAL_IMPACT_STALE_TIME = 0 type OrganizationSubscriptionCandidate = { id: string diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index 9074ce10b2e..63c887f65dc 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -692,12 +692,6 @@ describe('acceptInvitation', () => { workspaceMode: 'organization', billedAccountUserId: 'owner-1', }) - mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({ - success: true, - organizationId: 'org-1', - fixedSeats: false, - }) - queueWhereResponses([ [ { @@ -727,14 +721,8 @@ describe('acceptInvitation', () => { [{ name: 'Owner', email: 'owner@example.com' }], // Invitee-owned personal workspaces for the acceptance lock plan. [], - // Post-join owned-set re-check under the billing-identity lock. - [], - // Grant-txn membership re-check under the lock: member still present. - [{ id: 'member-1' }], - // Invitation status update under the lock. + // Pre-join staleness gate: no grant workspace remains in the stamped org. [], - // The granted workspace left the stamped organization. - [{ organizationId: 'org-elsewhere' }], ]) const result = await acceptInvitation({ @@ -745,6 +733,7 @@ describe('acceptInvitation', () => { }) expect(result).toEqual({ success: false, kind: 'workspace-not-found' }) + expect(mockEnsureUserInOrganization).not.toHaveBeenCalled() expect(auditMock.recordAudit).not.toHaveBeenCalled() }) @@ -986,6 +975,8 @@ describe('acceptInvitation', () => { [{ name: 'Owner', email: 'owner@example.com' }], // Invitee-owned personal workspaces for the acceptance lock plan. [], + // Pre-join staleness gate: the granted workspace is still in the org. + [{ id: 'workspace-1' }], // Post-join owned-set re-check under the billing-identity lock. [], // Grant-txn membership re-check under the lock: member still present. diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 5fd73d2e4b9..d40417a48bd 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -710,6 +710,40 @@ async function acceptLockedInvitation( shouldJoinOrganization = false } + /** + * A member-role organization invite whose grants ALL left the stamped + * organization can never land its member anywhere — fail before any + * mutation. This must precede the disclosure check: the preview mirrors + * this gate with an empty disclosure, and rejecting on disclosure first + * would loop the client on disclosure-outdated instead of surfacing the + * real cause. The grant rows are advisory-locked, so this read cannot + * change for the rest of the transaction. + */ + if ( + shouldJoinOrganization && + inv.kind === 'organization' && + inv.organizationId && + !isOrgAdminRole(inv.role) && + inv.grants.length > 0 + ) { + const [liveGrant] = await tx + .select({ id: workspace.id }) + .from(workspace) + .where( + and( + inArray( + workspace.id, + inv.grants.map((grant) => grant.workspaceId) + ), + eq(workspace.organizationId, inv.organizationId) + ) + ) + .limit(1) + if (!liveGrant) { + return { success: false, kind: 'workspace-not-found' } + } + } + let targetOrganizationId = workspaceOrganizationId if (shouldJoinOrganization) { From c3e9faff9f9537b16542ca6e31a1393a4f55e57e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 23 Jul 2026 23:01:47 -0700 Subject: [PATCH 13/16] fix(organizations): keep-external on org recovery and label archived move candidates Org creation/recovery now uses the keep-external collaborator policy (matching Pro-to-Team conversion) so different-org collaborators on archived workspaces cannot abort it with a conflict, and the admin workspace-move search and preflight expose an archived flag so internal tooling can label archived targets. Co-Authored-By: Claude Fable 5 --- apps/sim/app/api/organizations/route.test.ts | 1 + apps/sim/app/api/organizations/route.ts | 7 +++++++ .../api/contracts/v1/admin/dashboard-workspaces.ts | 2 ++ apps/sim/lib/workspaces/admin-move.ts | 11 +++++++++-- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/organizations/route.test.ts b/apps/sim/app/api/organizations/route.test.ts index a0e076b22dc..c07659b1567 100644 --- a/apps/sim/app/api/organizations/route.test.ts +++ b/apps/sim/app/api/organizations/route.test.ts @@ -96,6 +96,7 @@ describe('POST /api/organizations', () => { expect(mockAttachOwnedWorkspacesToOrganization).toHaveBeenCalledWith({ ownerUserId: 'user-1', organizationId: 'legacy-org-id', + externalMemberPolicy: 'keep-external', includeArchived: true, }) expect(mockCreateOrganizationForTeamPlan).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/organizations/route.ts b/apps/sim/app/api/organizations/route.ts index 113f39207da..38d8fc3a65a 100644 --- a/apps/sim/app/api/organizations/route.ts +++ b/apps/sim/app/api/organizations/route.ts @@ -186,9 +186,16 @@ export const POST = withRouteHandler(async (request: Request) => { organizationId = existingAdminMembership.organizationId if (activeOrgSubscription.referenceId === organizationId) { + /** + * keep-external, matching the Pro→Team conversion: different-org + * collaborators (including ones on archived workspaces the sweep now + * covers) stay external workspace members instead of aborting the + * whole org recovery with a conflict. + */ await attachOwnedWorkspacesToOrganization({ ownerUserId: user.id, organizationId, + externalMemberPolicy: 'keep-external', includeArchived: true, }) } else { diff --git a/apps/sim/lib/api/contracts/v1/admin/dashboard-workspaces.ts b/apps/sim/lib/api/contracts/v1/admin/dashboard-workspaces.ts index f8abf20fd83..4818e11688a 100644 --- a/apps/sim/lib/api/contracts/v1/admin/dashboard-workspaces.ts +++ b/apps/sim/lib/api/contracts/v1/admin/dashboard-workspaces.ts @@ -35,6 +35,8 @@ const adminDashboardWorkspaceCandidateSchema = z.object({ workspaceMode: z.string(), organizationId: z.string().nullable(), billedAccountUserId: z.string(), + /** Archived workspaces are movable; the flag lets admin UIs label them. */ + archived: z.boolean(), }) const adminDashboardWorkspacePreflightSchema = z.object({ diff --git a/apps/sim/lib/workspaces/admin-move.ts b/apps/sim/lib/workspaces/admin-move.ts index a26c0a3cfc6..12b997f0a75 100644 --- a/apps/sim/lib/workspaces/admin-move.ts +++ b/apps/sim/lib/workspaces/admin-move.ts @@ -55,6 +55,8 @@ export interface WorkspaceMoveCandidate { workspaceMode: string organizationId: string | null billedAccountUserId: string + /** Archived workspaces are movable; surfaced so admin UIs can label them. */ + archived: boolean } export interface WorkspaceMovePreflight { @@ -123,7 +125,7 @@ export async function searchWorkspaceMoveCandidates( const query = search.trim() if (!query) return [] - return db + const rows = await db .select({ id: workspace.id, name: workspace.name, @@ -133,6 +135,7 @@ export async function searchWorkspaceMoveCandidates( workspaceMode: workspace.workspaceMode, organizationId: workspace.organizationId, billedAccountUserId: workspace.billedAccountUserId, + archivedAt: workspace.archivedAt, }) .from(workspace) .innerJoin(user, eq(user.id, workspace.ownerId)) @@ -144,6 +147,8 @@ export async function searchWorkspaceMoveCandidates( ) .orderBy(asc(workspace.name)) .limit(Math.min(Math.max(limit, 1), 50)) + + return rows.map(({ archivedAt, ...row }) => ({ ...row, archived: archivedAt !== null })) } /** Builds the human-reviewable summary shown before a workspace move. */ @@ -435,7 +440,7 @@ export async function moveWorkspaceToOrganization(params: { } async function searchWorkspaceById(workspaceId: string): Promise { - return db + const rows = await db .select({ id: workspace.id, name: workspace.name, @@ -451,6 +456,8 @@ async function searchWorkspaceById(workspaceId: string): Promise ({ ...row, archived: archivedAt !== null })) } /** From 94a7216376f38771ba8b564a8ac97cf6eeb85e31 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 23 Jul 2026 23:15:30 -0700 Subject: [PATCH 14/16] fix(invitations): guard the reverse disclosure direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A will-join notice whose acceptance downgrades to no-join (stale escalation denial, concurrent other-org membership) now fails with disclosure-outdated instead of silently succeeding as an external grant — the disclosure token binds the outcome in both directions. Co-Authored-By: Claude Fable 5 --- apps/sim/lib/invitations/core.test.ts | 61 +++++++++++++++++++++++++++ apps/sim/lib/invitations/core.ts | 15 +++++++ 2 files changed, 76 insertions(+) diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index 63c887f65dc..5cbcb78c6cf 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -737,6 +737,67 @@ describe('acceptInvitation', () => { expect(auditMock.recordAudit).not.toHaveBeenCalled() }) + it('rejects a will-join disclosure when acceptance downgrades to external', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + name: 'Workspace', + ownerId: 'owner-1', + organizationId: 'org-1', + workspaceMode: 'organization', + billedAccountUserId: 'owner-1', + }) + // Invitee joined a different organization after the preview rendered. + mockGetUserOrganization.mockImplementation(async (userId: string) => + userId === 'invitee-user' + ? { organizationId: 'org-2', role: 'member', memberId: 'member-2' } + : null + ) + + queueWhereResponses([ + [ + { + id: 'inv-1', + kind: 'workspace', + email: 'invitee@example.com', + organizationId: 'org-1', + membershipIntent: 'internal', + inviterId: 'owner-1', + role: 'member', + status: 'pending', + token: 'tok-1', + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + [ + { + id: 'grant-1', + workspaceId: 'workspace-1', + permission: 'write', + workspaceName: 'Workspace', + }, + ], + [{ name: 'Acme' }], + [{ name: 'Owner', email: 'owner@example.com' }], + // Invitee-owned personal workspaces for the acceptance lock plan. + [{ id: 'joiner-ws-1' }], + ]) + + const result = await acceptInvitation({ + userId: 'invitee-user', + userEmail: 'invitee@example.com', + invitationId: 'inv-1', + token: 'tok-1', + // The accept screen promised a membership migration of joiner-ws-1. + disclosedWorkspaceIds: ['joiner-ws-1'], + }) + + expect(result).toEqual({ success: false, kind: 'disclosure-outdated' }) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(auditMock.recordAudit).not.toHaveBeenCalled() + }) + it('rolls back acceptance when the sweep set differs from the disclosed set', async () => { mockGetWorkspaceWithOwner.mockResolvedValue({ id: 'workspace-1', diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index d40417a48bd..a7d6064e732 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -881,6 +881,21 @@ async function acceptLockedInvitation( } } + /** + * Reverse disclosure guard: a will-join notice (non-empty disclosed set) + * whose acceptance resolved to no-join must not silently succeed as an + * external grant — the user consented to membership plus a migration that + * will not happen. Nothing has been written on the no-join path, so a + * plain failure return suffices; retry renders the refreshed preview. + */ + if ( + !shouldJoinOrganization && + input.disclosedWorkspaceIds !== undefined && + input.disclosedWorkspaceIds.length > 0 + ) { + return { success: false, kind: 'disclosure-outdated' } + } + const acceptedWorkspaceIds: string[] = [] try { From f991ca0174e14df470df8117a2a55828d6752d69 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 24 Jul 2026 16:54:42 -0700 Subject: [PATCH 15/16] address comments --- .../content/docs/en/platform/permissions.mdx | 6 +- .../content/docs/en/platform/workspaces.mdx | 2 +- apps/sim/app/api/organizations/route.test.ts | 1 - apps/sim/app/api/organizations/route.ts | 11 +- apps/sim/app/api/workspaces/route.ts | 19 ++- apps/sim/app/invite/[id]/invite.tsx | 10 +- .../remove-member-dialog.tsx | 17 ++- apps/sim/app/workspace/page.tsx | 30 +++- apps/sim/hooks/queries/invitations.ts | 14 +- apps/sim/lib/api/contracts/invitations.ts | 19 ++- apps/sim/lib/invitations/core.test.ts | 93 +++++++++++- apps/sim/lib/invitations/core.ts | 140 +++++++++++------- apps/sim/lib/workspaces/admin-move.ts | 10 +- .../organization-workspaces.test.ts | 28 ++++ .../lib/workspaces/organization-workspaces.ts | 28 +++- apps/sim/lib/workspaces/policy.ts | 16 ++ 16 files changed, 347 insertions(+), 97 deletions(-) diff --git a/apps/docs/content/docs/en/platform/permissions.mdx b/apps/docs/content/docs/en/platform/permissions.mdx index 87ca9545b36..9db811ed6ed 100644 --- a/apps/docs/content/docs/en/platform/permissions.mdx +++ b/apps/docs/content/docs/en/platform/permissions.mdx @@ -85,7 +85,7 @@ External workspace members still receive a workspace permission level — Read, Accepting an invite that makes you an **internal member** does more than grant access — it brings your work under the organization: -- **Your workspaces move with you.** Every personal workspace you own, archived ones included, becomes an organization workspace. The accept screen lists exactly which workspaces will move before you accept. +- **Your workspaces move with you.** Every personal workspace you own, archived ones included, becomes an organization workspace. The accept screen names the workspaces that will move before you accept. - **You keep Admin on them.** You remain their administrator; organization Owners and Admins also gain Admin access through the normal [role inheritance](#how-roles-inherit), and usage in those workspaces is billed to the organization from then on. - **Your collaborators are not pulled in.** Anyone you had shared those workspaces with keeps their access as an **external member** — they never join the organization or consume a seat as a side effect of your joining. - **The workspaces stay if you leave.** If you later leave or are removed from the organization, workspaces you brought in remain with the organization. @@ -234,8 +234,8 @@ import { FAQ } from '@/components/ui/faq' { expect(mockAttachOwnedWorkspacesToOrganization).toHaveBeenCalledWith({ ownerUserId: 'user-1', organizationId: 'legacy-org-id', - externalMemberPolicy: 'keep-external', includeArchived: true, }) expect(mockCreateOrganizationForTeamPlan).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/organizations/route.ts b/apps/sim/app/api/organizations/route.ts index 38d8fc3a65a..52ab9783236 100644 --- a/apps/sim/app/api/organizations/route.ts +++ b/apps/sim/app/api/organizations/route.ts @@ -187,15 +187,16 @@ export const POST = withRouteHandler(async (request: Request) => { if (activeOrgSubscription.referenceId === organizationId) { /** - * keep-external, matching the Pro→Team conversion: different-org - * collaborators (including ones on archived workspaces the sweep now - * covers) stay external workspace members instead of aborting the - * whole org recovery with a conflict. + * Keeps the default `reject` policy: manual organization creation + * surfaces a different-org collaborator as an explicit conflict (409 + * with actionable copy) rather than silently demoting them to an + * external member. Safe alongside `includeArchived` because the attach + * only enumerates collaborators of ACTIVE workspaces, so sweeping + * archived rows cannot manufacture a conflict. */ await attachOwnedWorkspacesToOrganization({ ownerUserId: user.id, organizationId, - externalMemberPolicy: 'keep-external', includeArchived: true, }) } else { diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 28e06f5dfd4..f3bb86871a7 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -151,6 +151,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { organizationId: creationPolicy.organizationId, workspaceMode: creationPolicy.workspaceMode, billedAccountUserId: creationPolicy.billedAccountUserId, + observedOrganizationId: creationPolicy.observedOrganizationId, }) captureServerEvent( @@ -210,6 +211,7 @@ async function createDefaultWorkspace( organizationId: string | null workspaceMode: WorkspaceMode billedAccountUserId: string + observedOrganizationId: string | null } ) { const firstName = userName?.split(' ')[0] || null @@ -220,11 +222,14 @@ async function createDefaultWorkspace( organizationId: creationPolicy.organizationId, workspaceMode: creationPolicy.workspaceMode, billedAccountUserId: creationPolicy.billedAccountUserId, + observedOrganizationId: creationPolicy.observedOrganizationId, }) } interface CreateWorkspaceParams { userId: string + /** Membership the creation policy observed; see WorkspaceCreationPolicy. */ + observedOrganizationId: string | null name: string skipDefaultWorkflow?: boolean explicitColor?: string @@ -235,6 +240,7 @@ interface CreateWorkspaceParams { async function createWorkspace({ userId, + observedOrganizationId, name, skipDefaultWorkflow = false, explicitColor, @@ -259,12 +265,19 @@ async function createWorkspace({ */ if (!organizationId) { await acquireUserBillingIdentityLock(tx, userId) - const [existingMembership] = await tx - .select({ id: member.id }) + const [currentMembership] = await tx + .select({ organizationId: member.organizationId }) .from(member) .where(eq(member.userId, userId)) .limit(1) - if (existingMembership) { + /** + * Only a CHANGE since the policy read means the decision is stale. An + * unchanged membership is legitimately personal — the policy returns + * a personal decision for members whose organization has no usable + * Team/Enterprise plan (a dormant org), and those users must still be + * able to create workspaces. + */ + if ((currentMembership?.organizationId ?? null) !== observedOrganizationId) { throw new PersonalWorkspaceCreationRacedError() } } diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index 119c5831ee6..8941276fcc2 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -240,7 +240,7 @@ export default function Invite() { } }, [searchParams, inviteId, inviteTokenStorageKey]) - const invitationQuery = useInvitationDetails(inviteId, token, { + const invitationQuery = useInvitationDetails(inviteId, token, session?.user?.id ?? null, { enabled: Boolean(session?.user), }) const invitation = invitationQuery.data?.invitation ?? null @@ -492,7 +492,13 @@ export default function Invite() { } const isOrg = invitation?.kind === 'organization' - const organizationLabel = invitation?.organizationName || 'the organization' + /** + * Prefer the preview's organization (the one acceptance will really join) + * over the invitation's stamped name — a granted workspace may have moved + * organizations since the invite was sent. + */ + const organizationLabel = + joinPreview?.organizationName || invitation?.organizationName || 'the organization' /** * When the server could not compute the preview, fall back to a generic * migration notice for membership invites — a missing preview must never diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx index 8dd701023e7..41ed8ce5c8d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx @@ -86,12 +86,21 @@ export function RemoveMemberDialog({ confirm={{ label: isSelfRemoval ? 'Leave Organization' : 'Remove', onClick: () => onConfirmRemove(), - pending: isSubmitting || credentialImpactPending, - pendingLabel: - credentialImpactPending && !isSubmitting ? 'Checking credentials…' : undefined, + pending: isSubmitting, + /** + * `disabled`, not `pending`: the impact check is a precondition, and + * `pending` means "the confirm is in flight" — the primitive also + * disables the dismiss button while pending, which must stay available + * while we are merely loading the disclosure. + */ + disabled: credentialImpactPending, }} > - {credentialWarning ? ( + {credentialImpactPending ? ( +

+ Checking which connected credentials this affects… +

+ ) : credentialWarning ? (

{credentialWarning}

) : null} {errorMessage ? ( diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index c365060e309..d8b6033ccd4 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -16,6 +16,9 @@ import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace' const logger = createLogger('WorkspacePage') +/** Bounds the one-shot reload after a creation-vs-membership 409. */ +const WORKSPACE_RACE_RETRY_KEY = 'workspaceRaceRetry' + /** * A 401 while the session claims we're authenticated means the auth cookies * are stale or inconsistent (e.g. after an impersonation session expired or @@ -130,7 +133,7 @@ export default function WorkspacePage() { return } hasRedirectedRef.current = true - handleNoWorkspaces(router) + handleNoWorkspaces(router, () => setRecoveryFailed(true)) return } @@ -171,7 +174,7 @@ export default function WorkspacePage() { description={ blockedPolicy.workspaceMode === 'organization' ? "Your account is linked to an organization, but you don't have access to any of its workspaces. Ask an organization admin for workspace access, then check again — or sign out and back in if you recently left the organization." - : 'All of your workspaces are archived and your plan has reached its workspace limit. Unarchive a workspace or upgrade your plan to continue.' + : 'Your plan has reached its workspace limit and none of your workspaces are active. Upgrade your plan to create another workspace, or contact support to restore an archived one.' } primaryLabel='Check again' onPrimary={() => window.location.reload()} @@ -232,7 +235,10 @@ async function handleWorkflowRedirect( router.replace(`/workspace/${fallbackWorkspaceId}/home`) } -async function handleNoWorkspaces(router: ReturnType): Promise { +async function handleNoWorkspaces( + router: ReturnType, + onUnrecoverable: () => void +): Promise { logger.warn('No workspaces found, creating default workspace') try { const data = await requestJson(createWorkspaceContract, { @@ -240,18 +246,28 @@ async function handleNoWorkspaces(router: ReturnType): Promise }) if (data.workspace?.id) { logger.info(`Created default workspace: ${data.workspace.id}`) + sessionStorage.removeItem(WORKSPACE_RACE_RETRY_KEY) router.replace(`/workspace/${data.workspace.id}/home`) return } logger.error('Failed to create default workspace') } catch (error) { /** - * 409 means the user joined an organization while the default workspace - * was being created — they are still authenticated and likely have org - * workspaces now, so re-resolve instead of falling into the login path. + * 409 means the caller's organization membership changed while the + * default workspace was being created — they are still authenticated and + * their workspaces likely exist now, so re-resolve ONCE. A second 409 + * means something other than a race, so surface the error card instead of + * reloading forever. */ if (isApiClientError(error) && error.status === 409) { - logger.info('Default workspace creation raced an organization join; re-resolving') + if (sessionStorage.getItem(WORKSPACE_RACE_RETRY_KEY)) { + logger.error('Default workspace creation kept conflicting after a retry') + sessionStorage.removeItem(WORKSPACE_RACE_RETRY_KEY) + onUnrecoverable() + return + } + sessionStorage.setItem(WORKSPACE_RACE_RETRY_KEY, '1') + logger.info('Default workspace creation raced an organization change; re-resolving') window.location.reload() return } diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index abd023fafaf..6bac427e03c 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -21,8 +21,15 @@ export const invitationKeys = { lists: () => [...invitationKeys.all, 'list'] as const, list: (workspaceId: string) => [...invitationKeys.lists(), workspaceId] as const, details: () => [...invitationKeys.all, 'detail'] as const, - detail: (invitationId: string, token: string | null) => - [...invitationKeys.details(), invitationId, token ?? ''] as const, + /** + * Scoped by viewer: the response is viewer-dependent (the join preview is + * invitee-only, and authorization differs per account), so a cached entry + * must never be reused across a sign-out/sign-in on the same invite link — + * doing so would let a stale "nothing moves" preview become the disclosure + * basis for a different user. + */ + detail: (invitationId: string, token: string | null, viewerId: string | null) => + [...invitationKeys.details(), invitationId, token ?? '', viewerId ?? ''] as const, } export const WORKSPACE_INVITATION_LIST_STALE_TIME = 30 * 1000 @@ -48,10 +55,11 @@ async function fetchInvitationDetails( export function useInvitationDetails( invitationId: string | undefined, token: string | null, + viewerId: string | null, options?: { enabled?: boolean } ) { return useQuery({ - queryKey: invitationKeys.detail(invitationId ?? '', token), + queryKey: invitationKeys.detail(invitationId ?? '', token, viewerId), queryFn: ({ signal }) => fetchInvitationDetails(invitationId as string, token, signal), enabled: Boolean(invitationId) && (options?.enabled ?? true), staleTime: INVITATION_DETAILS_STALE_TIME, diff --git a/apps/sim/lib/api/contracts/invitations.ts b/apps/sim/lib/api/contracts/invitations.ts index 09b61099a79..e919b6d960a 100644 --- a/apps/sim/lib/api/contracts/invitations.ts +++ b/apps/sim/lib/api/contracts/invitations.ts @@ -2,6 +2,13 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' import { workspacePermissionSchema } from '@/lib/api/contracts/workspaces' +/** + * Shared cap for the disclosure token: the preview's id list and the accept + * body's echo of it must agree, or a large owned-workspace set would produce a + * preview the accept endpoint rejects as over-long. + */ +export const DISCLOSED_WORKSPACE_ID_LIMIT = 500 + export const invitationParamsSchema = z.object({ id: z.string({ error: 'Invitation ID is required' }).min(1, 'Invitation ID is required'), }) @@ -80,7 +87,7 @@ export const invitationActionBodySchema = z.object({ * the set it would actually sweep differs — consent is only valid for the * set the user saw. */ - disclosedWorkspaceIds: z.array(z.string()).max(200).optional(), + disclosedWorkspaceIds: z.array(z.string()).max(DISCLOSED_WORKSPACE_ID_LIMIT).optional(), }) export const invitationDetailsSchema = z.object({ @@ -107,13 +114,17 @@ export const invitationDetailsSchema = z.object({ export const invitationJoinPreviewSchema = z.object({ willJoinOrganization: z.boolean(), - workspacesToMove: z.array(z.string()), + /** Name of the organization acceptance will actually join. */ + organizationName: z.string().nullable(), + workspacesToMove: z.array(z.string()).max(DISCLOSED_WORKSPACE_ID_LIMIT), /** * Stable ids behind `workspacesToMove`, echoed back on accept as the * disclosure token: acceptance rejects when the set it would sweep no - * longer matches what this preview disclosed. + * longer matches what this preview disclosed. Capped at the same limit as + * the accept body's echo so a large owned set can never render an invite + * permanently un-acceptable. */ - workspaceIdsToMove: z.array(z.string()), + workspaceIdsToMove: z.array(z.string()).max(DISCLOSED_WORKSPACE_ID_LIMIT), }) export const acceptInvitationResponseSchema = z.object({ diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index 5cbcb78c6cf..b9fd5dd637c 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -602,6 +602,88 @@ describe('acceptInvitation', () => { }) }) + it('still sweeps when the same transaction auto-joined the invitee', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + name: 'Workspace', + ownerId: 'owner-1', + organizationId: 'org-1', + workspaceMode: 'organization', + billedAccountUserId: 'owner-1', + }) + /** + * No membership before acceptance, but ensureUserInOrganizationTx reports + * alreadyMember — i.e. the Pro→Team conversion's keep-external attach + * joined this collaborator moments earlier in the same transaction. The + * sweep and the seat reconcile must still run. + */ + mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({ + success: true, + organizationId: 'org-1', + fixedSeats: false, + }) + mockEnsureUserInOrganization.mockResolvedValueOnce({ + success: true, + alreadyMember: true, + billingActions: { proUsageSnapshotted: false, proCancelledAtPeriodEnd: false }, + }) + mockAttachOwnedWorkspacesToOrganizationTx.mockResolvedValueOnce({ + attachedWorkspaceIds: ['joiner-ws-1'], + addedMemberIds: [], + skippedMembers: [], + usageLimitUserIds: [], + }) + + queueWhereResponses([ + [ + { + id: 'inv-1', + kind: 'workspace', + email: 'invitee@example.com', + organizationId: 'org-1', + membershipIntent: 'internal', + inviterId: 'owner-1', + role: 'member', + status: 'pending', + token: 'tok-1', + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + [ + { + id: 'grant-1', + workspaceId: 'workspace-1', + permission: 'write', + workspaceName: 'Workspace', + }, + ], + [{ name: 'Acme' }], + [{ name: 'Owner', email: 'owner@example.com' }], + // Invitee-owned personal workspaces for the acceptance lock plan. + [{ id: 'joiner-ws-1' }], + // Post-join owned-set re-check under the billing-identity lock. + [{ id: 'joiner-ws-1' }], + // Grant-txn membership re-check under the lock: member still present. + [{ id: 'member-1' }], + ]) + + const result = await acceptInvitation({ + userId: 'invitee-user', + userEmail: 'invitee@example.com', + invitationId: 'inv-1', + token: 'tok-1', + }) + + expect(result.success).toBe(true) + expect(mockAttachOwnedWorkspacesToOrganizationTx).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ ownerUserId: 'invitee-user', workspaceIds: ['joiner-ws-1'] }) + ) + expect(mockReconcileOrganizationSeats).toHaveBeenCalled() + }) + it('attaches the invitee-owned personal workspaces when joining the organization', async () => { mockGetWorkspaceWithOwner.mockResolvedValue({ id: 'workspace-1', @@ -1070,10 +1152,15 @@ describe('acceptInvitation', () => { workspaceMode: 'organization', billedAccountUserId: 'owner-1', }) - mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({ - success: true, + /** + * A genuinely pre-existing member: the membership is visible BEFORE + * acceptance runs. (An `alreadyMember` result with no prior membership + * means this transaction just auto-joined them, which must still sweep.) + */ + mockGetUserOrganization.mockResolvedValue({ organizationId: 'org-1', - fixedSeats: false, + role: 'member', + memberId: 'member-1', }) mockEnsureUserInOrganization.mockResolvedValueOnce({ success: true, diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index a7d6064e732..16dbcd726c6 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -188,8 +188,45 @@ async function stampedOrganizationAllowsEscalation( ) } +/** + * True when a member-role organization invitation still has at least one + * granted workspace inside the organization it was stamped with. All grants + * leaving that organization would strand the new member with nowhere to land, + * so acceptance refuses and the preview must predict the same — both consume + * this single predicate so they cannot drift. + */ +async function hasLiveGrantInStampedOrganization( + inv: InvitationWithGrants, + executor: DbOrTx = db +): Promise { + if (inv.kind !== 'organization' || !inv.organizationId) return true + if (isOrgAdminRole(inv.role)) return true + if (inv.grants.length === 0) return true + const [liveGrant] = await executor + .select({ id: workspace.id }) + .from(workspace) + .where( + and( + inArray( + workspace.id, + inv.grants.map((grant) => grant.workspaceId) + ), + eq(workspace.organizationId, inv.organizationId) + ) + ) + .limit(1) + return Boolean(liveGrant) +} + export interface InvitationJoinPreviewResult { willJoinOrganization: boolean + /** + * Name of the organization acceptance will actually join. For a workspace + * invite this is the granted workspace's LIVE organization, which can differ + * from the stamped `invitation.organizationName` — the disclosure must name + * the organization that will really gain control of the user's workspaces. + */ + organizationName: string | null workspacesToMove: string[] /** * Stable ids behind `workspacesToMove`; the accept screen echoes them back @@ -212,6 +249,7 @@ export async function getInvitationJoinPreview( ): Promise { const noJoin: InvitationJoinPreviewResult = { willJoinOrganization: false, + organizationName: null, workspacesToMove: [], workspaceIdsToMove: [], } @@ -245,32 +283,7 @@ export async function getInvitationJoinPreview( if (!(await stampedOrganizationAllowsEscalation(inv, workspaceOrganizationId))) return noJoin - /** - * Mirror of acceptance's stale-grant gate: a member-role organization - * invite whose grants ALL left the stamped organization rolls back at - * accept, so the preview must not promise a migration for it. - */ - if ( - inv.kind === 'organization' && - inv.organizationId && - !isOrgAdminRole(inv.role) && - inv.grants.length > 0 - ) { - const [liveGrant] = await db - .select({ id: workspace.id }) - .from(workspace) - .where( - and( - inArray( - workspace.id, - inv.grants.map((grant) => grant.workspaceId) - ), - eq(workspace.organizationId, inv.organizationId) - ) - ) - .limit(1) - if (!liveGrant) return noJoin - } + if (!(await hasLiveGrantInStampedOrganization(inv))) return noJoin /** * Mirror acceptance's billing gates: an unusable organization subscription @@ -301,8 +314,19 @@ export async function getInvitationJoinPreview( .where(ownedAttachableWorkspacesWhere({ userId: inviteeUserId, includeArchived: true })) .orderBy(asc(workspace.name)) + let targetOrganizationName: string | null = null + if (workspaceOrganizationId) { + const [targetOrg] = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, workspaceOrganizationId)) + .limit(1) + targetOrganizationName = targetOrg?.name ?? null + } + return { willJoinOrganization: true, + organizationName: targetOrganizationName, workspacesToMove: ownedWorkspaces.map((row) => row.name), workspaceIdsToMove: ownedWorkspaces.map((row) => row.id), } @@ -719,29 +743,8 @@ async function acceptLockedInvitation( * real cause. The grant rows are advisory-locked, so this read cannot * change for the rest of the transaction. */ - if ( - shouldJoinOrganization && - inv.kind === 'organization' && - inv.organizationId && - !isOrgAdminRole(inv.role) && - inv.grants.length > 0 - ) { - const [liveGrant] = await tx - .select({ id: workspace.id }) - .from(workspace) - .where( - and( - inArray( - workspace.id, - inv.grants.map((grant) => grant.workspaceId) - ), - eq(workspace.organizationId, inv.organizationId) - ) - ) - .limit(1) - if (!liveGrant) { - return { success: false, kind: 'workspace-not-found' } - } + if (shouldJoinOrganization && !(await hasLiveGrantInStampedOrganization(inv, tx))) { + return { success: false, kind: 'workspace-not-found' } } let targetOrganizationId = workspaceOrganizationId @@ -805,16 +808,45 @@ async function acceptLockedInvitation( } membershipAlreadyExists = membershipResult.alreadyMember - if (!membershipResult.alreadyMember) { + /** + * `membershipResult.alreadyMember` is true both for a genuinely + * pre-existing member AND for an invitee this very transaction just + * auto-joined (the Pro→Team conversion's `keep-external` attach joins + * org-less collaborators of the billing owner's workspaces before we + * reach here). Only the FORMER may skip the join side effects, so key + * them off the pre-acceptance membership snapshot instead — otherwise a + * collaborator-invitee silently keeps their workspaces personal, pays + * no seat, and loses their invited role. + */ + const joinedDuringThisAcceptance = !alreadyMemberOfTarget + + if (joinedDuringThisAcceptance) { effects.memberRole = inv.role || 'member' } + /** + * An in-transaction auto-join lands everyone as `member`; restore the + * role the invitation actually granted when it is higher. + */ + if ( + joinedDuringThisAcceptance && + membershipResult.alreadyMember && + isOrgAdminRole(inv.role) + ) { + await tx + .update(member) + .set({ role: inv.role }) + .where( + and(eq(member.userId, input.userId), eq(member.organizationId, targetOrganizationId)) + ) + } + // Grow the paid seat count to match the new member and push the charge // to Stripe asynchronously (Team plans only; Enterprise seats are // fixed). Best-effort: the member is already in, and a transient // failure self-heals on the next join/removal reconcile, matching the // removal path's seat accounting. - if (billingManagesSeats && !membershipResult.alreadyMember) { + if (billingManagesSeats && joinedDuringThisAcceptance) { effects.reconcileSeats = true } @@ -833,7 +865,7 @@ async function acceptLockedInvitation( * rolled back (retry succeeds with the fresh set) instead of committing * a member whose workspace dodged the sweep. */ - if (!membershipResult.alreadyMember) { + if (joinedDuringThisAcceptance) { const currentOwnedIds = ( await tx .select({ id: workspace.id }) @@ -863,7 +895,9 @@ async function acceptLockedInvitation( } if (lockPlan.joinerAttachWorkspaceIds.length > 0) { - await acquireOrganizationMutationLock(tx, targetOrganizationId) + // No acquireOrganizationMutationLock here: ensureUserInOrganizationTx + // above already took it for this organization, and advisory locks are + // transaction-scoped, so re-taking it is two wasted round trips. const attachResult = await attachOwnedWorkspacesToOrganizationTx(tx, { ownerUserId: input.userId, organizationId: targetOrganizationId, diff --git a/apps/sim/lib/workspaces/admin-move.ts b/apps/sim/lib/workspaces/admin-move.ts index 12b997f0a75..fa6243fdaad 100644 --- a/apps/sim/lib/workspaces/admin-move.ts +++ b/apps/sim/lib/workspaces/admin-move.ts @@ -883,7 +883,7 @@ async function getMovedWorkspaceSummary( workspaceId: string, destination: WorkspaceMoveDestination ): Promise { - const [workspaceRow] = await executor + const [movedRow] = await executor .select({ id: workspace.id, name: workspace.name, @@ -893,14 +893,20 @@ async function getMovedWorkspaceSummary( workspaceMode: workspace.workspaceMode, organizationId: workspace.organizationId, billedAccountUserId: workspace.billedAccountUserId, + archivedAt: workspace.archivedAt, }) .from(workspace) .innerJoin(user, eq(user.id, workspace.ownerId)) .where(eq(workspace.id, workspaceId)) .limit(1) - if (!workspaceRow) { + if (!movedRow) { throw new WorkspaceMoveError('Moved workspace could not be reloaded', 'workspace-not-found') } + const { archivedAt, ...movedWorkspace } = movedRow + const workspaceRow: WorkspaceMoveCandidate = { + ...movedWorkspace, + archived: archivedAt !== null, + } const collaboratorRows = await executor .select({ diff --git a/apps/sim/lib/workspaces/organization-workspaces.test.ts b/apps/sim/lib/workspaces/organization-workspaces.test.ts index 2dd1bcb4800..43b5fe564ef 100644 --- a/apps/sim/lib/workspaces/organization-workspaces.test.ts +++ b/apps/sim/lib/workspaces/organization-workspaces.test.ts @@ -218,6 +218,34 @@ describe('organization workspace helpers', () => { expect(dbChainMockFns.update).toHaveBeenCalled() }) + it('attaches an archived workspace without joining its collaborators', async () => { + queueTableRows(schemaMock.workspace, [{ id: 'ws-archived' }]) + queueTableRows(schemaMock.workspace, [{ id: 'ws-archived' }]) + queueTableRows(schemaMock.workspace, [ + { + id: 'ws-archived', + billedAccountUserId: 'user-1', + organizationId: null, + archivedAt: new Date(), + }, + ]) + queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'ws-archived' }]) + + const result = await attachOwnedWorkspacesToOrganization({ + ownerUserId: 'user-1', + organizationId: 'org-1', + externalMemberPolicy: 'keep-external', + includeArchived: true, + }) + + // Purview: the archived workspace still moves into the organization… + expect(result.attachedWorkspaceIds).toEqual(['ws-archived']) + // …but nobody is joined (and no seat consumed) off the back of it. + expect(mockEnsureUserInOrganizationTx).not.toHaveBeenCalled() + expect(result.addedMemberIds).toEqual([]) + }) + it('rolls back membership work when a concurrent move wins before the locked re-read', async () => { queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }]) queueTableRows(schemaMock.workspace, [{ id: 'ws-1' }]) diff --git a/apps/sim/lib/workspaces/organization-workspaces.ts b/apps/sim/lib/workspaces/organization-workspaces.ts index 86d9708290c..21d78504bbf 100644 --- a/apps/sim/lib/workspaces/organization-workspaces.ts +++ b/apps/sim/lib/workspaces/organization-workspaces.ts @@ -227,6 +227,7 @@ export async function attachOwnedWorkspacesToOrganizationTx( id: workspace.id, billedAccountUserId: workspace.billedAccountUserId, organizationId: workspace.organizationId, + archivedAt: workspace.archivedAt, }) .from(workspace) .where( @@ -257,12 +258,27 @@ export async function attachOwnedWorkspacesToOrganizationTx( } const ownedWorkspaceIds = ownedWorkspaces.map((row) => row.id) - const permissionRows = await tx - .select({ userId: permissions.userId }) - .from(permissions) - .where( - and(eq(permissions.entityType, 'workspace'), inArray(permissions.entityId, ownedWorkspaceIds)) - ) + /** + * Collaborators are enumerated from ACTIVE workspaces only. Archived + * workspaces still attach (so unarchiving can never dodge the organization), + * but sweeping them must not change who becomes a member: a forgotten + * collaborator on an archived workspace would otherwise be joined — and + * billed as a seat — by an upgrade they had nothing to do with. Anyone + * reachable only through an archived workspace stays an external member. + */ + const activeWorkspaceIds = ownedWorkspaces.filter((row) => !row.archivedAt).map((row) => row.id) + const permissionRows = + activeWorkspaceIds.length > 0 + ? await tx + .select({ userId: permissions.userId }) + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + inArray(permissions.entityId, activeWorkspaceIds) + ) + ) + : [] const workspaceMemberIds = [...new Set(permissionRows.map((row) => row.userId))].sort() const memberships = workspaceMemberIds.length > 0 diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index 5ad280e28b5..b74601cee29 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -79,6 +79,14 @@ export interface WorkspaceCreationPolicy { currentWorkspaceCount: number reason: string | null status: number + /** + * The organization the caller belonged to when this decision was made + * (`null` for none). A PERSONAL decision is legitimate for an existing + * member whose organization has no usable Team/Enterprise plan, so + * creation compares membership against this snapshot instead of treating + * any membership as a mid-create join. + */ + observedOrganizationId: string | null } interface GetWorkspaceCreationPolicyParams { @@ -283,6 +291,7 @@ export async function getWorkspaceCreationPolicy({ currentWorkspaceCount: 0, reason: 'Only organization owners and admins can create organization workspaces.', status: 403, + observedOrganizationId: membership?.organizationId ?? null, } } @@ -300,6 +309,7 @@ export async function getWorkspaceCreationPolicy({ currentWorkspaceCount: 0, reason: 'Only organization owners and admins can create organization workspaces.', status: 403, + observedOrganizationId: membership?.organizationId ?? null, } } @@ -312,6 +322,7 @@ export async function getWorkspaceCreationPolicy({ currentWorkspaceCount: 0, reason: null, status: 200, + observedOrganizationId: membership?.organizationId ?? null, } } @@ -326,6 +337,7 @@ export async function getWorkspaceCreationPolicy({ currentWorkspaceCount, reason: null, status: 200, + observedOrganizationId: membership?.organizationId ?? null, } } @@ -349,6 +361,7 @@ export async function getWorkspaceCreationPolicy({ currentWorkspaceCount: 0, reason: 'Only organization owners and admins can create organization workspaces.', status: 403, + observedOrganizationId: membership?.organizationId ?? null, } } @@ -361,6 +374,7 @@ export async function getWorkspaceCreationPolicy({ currentWorkspaceCount: 0, reason: null, status: 200, + observedOrganizationId: membership?.organizationId ?? null, } } } @@ -380,6 +394,7 @@ export async function getWorkspaceCreationPolicy({ currentWorkspaceCount, reason: `This plan supports up to ${maxWorkspaces} personal workspace${maxWorkspaces === 1 ? '' : 's'}.`, status: 403, + observedOrganizationId: membership?.organizationId ?? null, } } @@ -392,6 +407,7 @@ export async function getWorkspaceCreationPolicy({ currentWorkspaceCount, reason: null, status: 200, + observedOrganizationId: membership?.organizationId ?? null, } } From 47f375fca41a81da6e73e1582f06eac07e73ca6f Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 24 Jul 2026 17:59:17 -0700 Subject: [PATCH 16/16] fix --- apps/sim/app/workspace/page.tsx | 8 ++-- apps/sim/lib/api/contracts/workspaces.ts | 5 +++ apps/sim/lib/workspaces/policy.test.ts | 52 ++++++++++++++++++++++++ apps/sim/lib/workspaces/policy.ts | 27 ++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index d8b6033ccd4..8e88c906b97 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -172,9 +172,11 @@ export default function WorkspacePage() { window.location.reload()} diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 47567a624ed..9cc7e41ab11 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -38,6 +38,11 @@ export const workspaceCreationPolicySchema = z.object({ maxWorkspaces: z.number().nullable(), currentWorkspaceCount: z.number(), reason: z.string().nullable(), + /** + * Machine-readable discriminant for blocked states whose correct user-facing + * copy the workspace mode alone cannot determine. + */ + blockedReasonCode: z.literal('organization-subscription-inactive').optional(), }) export type WorkspaceCreationPolicy = z.output diff --git a/apps/sim/lib/workspaces/policy.test.ts b/apps/sim/lib/workspaces/policy.test.ts index 0a3ca8d6e55..9a98ded93f2 100644 --- a/apps/sim/lib/workspaces/policy.test.ts +++ b/apps/sim/lib/workspaces/policy.test.ts @@ -59,6 +59,58 @@ describe('getWorkspaceCreationPolicy', () => { expect(result.currentWorkspaceCount).toBe(1) }) + it('blocks a plain member of a lapsed organization from creating anything', async () => { + mockGetUserOrganization.mockResolvedValue({ + organizationId: 'org-1', + role: 'member', + memberId: 'member-1', + }) + // Cancelled / past_due Team: no usable organization subscription. + mockGetOrganizationSubscription.mockResolvedValue({ + id: 'sub-1', + plan: 'team_6000', + status: 'canceled', + referenceId: 'org-1', + }) + queueTableRows(member, [{ role: 'member' }]) + + const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) + + expect(result.canCreate).toBe(false) + expect(result.blockedReasonCode).toBe('organization-subscription-inactive') + expect(result.status).toBe(403) + }) + + it('lets an owner of a lapsed organization fall back to their personal plan', async () => { + mockGetUserOrganization.mockResolvedValue({ + organizationId: 'org-1', + role: 'owner', + memberId: 'member-1', + }) + mockGetOrganizationSubscription.mockResolvedValue({ + id: 'sub-1', + plan: 'team_6000', + status: 'canceled', + referenceId: 'org-1', + }) + mockGetHighestPrioritySubscription.mockResolvedValue({ + id: 'sub-2', + plan: 'pro_6000', + status: 'active', + }) + queueTableRows(member, [{ role: 'owner' }]) + queueTableRows(workspace, [{ value: 0 }]) + + const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) + + expect(result.canCreate).toBe(true) + expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL) + expect(result.maxWorkspaces).toBe(3) + // The membership snapshot lets creation tell "already a member" apart from + // "joined mid-create", so the owner is not spuriously 409'd. + expect(result.observedOrganizationId).toBe('org-1') + }) + it('allows pro users to create up to three personal workspaces', async () => { mockGetHighestPrioritySubscription.mockResolvedValueOnce({ id: 'sub-1', diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index b74601cee29..944e7aa2bd6 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -87,6 +87,8 @@ export interface WorkspaceCreationPolicy { * any membership as a mid-create join. */ observedOrganizationId: string | null + /** Discriminant for blocked states the workspace mode cannot distinguish. */ + blockedReasonCode?: 'organization-subscription-inactive' } interface GetWorkspaceCreationPolicyParams { @@ -377,6 +379,31 @@ export async function getWorkspaceCreationPolicy({ observedOrganizationId: membership?.organizationId ?? null, } } + + /** + * Lapsed organization (no usable Team/Enterprise plan). A plain member + * gets NO personal fallback: letting them create workspaces here would + * hand them an estate outside every admin's view purely because billing + * lapsed — exactly the purview escape this regime closes. Owners and + * admins DO fall through to the personal regime below: they sit at the top + * of the hierarchy, so there is no purview to escape, and after a + * downgrade they are usually back on a personal plan they still pay for. + */ + if (!isOrgAdminRole(orgRole)) { + return { + canCreate: false, + workspaceMode: WORKSPACE_MODE.ORGANIZATION, + organizationId, + billedAccountUserId: (await getOrganizationOwnerId(organizationId)) ?? userId, + maxWorkspaces: null, + currentWorkspaceCount: 0, + reason: + "Your organization's subscription is inactive. Ask an organization owner to reactivate it before creating workspaces.", + status: 403, + observedOrganizationId: membership?.organizationId ?? null, + blockedReasonCode: 'organization-subscription-inactive', + } + } } const highestPrioritySubscription = await getHighestPrioritySubscription(userId)