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/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..9db811ed6ed 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 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. + +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..f076a4c4028 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 names them 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. diff --git a/apps/sim/app/api/invitations/[id]/accept/route.ts b/apps/sim/app/api/invitations/[id]/accept/route.ts index 6fb0af7972f..1670992fe31 100644 --- a/apps/sim/app/api/invitations/[id]/accept/route.ts +++ b/apps/sim/app/api/invitations/[id]/accept/route.ts @@ -27,12 +27,15 @@ export const POST = withRouteHandler( actorName: session.user.name ?? undefined, invitationId: id, token: parsed.data.body.token ?? null, + disclosedWorkspaceIds: parsed.data.body.disclosedWorkspaceIds, request, }) if (!result.success) { const statusMap: Record = { 'not-found': 404, + 'workspace-not-found': 404, + 'disclosure-outdated': 409, 'invalid-token': 400, 'already-processed': 400, expired: 400, @@ -44,7 +47,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/api/invitations/[id]/route.ts b/apps/sim/app/api/invitations/[id]/route.ts index f17d2e10a59..65c4974485c 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,46 @@ 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 — 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, + }) + } } return NextResponse.json({ + joinPreview, + joinPreviewUnavailable, invitation: { id: inv.id, kind: inv.kind, @@ -128,6 +158,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/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..52ab9783236 100644 --- a/apps/sim/app/api/organizations/route.ts +++ b/apps/sim/app/api/organizations/route.ts @@ -186,9 +186,18 @@ export const POST = withRouteHandler(async (request: Request) => { organizationId = existingAdminMembership.organizationId if (activeOrgSubscription.referenceId === organizationId) { + /** + * 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, + includeArchived: true, }) } else { const resolvedSubscription = 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..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 @@ -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,9 +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 { acquireInvitationMutationLocks } from '@/lib/invitations/locks' +import { + attachOwnedWorkspacesToOrganizationTx, + ownedAttachableWorkspacesWhere, +} from '@/lib/workspaces/organization-workspaces' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { adminInvalidJsonResponse, @@ -58,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 { @@ -253,19 +271,102 @@ export const POST = withRouteHandler( ) } - const result = await addUserToOrganization({ - userId, - organizationId, - role, - skipBillingLogic: !isBillingEnabled, + /** + * 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. + */ + 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: [] } + } + + /** + * 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: [] } + } + const attach = await attachOwnedWorkspacesToOrganizationTx(tx, { + ownerUserId: userId, + organizationId, + workspaceIds: ownedWorkspaceIds, + externalMemberPolicy: 'external-all', + ownerMatch: 'owner', + includeArchived: true, + }) + return { + membership, + attachedWorkspaceIds: attach.attachedWorkspaceIds, + usageLimitUserIds: attach.usageLimitUserIds, + } }) - if (!result.success) { - return badRequestResponse(result.error || 'Failed to add member') + 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, + 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, @@ -276,8 +377,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({ @@ -287,7 +389,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, }) @@ -295,11 +402,16 @@ 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) { + 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/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/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index e63aab34a02..f3bb86871a7 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() @@ -58,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) @@ -118,6 +151,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { organizationId: creationPolicy.organizationId, workspaceMode: creationPolicy.workspaceMode, billedAccountUserId: creationPolicy.billedAccountUserId, + observedOrganizationId: creationPolicy.observedOrganizationId, }) captureServerEvent( @@ -156,6 +190,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 }) } @@ -168,6 +211,7 @@ async function createDefaultWorkspace( organizationId: string | null workspaceMode: WorkspaceMode billedAccountUserId: string + observedOrganizationId: string | null } ) { const firstName = userName?.split(' ')[0] || null @@ -178,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 @@ -193,6 +240,7 @@ interface CreateWorkspaceParams { async function createWorkspace({ userId, + observedOrganizationId, name, skipDefaultWorkflow = false, explicitColor, @@ -207,6 +255,33 @@ 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 [currentMembership] = await tx + .select({ organizationId: member.organizationId }) + .from(member) + .where(eq(member.userId, userId)) + .limit(1) + /** + * 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() + } + } + await tx.insert(workspace).values({ id: workspaceId, name, diff --git a/apps/sim/app/invite/[id]/invite.test.tsx b/apps/sim/app/invite/[id]/invite.test.tsx index 7e2c3310010..83fba300438 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, @@ -246,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 09391178790..8941276fcc2 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -3,20 +3,22 @@ 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' +import { workspaceKeys } from '@/hooks/queries/workspace' const logger = createLogger('InviteById') @@ -38,6 +40,7 @@ type InviteErrorCode = | 'already-processed' | 'email-mismatch' | 'workspace-not-found' + | 'disclosure-outdated' | 'user-not-found' | 'already-member' | 'already-in-organization' @@ -86,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.', @@ -155,6 +164,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 +212,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 +236,30 @@ 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, session?.user?.id ?? null, { + enabled: Boolean(session?.user), + }) + 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 + ? 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 @@ -245,7 +268,17 @@ 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. 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 ? joinPreview.workspaceIdsToMove : undefined, + }, }) setAccepted(true) @@ -259,13 +292,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' - setError(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) } } @@ -440,13 +492,29 @@ export default function Invite() { } const isOrg = invitation?.kind === '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 + * 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 ( void @@ -21,6 +36,9 @@ export function RemoveMemberDialog({ onCancel, isSelfRemoval = false, isExternalRemoval = false, + breakingCredentials = [], + credentialImpactPending = false, + credentialImpactFailed = false, isSubmitting = false, }: RemoveMemberDialogProps) { const title = isSelfRemoval @@ -29,8 +47,17 @@ 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 = 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 ${ + breakingCredentials.length === 1 ? 'it' : 'them' + }.` + : null return ( onConfirmRemove(), 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, }} > + {credentialImpactPending ? ( +

+ Checking which connected credentials this affects… +

+ ) : 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..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 @@ -18,6 +18,7 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/team-management/components' import { useCreateOrganization, + useMemberRemovalImpact, useOrganization, useOrganizationBilling, useOrganizationRoster, @@ -75,6 +76,19 @@ 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, + isFetching: isRemovalImpactFetching, + isError: isRemovalImpactError, + } = 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 +389,11 @@ export function TeamManagement({ memberName={removeMemberDialog.memberName} isSelfRemoval={removeMemberDialog.isSelfRemoval} isExternalRemoval={removeMemberDialog.isExternalRemoval} + breakingCredentials={[ + ...new Set(removalImpactCredentials?.map((c) => c.displayName) ?? []), + ]} + credentialImpactPending={isRemovalImpactFetching} + credentialImpactFailed={isRemovalImpactError} 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..8e88c906b97 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -12,10 +12,13 @@ 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') +/** 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 @@ -27,12 +30,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 +113,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, () => setRecoveryFailed(true)) 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 +158,32 @@ 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 +191,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()} + /> ) } @@ -174,18 +239,8 @@ async function handleWorkflowRedirect( async function handleNoWorkspaces( router: ReturnType, - creationPolicy: WorkspaceCreationPolicy | null + onUnrecoverable: () => void ): 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 - } - logger.warn('No workspaces found, creating default workspace') try { const data = await requestJson(createWorkspaceContract, { @@ -193,11 +248,31 @@ async function handleNoWorkspaces( }) 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 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) { + 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 + } logger.error('Error creating default workspace:', error) } router.replace('/login') diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index 410bf83ff04..6bac427e03c 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,52 @@ 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, + /** + * 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 +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, + viewerId: string | null, + options?: { enabled?: boolean } +) { + return useQuery({ + 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, + retry: false, + }) +} export interface WorkspaceInvitation { email: string @@ -89,11 +133,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..6ac2512a967 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,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 +/** + * 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 @@ -115,6 +123,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 +158,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..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'), }) @@ -47,6 +54,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 @@ -69,6 +81,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(DISCLOSED_WORKSPACE_ID_LIMIT).optional(), }) export const invitationDetailsSchema = z.object({ @@ -93,6 +112,21 @@ export const invitationDetailsSchema = z.object({ ), }) +export const invitationJoinPreviewSchema = z.object({ + willJoinOrganization: z.boolean(), + /** 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. 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()).max(DISCLOSED_WORKSPACE_ID_LIMIT), +}) + export const acceptInvitationResponseSchema = z.object({ success: z.literal(true), redirectPath: z.string(), @@ -151,6 +185,15 @@ 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(), + /** + * 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(), }), }, }) @@ -211,3 +254,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-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/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/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/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 { diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index 0535f6632cd..b9fd5dd637c 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,8 +459,12 @@ 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. [], + // 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' }], ]) @@ -516,6 +535,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 +571,10 @@ 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' }], [], @@ -570,7 +602,7 @@ describe('acceptInvitation', () => { }) }) - it('does not record an ORG_MEMBER_ADDED audit for a user who is already a member', async () => { + it('still sweeps when the same transaction auto-joined the invitee', async () => { mockGetWorkspaceWithOwner.mockResolvedValue({ id: 'workspace-1', name: 'Workspace', @@ -579,6 +611,12 @@ describe('acceptInvitation', () => { 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', @@ -589,6 +627,83 @@ describe('acceptInvitation', () => { 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', + 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([ [ @@ -617,6 +732,471 @@ describe('acceptInvitation', () => { ], [{ 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) + 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('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', + }) + 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. + [], + // Pre-join staleness gate: no grant workspace remains in the stamped org. + [], + ]) + + 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(mockEnsureUserInOrganization).not.toHaveBeenCalled() + 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', + 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', + 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', + 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. + [], + // 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. + [{ 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', + name: 'Workspace', + ownerId: 'owner-1', + organizationId: 'org-1', + workspaceMode: 'organization', + billedAccountUserId: 'owner-1', + }) + /** + * 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', + role: 'member', + memberId: 'member-1', + }) + mockEnsureUserInOrganization.mockResolvedValueOnce({ + success: true, + alreadyMember: true, + billingActions: { proUsageSnapshotted: false, proCancelledAtPeriodEnd: 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: 'member-1' }], ]) @@ -678,6 +1258,10 @@ describe('acceptInvitation', () => { ], [{ 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' }], ]) @@ -750,6 +1334,10 @@ describe('acceptInvitation', () => { ], [{ 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. + [], [{ id: 'member-1' }], ]) diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index a743765f9f4..16dbcd726c6 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,181 @@ 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) + ) +} + +/** + * 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 + * as the disclosure token so acceptance can reject when the sweep set no + * longer matches what was disclosed. + */ + workspaceIdsToMove: 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, + organizationName: null, + workspacesToMove: [], + workspaceIdsToMove: [], + } + 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 + + if (!(await hasLiveGrantInStampedOrganization(inv))) 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({ id: workspace.id, name: workspace.name }) + .from(workspace) + .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), + } +} + /** * Flip any still-pending invitations for the given organization whose * `expiresAt` has already passed to `expired`. Best-effort housekeeping @@ -220,6 +402,8 @@ 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' } @@ -247,6 +431,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 } } } @@ -263,55 +453,124 @@ 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' + } +} + +/** + * 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' + } +} + +/** + * 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 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,49 +583,104 @@ export async function acceptInvitation( memberRole: null, reconcileSeats: false, acceptedWorkspaceIds: [], + attachedWorkspaceIds: [], syncUsageLimitUserIds: [], planConversions: [], 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' } + } - const lockPlan = await getInvitationAcceptanceWorkspaceLockIds(tx, inv) - await acquireInvitationMutationLocks(tx, { - invitationIds: [], - workspaceIds: lockPlan.workspaceIds, - }) + /** + * 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]), + ], + }) - // 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.', + } + } + 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' } + } + 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) { await runInvitationAcceptancePostCommitEffects(input, effects) } @@ -376,42 +690,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 && @@ -425,6 +734,19 @@ 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 && !(await hasLiveGrantInStampedOrganization(inv, tx))) { + return { success: false, kind: 'workspace-not-found' } + } + let targetOrganizationId = workspaceOrganizationId if (shouldJoinOrganization) { @@ -486,23 +808,128 @@ 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 } + + /** + * 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. + * + * 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 (joinedDuringThisAcceptance) { + 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() + } + + /** + * 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) { + // 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, + workspaceIds: lockPlan.joinerAttachWorkspaceIds, + externalMemberPolicy: 'external-all', + ownerMatch: 'owner', + includeArchived: true, + }) + effects.syncUsageLimitUserIds.push(...attachResult.usageLimitUserIds) + effects.attachedWorkspaceIds = attachResult.attachedWorkspaceIds + } + } } else { shouldJoinOrganization = false } } + /** + * 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 { @@ -534,6 +961,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) @@ -572,6 +1021,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', { @@ -599,9 +1066,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 +1123,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..fa6243fdaad 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) @@ -56,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 { @@ -124,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, @@ -134,18 +135,20 @@ 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)) .where( and( - isNull(workspace.archivedAt), ne(workspace.workspaceMode, WORKSPACE_MODE.ORGANIZATION), or(eq(workspace.id, query), ilike(workspace.name, `%${query}%`)) ) ) .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. */ @@ -437,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, @@ -453,12 +456,16 @@ async function searchWorkspaceById(workspaceId: string): Promise ({ ...row, archived: archivedAt !== null })) } +/** + * Archived workspaces are deliberately movable: leaving them behind keeps an + * unarchive-later escape hatch outside the organization's purview, and + * join-attach already sweeps them (`includeArchived`). + */ function assertWorkspaceMovable(row: { archivedAt?: Date | null; workspaceMode: string }): void { - if (row.archivedAt) { - throw new WorkspaceMoveError('Archived workspaces cannot be moved', 'workspace-archived') - } if (row.workspaceMode === WORKSPACE_MODE.ORGANIZATION) { throw new WorkspaceMoveError( 'Inter-organization workspace transfers are not supported', @@ -876,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, @@ -886,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 428610ac014..21d78504bbf 100644 --- a/apps/sim/lib/workspaces/organization-workspaces.ts +++ b/apps/sim/lib/workspaces/organization-workspaces.ts @@ -47,14 +47,49 @@ export class WorkspaceOrganizationMembershipConflictError extends Error { } /** - * How to treat workspace members that already belong to a *different* + * How to treat workspace collaborators that are not members of the target * organization when attaching workspaces: - * - `reject` (default): throw a conflict — used by manual org creation. - * - `keep-external`: skip them (they stay external workspace members) and - * attach anyway — used by the Pro→Team conversion, which must not abort - * just because a personal workspace already has an external collaborator. + * - `reject` (default): throw a conflict when any collaborator belongs to a + * *different* organization — used by manual org creation. + * - `keep-external`: collaborators in a *different* organization stay + * external workspace members and the attach proceeds; org-less + * collaborators are joined into the organization — used by the Pro→Team + * conversion, which must not abort just because a personal workspace + * already has an external collaborator. + * - `external-all`: nobody joins the organization as a side effect — every + * collaborator who is not already a member stays an external workspace + * member. Used when a joining member's owned workspaces follow them into + * the organization: membership (and its seat) only ever comes from an + * invitation the person accepted or an explicit admin action. */ -type ExternalMemberPolicy = 'reject' | 'keep-external' +type ExternalMemberPolicy = 'reject' | 'keep-external' | 'external-all' + +/** + * The single definition of "a workspace that follows this user into an + * organization": owned (or billed) by them, not yet organization-owned, and — + * unless `includeArchived` — not archived. Every site that selects, locks, or + * previews attachable workspaces must build its WHERE from this so the + * acceptance lock plan, the attach queries, and the accept-screen preview can + * never drift apart. + */ +export function ownedAttachableWorkspacesWhere({ + userId, + ownerMatch = 'owner', + includeArchived = false, +}: { + userId: string + ownerMatch?: 'owner' | 'billing-account' + includeArchived?: boolean +}) { + return and( + ownerMatch === 'owner' + ? eq(workspace.ownerId, userId) + : eq(workspace.billedAccountUserId, userId), + isNull(workspace.organizationId), + ne(workspace.workspaceMode, WORKSPACE_MODE.ORGANIZATION), + ...(includeArchived ? [] : [isNull(workspace.archivedAt)]) + ) +} /** * Locks workspace rows before any membership billing path can lock user or @@ -74,24 +109,23 @@ interface AttachOwnedWorkspacesToOrganizationParams { ownerUserId: string organizationId: string externalMemberPolicy?: ExternalMemberPolicy + /** + * Also attach archived workspaces. Join-attach sweeps them so unarchiving + * later can never resurface a personal workspace outside the organization. + */ + includeArchived?: boolean } export async function attachOwnedWorkspacesToOrganization({ ownerUserId, organizationId, externalMemberPolicy = 'reject', + includeArchived = false, }: AttachOwnedWorkspacesToOrganizationParams): 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) { @@ -190,17 +227,13 @@ export async function attachOwnedWorkspacesToOrganizationTx( id: workspace.id, billedAccountUserId: workspace.billedAccountUserId, organizationId: workspace.organizationId, + archivedAt: workspace.archivedAt, }) .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)) @@ -225,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 @@ -240,23 +288,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/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 5ad280e28b5..944e7aa2bd6 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -79,6 +79,16 @@ 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 + /** Discriminant for blocked states the workspace mode cannot distinguish. */ + blockedReasonCode?: 'organization-subscription-inactive' } interface GetWorkspaceCreationPolicyParams { @@ -283,6 +293,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 +311,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 +324,7 @@ export async function getWorkspaceCreationPolicy({ currentWorkspaceCount: 0, reason: null, status: 200, + observedOrganizationId: membership?.organizationId ?? null, } } @@ -326,6 +339,7 @@ export async function getWorkspaceCreationPolicy({ currentWorkspaceCount, reason: null, status: 200, + observedOrganizationId: membership?.organizationId ?? null, } } @@ -349,6 +363,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 +376,32 @@ export async function getWorkspaceCreationPolicy({ currentWorkspaceCount: 0, reason: null, status: 200, + 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', } } } @@ -380,6 +421,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 +434,7 @@ export async function getWorkspaceCreationPolicy({ currentWorkspaceCount, reason: null, status: 200, + observedOrganizationId: membership?.organizationId ?? null, } } 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 +}