diff --git a/apps/docs/content/docs/en/execution/costs.mdx b/apps/docs/content/docs/en/execution/costs.mdx index b32530febe6..3028a79f983 100644 --- a/apps/docs/content/docs/en/execution/costs.mdx +++ b/apps/docs/content/docs/en/execution/costs.mdx @@ -308,6 +308,17 @@ By default, your usage is capped at the credits included in your plan. To allow ## Plan Limits +### Workspaces + +| Plan | Personal Workspaces | Shared (Organization) Workspaces | +|------|---------------------|----------------------------------| +| **Free** | 1 | — | +| **Pro** | Up to 3 | — | +| **Max** | Up to 10 | — | +| **Team / Enterprise** | Unlimited | Unlimited | + +Team and Enterprise plans unlock shared workspaces that belong to your organization. Members invited to a shared workspace automatically join the organization and count toward your seat total. 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 | Plan | Sync (req/min) | Async (req/min) | diff --git a/apps/docs/content/docs/en/permissions/roles-and-permissions.mdx b/apps/docs/content/docs/en/permissions/roles-and-permissions.mdx index daf9bd0847d..d64cca35f81 100644 --- a/apps/docs/content/docs/en/permissions/roles-and-permissions.mdx +++ b/apps/docs/content/docs/en/permissions/roles-and-permissions.mdx @@ -2,10 +2,31 @@ title: "Roles and Permissions" --- +import { Callout } from 'fumadocs-ui/components/callout' import { Video } from '@/components/ui/video' When you invite team members to your organization or workspace, you'll need to choose what level of access to give them. This guide explains what each permission level allows users to do, helping you understand team roles and what access each permission level provides. +## Workspaces and Organizations + +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. Members invited to a shared workspace automatically join the organization and count toward your seat total. + +### Workspace Limits by Plan + +| Plan | Personal Workspaces | Shared Workspaces | +|------|---------------------|-------------------| +| **Free** | 1 | — | +| **Pro** | Up to 3 | — | +| **Max** | Up to 10 | — | +| **Team / Enterprise** | Unlimited | Unlimited (seat-gated invites) | + + + 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. + + ## How to Invite Someone to a Workspace
@@ -88,6 +109,10 @@ Every workspace has one **Owner** (the person who created it) plus any number of - Can do everything except delete the workspace or remove the owner - Can be removed from the workspace by the owner or other admins + + For shared (organization) workspaces, the organization's Owner and Admins are treated as Admins of every workspace in the organization, even without an explicit per-workspace invite. + + --- ## Common Scenarios @@ -145,25 +170,38 @@ Periodically review who has access to what, especially when team members change ## Organization Roles -When inviting someone to your organization, you can assign one of two roles: +An organization has three roles: **Owner**, **Admin**, and **Member**. + +### Organization Owner +**What they can do:** +- Everything an Admin can do +- Transfer organization ownership to another user +- Only one Owner exists per organization ### Organization Admin **What they can do:** - Invite and remove team members from the organization -- Create new workspaces -- Manage billing and subscription settings -- Access all workspaces within the organization +- Create new shared workspaces under the organization +- Manage billing, seat count, and subscription settings +- Access all shared workspaces within the organization as a workspace Admin +- Promote members to Admin or demote Admins to Member + + + Owners and Admins have the same day-to-day permissions. The only action reserved for the Owner is transferring ownership. + ### Organization Member **What they can do:** -- Access workspaces they've been specifically invited to +- Access shared workspaces they've been specifically invited to - View the list of organization members -- Cannot invite new people or manage organization settings +- Cannot invite new people, create shared workspaces, or manage organization settings import { FAQ } from '@/components/ui/faq'

- Sim pricing: Community plan is free with 1,000 credits and 5GB storage. Pro plan is $25 - per month with 6,000 credits and 50GB storage. Max plan is $100 per month with 25,000 - credits and 500GB storage. Enterprise pricing is custom with SSO, SCIM, SOC2 compliance, - self-hosting, and dedicated support. All plans include CLI, SDK, and MCP access. + Sim pricing: Community plan is free with 1,000 credits, 5GB storage, and 1 personal + workspace. Pro plan is $25 per month with 6,000 credits, 50GB storage, and up to 3 + personal workspaces. Max plan is $100 per month with 25,000 credits, 500GB storage, and + up to 10 personal workspaces. Enterprise pricing is custom with unlimited shared + workspaces, SSO, SCIM, SOC2 compliance, self-hosting, and dedicated support. All plans + include CLI, SDK, and MCP access.

diff --git a/apps/sim/app/_shell/providers/session-provider.tsx b/apps/sim/app/_shell/providers/session-provider.tsx index 6d77ee79c82..c3f21301fa4 100644 --- a/apps/sim/app/_shell/providers/session-provider.tsx +++ b/apps/sim/app/_shell/providers/session-provider.tsx @@ -2,6 +2,7 @@ import type React from 'react' import { createContext, useCallback, useEffect, useMemo, useState } from 'react' +import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { client } from '@/lib/auth/auth-client' import { extractSessionDataFromAuthClientResult } from '@/lib/auth/session-response' @@ -34,6 +35,8 @@ export type SessionHookResult = { export const SessionContext = createContext(null) +const logger = createLogger('SessionProvider') + export function SessionProvider({ children }: { children: React.ReactNode }) { const [data, setData] = useState(null) const [isPending, setIsPending] = useState(true) @@ -49,14 +52,18 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { : await client.getSession() const session = extractSessionDataFromAuthClientResult(res) as AppSession setData(session) + return session } catch (e) { setError(e instanceof Error ? e : new Error('Failed to fetch session')) + return null } finally { setIsPending(false) } }, []) useEffect(() => { + let isCancelled = false + // Check if user was redirected after plan upgrade const params = new URLSearchParams(window.location.search) const wasUpgraded = params.get('upgraded') === 'true' @@ -69,12 +76,51 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { window.history.replaceState({}, '', newUrl) } - loadSession(wasUpgraded).then(() => { - if (wasUpgraded) { - queryClient.invalidateQueries({ queryKey: ['organizations'] }) - queryClient.invalidateQueries({ queryKey: ['subscription'] }) + const initializeSession = async () => { + const session = await loadSession(wasUpgraded) + + if (!wasUpgraded || isCancelled) { + return } - }) + + queryClient.invalidateQueries({ queryKey: ['organizations'] }) + queryClient.invalidateQueries({ queryKey: ['subscription'] }) + + const activeOrganizationId = session?.session?.activeOrganizationId ?? null + if (activeOrganizationId) { + return + } + + try { + const response = await fetch('/api/organizations') + if (!response.ok) { + return + } + + const orgData = (await response.json()) as { + organizations?: Array<{ id: string }> + } + const organizationId = orgData.organizations?.[0]?.id + + if (!organizationId || isCancelled) { + return + } + + await client.organization.setActive({ organizationId }) + + if (!isCancelled) { + await loadSession(true) + } + } catch (error) { + logger.warn('Failed to activate organization after subscription upgrade', { error }) + } + } + + void initializeSession() + + return () => { + isCancelled = true + } }, [loadSession, queryClient]) useEffect(() => { @@ -107,9 +153,13 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { .catch(() => {}) }, [data, isPending]) + const refetch = useCallback(async () => { + await loadSession() + }, [loadSession]) + const value = useMemo( - () => ({ data, isPending, error, refetch: loadSession }), - [data, isPending, error, loadSession] + () => ({ data, isPending, error, refetch }), + [data, isPending, error, refetch] ) return {children} diff --git a/apps/sim/app/api/auth/[...all]/route.test.ts b/apps/sim/app/api/auth/[...all]/route.test.ts index 37167b82a48..d9aa74cab91 100644 --- a/apps/sim/app/api/auth/[...all]/route.test.ts +++ b/apps/sim/app/api/auth/[...all]/route.test.ts @@ -39,7 +39,7 @@ vi.mock('@/lib/core/config/feature-flags', () => ({ }, })) -import { GET } from '@/app/api/auth/[...all]/route' +import { GET, POST } from '@/app/api/auth/[...all]/route' describe('auth catch-all route (DISABLE_AUTH get-session)', () => { beforeEach(() => { @@ -95,3 +95,49 @@ describe('auth catch-all route (DISABLE_AUTH get-session)', () => { expect(json).toEqual({ data: { ok: true } }) }) }) + +describe('auth catch-all route organization mutations', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('blocks Better Auth organization mutation endpoints that bypass app lifecycle rules', async () => { + const req = createMockRequest( + 'POST', + undefined, + {}, + 'http://localhost:3000/api/auth/organization/create' + ) + + const res = await POST(req as any) + const json = await res.json() + + expect(res.status).toBe(404) + expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled() + expect(json).toEqual({ + error: 'Organization mutations are handled by application API routes.', + }) + }) + + it('allows safe Better Auth organization session endpoints', async () => { + const { NextResponse } = await import('next/server') + handlerMocks.betterAuthPOST.mockResolvedValueOnce( + new NextResponse(JSON.stringify({ data: { ok: true } }), { + headers: { 'content-type': 'application/json' }, + }) as any + ) + + const req = createMockRequest( + 'POST', + undefined, + {}, + 'http://localhost:3000/api/auth/organization/set-active' + ) + + const res = await POST(req as any) + const json = await res.json() + + expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1) + expect(json).toEqual({ data: { ok: true } }) + }) +}) diff --git a/apps/sim/app/api/auth/[...all]/route.ts b/apps/sim/app/api/auth/[...all]/route.ts index 8578b25e181..31005daafcc 100644 --- a/apps/sim/app/api/auth/[...all]/route.ts +++ b/apps/sim/app/api/auth/[...all]/route.ts @@ -7,6 +7,11 @@ import { isAuthDisabled } from '@/lib/core/config/feature-flags' export const dynamic = 'force-dynamic' const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler) +const SAFE_ORGANIZATION_POST_PATHS = new Set(['organization/check-slug', 'organization/set-active']) + +function isBlockedOrganizationMutationPath(path: string): boolean { + return path.startsWith('organization/') && !SAFE_ORGANIZATION_POST_PATHS.has(path) +} export async function GET(request: NextRequest) { const url = new URL(request.url) @@ -20,4 +25,16 @@ export async function GET(request: NextRequest) { return betterAuthGET(request) } -export const POST = betterAuthPOST +export async function POST(request: NextRequest) { + const url = new URL(request.url) + const path = url.pathname.replace('/api/auth/', '') + + if (isBlockedOrganizationMutationPath(path)) { + return NextResponse.json( + { error: 'Organization mutations are handled by application API routes.' }, + { status: 404 } + ) + } + + return betterAuthPOST(request) +} diff --git a/apps/sim/app/api/invitations/[id]/accept/route.ts b/apps/sim/app/api/invitations/[id]/accept/route.ts new file mode 100644 index 00000000000..bbb5ca9835f --- /dev/null +++ b/apps/sim/app/api/invitations/[id]/accept/route.ts @@ -0,0 +1,84 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { getSession } from '@/lib/auth' +import { acceptInvitation } from '@/lib/invitations/core' + +const logger = createLogger('InvitationAcceptAPI') + +const bodySchema = z.object({ token: z.string().min(1).optional() }) + +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const session = await getSession() + + if (!session?.user?.id || !session.user.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json().catch(() => ({})) + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + + const result = await acceptInvitation({ + userId: session.user.id, + userEmail: session.user.email, + invitationId: id, + token: parsed.data.token ?? null, + }) + + if (!result.success) { + const statusMap: Record = { + 'not-found': 404, + 'invalid-token': 400, + 'already-processed': 400, + expired: 400, + 'email-mismatch': 403, + 'already-in-organization': 409, + 'no-seats-available': 400, + 'server-error': 500, + } + const status = statusMap[result.kind] ?? 500 + logger.warn('Invitation accept rejected', { invitationId: id, reason: result.kind }) + return NextResponse.json({ error: result.kind }, { status }) + } + + const inv = result.invitation + + recordAudit({ + workspaceId: result.acceptedWorkspaceIds[0] ?? null, + actorId: session.user.id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + action: + inv.kind === 'workspace' + ? AuditAction.INVITATION_ACCEPTED + : AuditAction.ORG_INVITATION_ACCEPTED, + resourceType: + inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION, + resourceId: inv.organizationId ?? result.acceptedWorkspaceIds[0] ?? inv.id, + description: `Accepted ${inv.kind} invitation for ${inv.email}`, + metadata: { + invitationId: inv.id, + targetEmail: inv.email, + targetRole: inv.role, + kind: inv.kind, + workspaceIds: result.acceptedWorkspaceIds, + }, + request, + }) + + return NextResponse.json({ + success: true, + redirectPath: result.redirectPath, + invitation: { + id: inv.id, + kind: inv.kind, + organizationId: inv.organizationId, + acceptedWorkspaceIds: result.acceptedWorkspaceIds, + }, + }) +} diff --git a/apps/sim/app/api/invitations/[id]/reject/route.ts b/apps/sim/app/api/invitations/[id]/reject/route.ts new file mode 100644 index 00000000000..07e3c875217 --- /dev/null +++ b/apps/sim/app/api/invitations/[id]/reject/route.ts @@ -0,0 +1,70 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { getSession } from '@/lib/auth' +import { rejectInvitation } from '@/lib/invitations/core' + +const logger = createLogger('InvitationRejectAPI') + +const bodySchema = z.object({ token: z.string().min(1).optional() }) + +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const session = await getSession() + + if (!session?.user?.id || !session.user.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json().catch(() => ({})) + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + + const result = await rejectInvitation({ + userId: session.user.id, + userEmail: session.user.email, + invitationId: id, + token: parsed.data.token ?? null, + }) + + if (!result.success) { + const statusMap: Record = { + 'not-found': 404, + 'invalid-token': 400, + 'already-processed': 400, + expired: 400, + 'email-mismatch': 403, + } + const status = statusMap[result.kind] ?? 500 + logger.warn('Invitation reject rejected', { invitationId: id, reason: result.kind }) + return NextResponse.json({ error: result.kind }, { status }) + } + + const inv = result.invitation + recordAudit({ + workspaceId: null, + actorId: session.user.id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + action: + inv.kind === 'workspace' + ? AuditAction.INVITATION_REJECTED + : AuditAction.ORG_INVITATION_REJECTED, + resourceType: + inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION, + resourceId: inv.organizationId ?? inv.grants[0]?.workspaceId ?? inv.id, + description: `Rejected ${inv.kind} invitation for ${inv.email}`, + metadata: { + invitationId: inv.id, + targetEmail: inv.email, + targetRole: inv.role, + kind: inv.kind, + }, + request, + }) + + return NextResponse.json({ success: true }) +} diff --git a/apps/sim/app/api/invitations/[id]/resend/route.ts b/apps/sim/app/api/invitations/[id]/resend/route.ts new file mode 100644 index 00000000000..14388ebe86e --- /dev/null +++ b/apps/sim/app/api/invitations/[id]/resend/route.ts @@ -0,0 +1,156 @@ +import { db } from '@sim/db' +import { user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { getSession } from '@/lib/auth' +import { getOrganizationSubscription } from '@/lib/billing/core/billing' +import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' +import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers' +import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' +import { getInvitationById } from '@/lib/invitations/core' +import { + persistInvitationResend, + prepareInvitationResend, + sendInvitationEmail, +} from '@/lib/invitations/send' +import { getWorkspaceWithOwner, hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' +import { getWorkspaceInvitePolicy } from '@/lib/workspaces/policy' + +const logger = createLogger('InvitationResendAPI') + +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const inv = await getInvitationById(id) + if (!inv) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } + if (inv.status !== 'pending') { + return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 }) + } + + let canResend = false + if (inv.organizationId) { + canResend = await isOrganizationOwnerOrAdmin(session.user.id, inv.organizationId) + } + if (!canResend && inv.grants.length > 0) { + const adminChecks = await Promise.all( + inv.grants.map((grant) => hasWorkspaceAdminAccess(session.user.id, grant.workspaceId)) + ) + canResend = adminChecks.some(Boolean) + } + if (!canResend) { + return NextResponse.json( + { error: 'Only an organization or workspace admin can resend this invitation' }, + { status: 403 } + ) + } + + for (const grant of inv.grants) { + const workspaceDetails = await getWorkspaceWithOwner(grant.workspaceId) + if (!workspaceDetails) { + return NextResponse.json( + { error: 'Invitation references a workspace that no longer exists' }, + { status: 409 } + ) + } + const policy = await getWorkspaceInvitePolicy(workspaceDetails) + if (!policy.allowed) { + return NextResponse.json( + { + error: policy.reason ?? 'Invites are no longer allowed on this workspace', + upgradeRequired: policy.upgradeRequired, + }, + { status: 403 } + ) + } + } + + if (inv.kind === 'organization' && inv.grants.length === 0 && inv.organizationId) { + const orgSubscription = await getOrganizationSubscription(inv.organizationId) + const orgOnTeamOrEnterprise = + !!orgSubscription && + hasUsableSubscriptionStatus(orgSubscription.status) && + (isTeam(orgSubscription.plan) || isEnterprise(orgSubscription.plan)) + if (!orgOnTeamOrEnterprise) { + return NextResponse.json( + { + error: 'Invites are no longer allowed on this organization', + upgradeRequired: true, + }, + { status: 403 } + ) + } + } + + const { tokenForEmail, nextToken, nextExpiresAt } = await prepareInvitationResend({ + invitationId: id, + rotateToken: true, + currentToken: inv.token, + }) + + const [inviterRow] = await db + .select({ name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1) + + const emailResult = await sendInvitationEmail({ + invitationId: inv.id, + token: tokenForEmail, + kind: inv.kind, + email: inv.email, + inviterName: inviterRow?.name || inviterRow?.email || 'A user', + organizationId: inv.organizationId, + organizationRole: (inv.role as 'admin' | 'member') || 'member', + grants: inv.grants.map((grant) => ({ + workspaceId: grant.workspaceId, + permission: grant.permission, + })), + }) + + if (!emailResult.success) { + return NextResponse.json( + { error: emailResult.error || 'Failed to send invitation email' }, + { status: 502 } + ) + } + + await persistInvitationResend({ invitationId: id, nextToken, nextExpiresAt }) + + recordAudit({ + workspaceId: inv.grants[0]?.workspaceId ?? null, + actorId: session.user.id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + action: + inv.kind === 'workspace' + ? AuditAction.INVITATION_RESENT + : AuditAction.ORG_INVITATION_RESENT, + resourceType: + inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION, + resourceId: inv.organizationId ?? inv.grants[0]?.workspaceId ?? inv.id, + description: `Resent ${inv.kind} invitation to ${inv.email}`, + metadata: { + invitationId: inv.id, + targetEmail: inv.email, + targetRole: inv.role, + kind: inv.kind, + }, + request, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Failed to resend invitation', { invitationId: id, error }) + return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/invitations/[id]/route.ts b/apps/sim/app/api/invitations/[id]/route.ts new file mode 100644 index 00000000000..fe12f9398b0 --- /dev/null +++ b/apps/sim/app/api/invitations/[id]/route.ts @@ -0,0 +1,270 @@ +import { db } from '@sim/db' +import { invitation, invitationWorkspaceGrant } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { getSession } from '@/lib/auth' +import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' +import { cancelInvitation, getInvitationById, normalizeEmail } from '@/lib/invitations/core' +import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('InvitationsAPI') + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const inv = await getInvitationById(id) + if (!inv) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } + + const token = request.nextUrl.searchParams.get('token') + 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 && !hasAdminView) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + return NextResponse.json({ + invitation: { + id: inv.id, + kind: inv.kind, + email: inv.email, + organizationId: inv.organizationId, + organizationName: inv.organizationName, + role: inv.role, + status: inv.status, + expiresAt: inv.expiresAt, + createdAt: inv.createdAt, + inviterName: inv.inviterName, + inviterEmail: inv.inviterEmail, + grants: inv.grants.map((grant) => ({ + workspaceId: grant.workspaceId, + workspaceName: grant.workspaceName, + permission: grant.permission, + })), + }, + }) + } catch (error) { + logger.error('Failed to fetch invitation', { invitationId: id, error }) + return NextResponse.json({ error: 'Failed to fetch invitation' }, { status: 500 }) + } +} + +const patchSchema = z + .object({ + role: z.enum(['admin', 'member']).optional(), + grants: z + .array( + z.object({ + workspaceId: z.string().min(1), + permission: z.enum(['read', 'write', 'admin']), + }) + ) + .optional(), + }) + .refine((data) => data.role !== undefined || (data.grants && data.grants.length > 0), { + message: 'Provide a role or at least one grant update', + }) + +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const inv = await getInvitationById(id) + if (!inv) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } + + if (inv.status !== 'pending') { + return NextResponse.json({ error: 'Can only modify pending invitations' }, { status: 400 }) + } + + const body = await request.json().catch(() => ({})) + const parsed = patchSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.errors[0]?.message || 'Invalid request body' }, + { status: 400 } + ) + } + + const { role, grants } = parsed.data + + if (role !== undefined) { + if (!inv.organizationId) { + return NextResponse.json( + { error: 'Role updates are only valid on organization-scoped invitations' }, + { status: 400 } + ) + } + if (!(await isOrganizationOwnerOrAdmin(session.user.id, inv.organizationId))) { + return NextResponse.json( + { error: 'Only an organization owner or admin can change invitation roles' }, + { status: 403 } + ) + } + } + + const grantsToApply = grants ?? [] + for (const update of grantsToApply) { + const belongsToInvite = inv.grants.some((g) => g.workspaceId === update.workspaceId) + if (!belongsToInvite) { + return NextResponse.json( + { error: `Invitation does not grant access to workspace ${update.workspaceId}` }, + { status: 400 } + ) + } + if (!(await hasWorkspaceAdminAccess(session.user.id, update.workspaceId))) { + return NextResponse.json( + { error: 'Workspace admin access required to change grant permissions' }, + { status: 403 } + ) + } + } + + await db.transaction(async (tx) => { + if (role !== undefined && role !== inv.role) { + await tx + .update(invitation) + .set({ role, updatedAt: new Date() }) + .where(eq(invitation.id, id)) + } + for (const update of grantsToApply) { + await tx + .update(invitationWorkspaceGrant) + .set({ permission: update.permission, updatedAt: new Date() }) + .where( + and( + eq(invitationWorkspaceGrant.invitationId, id), + eq(invitationWorkspaceGrant.workspaceId, update.workspaceId) + ) + ) + } + }) + + const isOrgScoped = inv.kind === 'organization' + const primaryWorkspaceId = inv.grants[0]?.workspaceId ?? null + recordAudit({ + workspaceId: isOrgScoped ? null : primaryWorkspaceId, + actorId: session.user.id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + action: isOrgScoped ? AuditAction.ORG_INVITATION_UPDATED : AuditAction.INVITATION_UPDATED, + resourceType: isOrgScoped ? AuditResourceType.ORGANIZATION : AuditResourceType.WORKSPACE, + resourceId: isOrgScoped ? (inv.organizationId ?? inv.id) : (primaryWorkspaceId ?? inv.id), + description: `Updated ${inv.kind} invitation for ${inv.email}`, + metadata: { + invitationId: id, + targetEmail: inv.email, + kind: inv.kind, + roleUpdate: role ?? null, + grantUpdates: grantsToApply, + }, + request, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Failed to update invitation', { invitationId: id, error }) + return NextResponse.json({ error: 'Failed to update invitation' }, { status: 500 }) + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const inv = await getInvitationById(id) + if (!inv) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } + + let canCancel = false + if (inv.organizationId) { + canCancel = await isOrganizationOwnerOrAdmin(session.user.id, inv.organizationId) + } + if (!canCancel && inv.grants.length > 0) { + const adminChecks = await Promise.all( + inv.grants.map((grant) => hasWorkspaceAdminAccess(session.user.id, grant.workspaceId)) + ) + canCancel = adminChecks.some(Boolean) + } + + if (!canCancel) { + return NextResponse.json( + { error: 'Only an organization or workspace admin can cancel this invitation' }, + { status: 403 } + ) + } + + if (inv.status !== 'pending') { + return NextResponse.json({ error: 'Can only cancel pending invitations' }, { status: 400 }) + } + + const cancelled = await cancelInvitation(id) + if (!cancelled) { + return NextResponse.json({ error: 'Invitation not cancellable' }, { status: 400 }) + } + + recordAudit({ + workspaceId: inv.grants[0]?.workspaceId ?? null, + actorId: session.user.id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + action: + inv.kind === 'workspace' + ? AuditAction.INVITATION_REVOKED + : AuditAction.ORG_INVITATION_REVOKED, + resourceType: + inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION, + resourceId: inv.organizationId ?? inv.grants[0]?.workspaceId ?? id, + description: `Cancelled ${inv.kind} invitation for ${inv.email}`, + metadata: { + invitationId: id, + targetEmail: inv.email, + targetRole: inv.role, + kind: inv.kind, + }, + request, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Failed to cancel invitation', { invitationId: id, error }) + return NextResponse.json({ error: 'Failed to cancel invitation' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts deleted file mode 100644 index 51ce6ac9099..00000000000 --- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts +++ /dev/null @@ -1,640 +0,0 @@ -import { db } from '@sim/db' -import { - invitation, - member, - organization, - permissionGroup, - permissionGroupMember, - permissions, - subscription as subscriptionTable, - user, - userStats, - type WorkspaceInvitationStatus, - workspaceEnvironment, - workspaceInvitation, -} from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq, inArray, sql } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { getEmailSubject, renderInvitationEmail } from '@/components/emails' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' -import { getSession } from '@/lib/auth' -import { hasAccessControlAccess } from '@/lib/billing' -import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' -import { isPaid, sqlIsPro } from '@/lib/billing/plan-helpers' -import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' -import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' -import { enqueueOutboxEvent } from '@/lib/core/outbox/service' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' -import { sendEmail } from '@/lib/messaging/email/mailer' - -const logger = createLogger('OrganizationInvitation') - -const updateInvitationSchema = z.object({ - status: z.enum(['accepted', 'rejected', 'cancelled'], { - errorMap: () => ({ message: 'Invalid status. Must be "accepted", "rejected", or "cancelled"' }), - }), -}) - -// Get invitation details -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ id: string; invitationId: string }> } -) { - const { id: organizationId, invitationId } = await params - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const orgInvitation = await db - .select() - .from(invitation) - .where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId))) - .then((rows) => rows[0]) - - if (!orgInvitation) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - - // Verify caller is either an org member or the invitee - const isInvitee = session.user.email?.toLowerCase() === orgInvitation.email.toLowerCase() - - if (!isInvitee) { - const memberEntry = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) - - if (memberEntry.length === 0) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const org = await db - .select() - .from(organization) - .where(eq(organization.id, organizationId)) - .then((rows) => rows[0]) - - if (!org) { - return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) - } - - return NextResponse.json({ - invitation: orgInvitation, - organization: org, - }) - } catch (error) { - logger.error('Error fetching organization invitation:', error) - return NextResponse.json({ error: 'Failed to fetch invitation' }, { status: 500 }) - } -} - -// Resend invitation -export async function POST( - _request: NextRequest, - { params }: { params: Promise<{ id: string; invitationId: string }> } -) { - const { id: organizationId, invitationId } = await params - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - // Verify user is admin/owner - const memberEntry = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) - - if (memberEntry.length === 0 || !['owner', 'admin'].includes(memberEntry[0].role)) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } - - const orgInvitation = await db - .select() - .from(invitation) - .where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId))) - .then((rows) => rows[0]) - - if (!orgInvitation) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - - if (orgInvitation.status !== 'pending') { - return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 }) - } - - const org = await db - .select({ name: organization.name }) - .from(organization) - .where(eq(organization.id, organizationId)) - .then((rows) => rows[0]) - - const inviter = await db - .select({ name: user.name }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) - - // Update expiration date - const newExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days - await db - .update(invitation) - .set({ expiresAt: newExpiresAt }) - .where(eq(invitation.id, invitationId)) - - // Send email - const emailHtml = await renderInvitationEmail( - inviter[0]?.name || 'Someone', - org?.name || 'organization', - `${getBaseUrl()}/invite/${invitationId}` - ) - - const emailResult = await sendEmail({ - to: orgInvitation.email, - subject: getEmailSubject('invitation'), - html: emailHtml, - emailType: 'transactional', - }) - - if (!emailResult.success) { - logger.error('Failed to resend invitation email', { - email: orgInvitation.email, - error: emailResult.message, - }) - return NextResponse.json({ error: 'Failed to send invitation email' }, { status: 500 }) - } - - logger.info('Organization invitation resent', { - organizationId, - invitationId, - resentBy: session.user.id, - email: orgInvitation.email, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.ORG_INVITATION_RESENT, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: org?.name ?? undefined, - description: `Resent organization invitation to ${orgInvitation.email}`, - metadata: { invitationId, targetEmail: orgInvitation.email, targetRole: orgInvitation.role }, - request: _request, - }) - - return NextResponse.json({ - success: true, - message: 'Invitation resent successfully', - }) - } catch (error) { - logger.error('Error resending organization invitation:', error) - return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 }) - } -} - -export async function PUT( - req: NextRequest, - { params }: { params: Promise<{ id: string; invitationId: string }> } -) { - const { id: organizationId, invitationId } = await params - - logger.info( - '[PUT /api/organizations/[id]/invitations/[invitationId]] Invitation acceptance request', - { - organizationId, - invitationId, - path: req.url, - } - ) - - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const body = await req.json() - - const validation = updateInvitationSchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } - - const { status } = validation.data - - const orgInvitation = await db - .select() - .from(invitation) - .where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId))) - .then((rows) => rows[0]) - - if (!orgInvitation) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - - if (orgInvitation.status !== 'pending') { - return NextResponse.json({ error: 'Invitation already processed' }, { status: 400 }) - } - - if (status === 'accepted') { - const userData = await db - .select() - .from(user) - .where(eq(user.id, session.user.id)) - .then((rows) => rows[0]) - - if (!userData || userData.email.toLowerCase() !== orgInvitation.email.toLowerCase()) { - return NextResponse.json( - { error: 'Email mismatch. You can only accept invitations sent to your email address.' }, - { status: 403 } - ) - } - } - - if (status === 'cancelled') { - const isAdmin = await db - .select() - .from(member) - .where( - and( - eq(member.organizationId, organizationId), - eq(member.userId, session.user.id), - eq(member.role, 'admin') - ) - ) - .then((rows) => rows.length > 0) - - if (!isAdmin) { - return NextResponse.json( - { error: 'Only organization admins can cancel invitations' }, - { status: 403 } - ) - } - } - - // Enforce: user can only be part of a single organization - if (status === 'accepted') { - // Check if user is already a member of ANY organization - const existingOrgMemberships = await db - .select({ organizationId: member.organizationId }) - .from(member) - .where(eq(member.userId, session.user.id)) - - if (existingOrgMemberships.length > 0) { - // Check if already a member of THIS specific organization - const alreadyMemberOfThisOrg = existingOrgMemberships.some( - (m) => m.organizationId === organizationId - ) - - if (alreadyMemberOfThisOrg) { - return NextResponse.json( - { error: 'You are already a member of this organization' }, - { status: 400 } - ) - } - - // Member of a different organization - // Mark the invitation as rejected since they can't accept it - await db - .update(invitation) - .set({ - status: 'rejected', - }) - .where(eq(invitation.id, invitationId)) - - return NextResponse.json( - { - error: - 'You are already a member of an organization. Leave your current organization before accepting a new invitation.', - }, - { status: 409 } - ) - } - } - - await db.transaction(async (tx) => { - await tx.update(invitation).set({ status }).where(eq(invitation.id, invitationId)) - - if (status === 'accepted') { - await tx.insert(member).values({ - id: generateId(), - userId: session.user.id, - organizationId, - role: orgInvitation.role, - createdAt: new Date(), - }) - - { - const orgSubs = await tx - .select() - .from(subscriptionTable) - .where( - and( - eq(subscriptionTable.referenceId, organizationId), - inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES) - ) - ) - .limit(1) - - const orgSub = orgSubs[0] - const orgIsPaid = orgSub && isPaid(orgSub.plan) - - if (orgIsPaid) { - const userId = session.user.id - - // Find user's active personal Pro subscription - const personalSubs = await tx - .select() - .from(subscriptionTable) - .where( - and( - eq(subscriptionTable.referenceId, userId), - inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES), - sqlIsPro(subscriptionTable.plan) - ) - ) - .limit(1) - - const personalPro = personalSubs[0] - if (personalPro) { - // Snapshot the current Pro usage before resetting - const userStatsRows = await tx - .select({ - currentPeriodCost: userStats.currentPeriodCost, - }) - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) - - if (userStatsRows.length > 0) { - const currentProUsage = userStatsRows[0].currentPeriodCost || '0' - - // Snapshot Pro usage and reset currentPeriodCost so new usage goes to team - await tx - .update(userStats) - .set({ - proPeriodCostSnapshot: currentProUsage, - proPeriodCostSnapshotAt: new Date(), - currentPeriodCost: '0', - currentPeriodCopilotCost: '0', - }) - .where(eq(userStats.userId, userId)) - - logger.info('Snapshotted Pro usage when joining team', { - userId, - proUsageSnapshot: currentProUsage, - organizationId, - }) - } - - if (personalPro.cancelAtPeriodEnd !== true && personalPro.stripeSubscriptionId) { - await tx - .update(subscriptionTable) - .set({ cancelAtPeriodEnd: true }) - .where(eq(subscriptionTable.id, personalPro.id)) - - await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, { - stripeSubscriptionId: personalPro.stripeSubscriptionId, - subscriptionId: personalPro.id, - reason: 'member-joined-paid-org', - }) - } - } - - const storageRows = await tx - .select({ storageUsedBytes: userStats.storageUsedBytes }) - .from(userStats) - .where(eq(userStats.userId, userId)) - .for('update') - .limit(1) - - const bytesToTransfer = storageRows[0]?.storageUsedBytes ?? 0 - if (bytesToTransfer > 0) { - await tx - .update(organization) - .set({ - storageUsedBytes: sql`${organization.storageUsedBytes} + ${bytesToTransfer}`, - }) - .where(eq(organization.id, organizationId)) - - await tx - .update(userStats) - .set({ storageUsedBytes: 0 }) - .where(eq(userStats.userId, userId)) - - logger.info('Transferred personal storage bytes to org pool on join', { - userId, - organizationId, - bytes: bytesToTransfer, - }) - } - } - } - - // Auto-assign to permission group if one has autoAddNewMembers enabled - try { - const hasAccessControl = await hasAccessControlAccess(session.user.id) - if (hasAccessControl) { - const [autoAddGroup] = await tx - .select({ id: permissionGroup.id, name: permissionGroup.name }) - .from(permissionGroup) - .where( - and( - eq(permissionGroup.organizationId, organizationId), - eq(permissionGroup.autoAddNewMembers, true) - ) - ) - .limit(1) - - if (autoAddGroup) { - await tx.insert(permissionGroupMember).values({ - id: generateId(), - permissionGroupId: autoAddGroup.id, - userId: session.user.id, - assignedBy: null, - assignedAt: new Date(), - }) - - logger.info('Auto-assigned new member to permission group', { - userId: session.user.id, - organizationId, - permissionGroupId: autoAddGroup.id, - permissionGroupName: autoAddGroup.name, - }) - } - } - } catch (error) { - logger.error('Failed to auto-assign user to permission group', { - userId: session.user.id, - organizationId, - error, - }) - // Don't fail the whole invitation acceptance due to this - } - - const linkedWorkspaceInvitations = await tx - .select() - .from(workspaceInvitation) - .where( - and( - eq(workspaceInvitation.orgInvitationId, invitationId), - eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus) - ) - ) - - for (const wsInvitation of linkedWorkspaceInvitations) { - await tx - .update(workspaceInvitation) - .set({ - status: 'accepted' as WorkspaceInvitationStatus, - updatedAt: new Date(), - }) - .where(eq(workspaceInvitation.id, wsInvitation.id)) - - const existingPermission = await tx - .select({ id: permissions.id, permissionType: permissions.permissionType }) - .from(permissions) - .where( - and( - eq(permissions.entityId, wsInvitation.workspaceId), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, session.user.id) - ) - ) - .then((rows) => rows[0]) - - if (existingPermission) { - const PERMISSION_RANK = { read: 0, write: 1, admin: 2 } as const - type PermissionLevel = keyof typeof PERMISSION_RANK - const existingRank = - PERMISSION_RANK[existingPermission.permissionType as PermissionLevel] ?? 0 - const newPermission = (wsInvitation.permissions || 'read') as PermissionLevel - const newRank = PERMISSION_RANK[newPermission] ?? 0 - - if (newRank > existingRank) { - await tx - .update(permissions) - .set({ - permissionType: newPermission, - updatedAt: new Date(), - }) - .where(eq(permissions.id, existingPermission.id)) - } - } else { - await tx.insert(permissions).values({ - id: generateId(), - entityType: 'workspace', - entityId: wsInvitation.workspaceId, - userId: session.user.id, - permissionType: wsInvitation.permissions || 'read', - createdAt: new Date(), - updatedAt: new Date(), - }) - } - } - } else if (status === 'cancelled') { - await tx - .update(workspaceInvitation) - .set({ status: 'cancelled' as WorkspaceInvitationStatus }) - .where(eq(workspaceInvitation.orgInvitationId, invitationId)) - } - }) - - if (status === 'accepted') { - const acceptedWsInvitations = await db - .select({ workspaceId: workspaceInvitation.workspaceId }) - .from(workspaceInvitation) - .where( - and( - eq(workspaceInvitation.orgInvitationId, invitationId), - eq(workspaceInvitation.status, 'accepted' as WorkspaceInvitationStatus) - ) - ) - - for (const wsInv of acceptedWsInvitations) { - const [wsEnvRow] = await db - .select({ variables: workspaceEnvironment.variables }) - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, wsInv.workspaceId)) - .limit(1) - const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record) || {}) - if (wsEnvKeys.length > 0) { - await syncWorkspaceEnvCredentials({ - workspaceId: wsInv.workspaceId, - envKeys: wsEnvKeys, - actingUserId: session.user.id, - }) - } - } - } - - if (status === 'accepted') { - try { - await syncUsageLimitsFromSubscription(session.user.id) - } catch (syncError) { - logger.error('Failed to sync usage limits after joining org', { - userId: session.user.id, - organizationId, - error: syncError, - }) - } - } - - logger.info(`Organization invitation ${status}`, { - organizationId, - invitationId, - userId: session.user.id, - email: orgInvitation.email, - }) - - const auditActionMap = { - accepted: AuditAction.ORG_INVITATION_ACCEPTED, - rejected: AuditAction.ORG_INVITATION_REJECTED, - cancelled: AuditAction.ORG_INVITATION_CANCELLED, - } as const - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: auditActionMap[status], - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Organization invitation ${status} for ${orgInvitation.email}`, - metadata: { - invitationId, - targetEmail: orgInvitation.email, - targetRole: orgInvitation.role, - status, - }, - request: req, - }) - - return NextResponse.json({ - success: true, - message: `Invitation ${status} successfully`, - invitation: { ...orgInvitation, status }, - }) - } catch (error) { - logger.error(`Error updating organization invitation:`, error) - return NextResponse.json({ error: 'Failed to update invitation' }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts new file mode 100644 index 00000000000..cd508506fb7 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts @@ -0,0 +1,209 @@ +/** + * @vitest-environment node + */ +import { auditMock, createSession, loggerMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockDbState, + mockGetSession, + mockValidateInvitationsAllowed, + mockValidateSeatAvailability, + mockCreatePendingInvitation, + mockSendInvitationEmail, + mockCancelPendingInvitation, +} = vi.hoisted(() => ({ + mockDbState: { + selectResults: [] as any[], + }, + mockGetSession: vi.fn(), + mockValidateInvitationsAllowed: vi.fn(), + mockValidateSeatAvailability: vi.fn(), + mockCreatePendingInvitation: vi.fn(), + mockSendInvitationEmail: vi.fn(), + mockCancelPendingInvitation: vi.fn(), +})) + +function createSelectChain() { + const chain: any = {} + chain.from = vi.fn().mockReturnValue(chain) + chain.innerJoin = vi.fn().mockReturnValue(chain) + chain.leftJoin = vi.fn().mockReturnValue(chain) + chain.where = vi.fn().mockReturnValue(chain) + chain.orderBy = vi.fn().mockReturnValue(chain) + chain.limit = vi + .fn() + .mockImplementation(() => Promise.resolve(mockDbState.selectResults.shift() ?? [])) + chain.then = vi.fn().mockImplementation((callback: (rows: any[]) => unknown) => { + const rows = mockDbState.selectResults.shift() ?? [] + return Promise.resolve(callback(rows)) + }) + return chain +} + +vi.mock('@sim/db', () => ({ + db: { + select: vi.fn().mockImplementation(() => createSelectChain()), + }, +})) + +vi.mock('@sim/db/schema', () => ({ + invitation: { + id: 'invitation.id', + organizationId: 'invitation.organizationId', + status: 'invitation.status', + email: 'invitation.email', + kind: 'invitation.kind', + role: 'invitation.role', + inviterId: 'invitation.inviterId', + expiresAt: 'invitation.expiresAt', + createdAt: 'invitation.createdAt', + }, + member: { + organizationId: 'member.organizationId', + userId: 'member.userId', + role: 'member.role', + }, + organization: { + id: 'organization.id', + name: 'organization.name', + }, + user: { + id: 'user.id', + name: 'user.name', + email: 'user.email', + }, + workspace: { + id: 'workspace.id', + name: 'workspace.name', + organizationId: 'workspace.organizationId', + workspaceMode: 'workspace.workspaceMode', + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), + inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values })), +})) + +vi.mock('@sim/logger', () => loggerMock) + +vi.mock('@/lib/audit/log', () => auditMock) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/billing/validation/seat-management', () => ({ + validateBulkInvitations: vi.fn(), + validateSeatAvailability: mockValidateSeatAvailability, +})) + +vi.mock('@/lib/invitations/send', () => ({ + createPendingInvitation: mockCreatePendingInvitation, + sendInvitationEmail: mockSendInvitationEmail, + cancelPendingInvitation: mockCancelPendingInvitation, +})) + +vi.mock('@/lib/messaging/email/validation', () => ({ + quickValidateEmail: vi.fn((email: string) => ({ isValid: email.includes('@') })), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + hasWorkspaceAdminAccess: vi.fn().mockResolvedValue(true), +})) + +vi.mock('@/lib/workspaces/policy', () => ({ + isOrganizationWorkspace: vi.fn().mockReturnValue(true), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error {}, + validateInvitationsAllowed: mockValidateInvitationsAllowed, +})) + +import { POST } from '@/app/api/organizations/[id]/invitations/route' + +describe('POST /api/organizations/[id]/invitations', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDbState.selectResults = [] + mockValidateInvitationsAllowed.mockResolvedValue(undefined) + mockValidateSeatAvailability.mockResolvedValue({ + canInvite: true, + currentSeats: 1, + maxSeats: 5, + availableSeats: 4, + }) + mockCreatePendingInvitation.mockResolvedValue({ + invitationId: 'inv-1', + token: 'tok-1', + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }) + mockSendInvitationEmail.mockResolvedValue({ success: true }) + }) + + it('creates a unified invitation and sends a single email', async () => { + mockGetSession.mockResolvedValue( + createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) + ) + mockDbState.selectResults = [ + [{ role: 'owner' }], + [{ name: 'Org One' }], + [], + [], + [{ name: 'Owner', email: 'owner@example.com' }], + ] + + const response = await POST( + new Request('http://localhost/api/organizations/org-1/invitations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ emails: ['invitee@example.com'] }), + }) as any, + { params: Promise.resolve({ id: 'org-1' }) } + ) + + expect(response.status).toBe(200) + expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'organization', + email: 'invitee@example.com', + organizationId: 'org-1', + role: 'member', + grants: [], + }) + ) + expect(mockSendInvitationEmail).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'organization', email: 'invitee@example.com' }) + ) + expect(mockCancelPendingInvitation).not.toHaveBeenCalled() + }) + + it('rolls back the pending invitation when email delivery fails', async () => { + mockGetSession.mockResolvedValue( + createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) + ) + mockDbState.selectResults = [ + [{ role: 'owner' }], + [{ name: 'Org One' }], + [], + [], + [{ name: 'Owner', email: 'owner@example.com' }], + ] + mockSendInvitationEmail.mockResolvedValue({ success: false, error: 'mailer unavailable' }) + + const response = await POST( + new Request('http://localhost/api/organizations/org-1/invitations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ emails: ['invitee@example.com'] }), + }) as any, + { params: Promise.resolve({ id: 'org-1' }) } + ) + + expect(response.status).toBe(502) + expect(mockCancelPendingInvitation).toHaveBeenCalledWith('inv-1') + }) +}) diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index 1ecb244d291..6c2dbe054b2 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -1,32 +1,22 @@ import { db } from '@sim/db' -import { - invitation, - member, - organization, - user, - type WorkspaceInvitationStatus, - workspace, - workspaceInvitation, -} from '@sim/db/schema' +import { invitation, member, organization, user, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq, inArray, isNull, or } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { - getEmailSubject, - renderBatchInvitationEmail, - renderInvitationEmail, -} from '@/components/emails' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { validateBulkInvitations, validateSeatAvailability, } from '@/lib/billing/validation/seat-management' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { sendEmail } from '@/lib/messaging/email/mailer' +import { + cancelPendingInvitation, + createPendingInvitation, + sendInvitationEmail, +} from '@/lib/invitations/send' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' +import { isOrganizationWorkspace } from '@/lib/workspaces/policy' import { InvitationsNotAllowedError, validateInvitationsAllowed, @@ -34,42 +24,35 @@ import { const logger = createLogger('OrganizationInvitations') -interface WorkspaceInvitation { +interface WorkspaceGrantPayload { workspaceId: string permission: 'admin' | 'write' | 'read' } -/** - * GET /api/organizations/[id]/invitations - * Get all pending invitations for an organization - */ export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const session = await getSession() - if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const { id: organizationId } = await params - const memberEntry = await db + const [memberEntry] = await db .select() .from(member) .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) .limit(1) - if (memberEntry.length === 0) { + if (!memberEntry) { return NextResponse.json( { error: 'Forbidden - Not a member of this organization' }, { status: 403 } ) } - const userRole = memberEntry[0].role - const hasAdminAccess = ['owner', 'admin'].includes(userRole) - - if (!hasAdminAccess) { + const userRole = memberEntry.role + if (!['owner', 'admin'].includes(userRole)) { return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } @@ -77,6 +60,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .select({ id: invitation.id, email: invitation.email, + kind: invitation.kind, role: invitation.role, status: invitation.status, expiresAt: invitation.expiresAt, @@ -91,32 +75,17 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ success: true, - data: { - invitations, - userRole, - }, + data: { invitations, userRole }, }) } catch (error) { - logger.error('Failed to get organization invitations', { - organizationId: (await params).id, - error, - }) - + logger.error('Failed to get organization invitations', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } -/** - * POST /api/organizations/[id]/invitations - * Create organization invitations with optional validation and batch workspace invitations - * Query parameters: - * - ?validate=true - Only validate, don't send invitations - * - ?batch=true - Include workspace invitations - */ export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const session = await getSession() - if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -130,7 +99,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const body = await request.json() const { email, emails, role = 'member', workspaceInvitations } = body - const invitationEmails = email ? [email] : emails if (!invitationEmails || !Array.isArray(invitationEmails) || invitationEmails.length === 0) { @@ -141,33 +109,25 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Invalid role' }, { status: 400 }) } - const memberEntry = await db + const [memberEntry] = await db .select() .from(member) .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) .limit(1) - if (memberEntry.length === 0) { + if (!memberEntry) { return NextResponse.json( { error: 'Forbidden - Not a member of this organization' }, { status: 403 } ) } - if (!['owner', 'admin'].includes(memberEntry[0].role)) { + if (!['owner', 'admin'].includes(memberEntry.role)) { return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } if (validateOnly) { const validationResult = await validateBulkInvitations(organizationId, invitationEmails) - - logger.info('Invitation validation completed', { - organizationId, - userId: session.user.id, - emailCount: invitationEmails.length, - result: validationResult, - }) - return NextResponse.json({ success: true, data: validationResult, @@ -176,50 +136,42 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ }) } - const seatValidation = await validateSeatAvailability(organizationId, invitationEmails.length) - - if (!seatValidation.canInvite) { - return NextResponse.json( - { - error: seatValidation.reason, - seatInfo: { - currentSeats: seatValidation.currentSeats, - maxSeats: seatValidation.maxSeats, - availableSeats: seatValidation.availableSeats, - seatsRequested: invitationEmails.length, - }, - }, - { status: 400 } - ) - } - - const organizationEntry = await db + const [organizationEntry] = await db .select({ name: organization.name }) .from(organization) .where(eq(organization.id, organizationId)) .limit(1) - if (organizationEntry.length === 0) { + if (!organizationEntry) { return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) } - const processedEmails = invitationEmails - .map((email: string) => { - const normalized = email.trim().toLowerCase() - const validation = quickValidateEmail(normalized) - return validation.isValid ? normalized : null - }) - .filter(Boolean) as string[] + const processedEmails = Array.from( + new Set( + invitationEmails + .map((raw: string) => { + const normalized = raw.trim().toLowerCase() + return quickValidateEmail(normalized).isValid ? normalized : null + }) + .filter((email): email is string => !!email) + ) + ) if (processedEmails.length === 0) { return NextResponse.json({ error: 'No valid emails provided' }, { status: 400 }) } - const validWorkspaceInvitations: WorkspaceInvitation[] = [] - if (isBatch && workspaceInvitations && workspaceInvitations.length > 0) { + const validGrants: WorkspaceGrantPayload[] = [] + if (isBatch) { + if (!Array.isArray(workspaceInvitations) || workspaceInvitations.length === 0) { + return NextResponse.json( + { error: 'Select at least one organization workspace for this invitation.' }, + { status: 400 } + ) + } + for (const wsInvitation of workspaceInvitations) { const canInvite = await hasWorkspaceAdminAccess(session.user.id, wsInvitation.workspaceId) - if (!canInvite) { return NextResponse.json( { @@ -229,7 +181,38 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ) } - validWorkspaceInvitations.push(wsInvitation) + const [workspaceEntry] = await db + .select({ + id: workspace.id, + organizationId: workspace.organizationId, + workspaceMode: workspace.workspaceMode, + }) + .from(workspace) + .where(eq(workspace.id, wsInvitation.workspaceId)) + .limit(1) + + if (!workspaceEntry || !isOrganizationWorkspace(workspaceEntry)) { + return NextResponse.json( + { + error: `Workspace ${wsInvitation.workspaceId} is not an organization-owned workspace.`, + }, + { status: 400 } + ) + } + + if (workspaceEntry.organizationId !== organizationId) { + return NextResponse.json( + { + error: `Workspace ${wsInvitation.workspaceId} does not belong to this organization.`, + }, + { status: 400 } + ) + } + + validGrants.push({ + workspaceId: wsInvitation.workspaceId, + permission: wsInvitation.permission, + }) } } @@ -238,33 +221,29 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ .from(member) .innerJoin(user, eq(member.userId, user.id)) .where(eq(member.organizationId, organizationId)) - - const existingEmails = existingMembers.map((m) => m.userEmail) - const newEmails = processedEmails.filter((email: string) => !existingEmails.includes(email)) + const existingEmails = existingMembers.map((m) => m.userEmail.toLowerCase()) + const newEmails = processedEmails.filter((email) => !existingEmails.includes(email)) const existingInvitations = await db .select({ email: invitation.email }) .from(invitation) .where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending'))) - - const pendingEmails = existingInvitations.map((i) => i.email) - const emailsToInvite = newEmails.filter((email: string) => !pendingEmails.includes(email)) + const pendingEmails = existingInvitations.map((i) => i.email.toLowerCase()) + const emailsToInvite = newEmails.filter((email) => !pendingEmails.includes(email)) if (emailsToInvite.length === 0) { const isSingleEmail = processedEmails.length === 1 - const existingMembersEmails = processedEmails.filter((email: string) => + const existingMembersEmails = processedEmails.filter((email) => existingEmails.includes(email) ) - const pendingInvitationEmails = processedEmails.filter((email: string) => + const pendingInvitationEmails = processedEmails.filter((email) => pendingEmails.includes(email) ) if (isSingleEmail) { if (existingMembersEmails.length > 0) { return NextResponse.json( - { - error: 'Failed to send invitation. User is already a part of the organization.', - }, + { error: 'Failed to send invitation. User is already a part of the organization.' }, { status: 400 } ) } @@ -291,128 +270,79 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ) } - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days - const invitationsToCreate = emailsToInvite.map((email: string) => ({ - id: generateId(), - email, - inviterId: session.user.id, - organizationId, - role, - status: 'pending' as const, - expiresAt, - createdAt: new Date(), - })) - - await db.insert(invitation).values(invitationsToCreate) - - const workspaceInvitationIds: string[] = [] - if (isBatch && validWorkspaceInvitations.length > 0) { - for (const email of emailsToInvite) { - const orgInviteForEmail = invitationsToCreate.find((inv) => inv.email === email) - for (const wsInvitation of validWorkspaceInvitations) { - const wsInvitationId = generateId() - const token = generateId() - - await db.insert(workspaceInvitation).values({ - id: wsInvitationId, - workspaceId: wsInvitation.workspaceId, - email, - inviterId: session.user.id, - role: 'member', - status: 'pending', - token, - permissions: wsInvitation.permission, - orgInvitationId: orgInviteForEmail?.id, - expiresAt, - createdAt: new Date(), - updatedAt: new Date(), - }) - - workspaceInvitationIds.push(wsInvitationId) - } - } + const seatValidation = await validateSeatAvailability(organizationId, emailsToInvite.length) + if (!seatValidation.canInvite) { + return NextResponse.json( + { + error: seatValidation.reason, + seatInfo: { + currentSeats: seatValidation.currentSeats, + maxSeats: seatValidation.maxSeats, + availableSeats: seatValidation.availableSeats, + seatsRequested: emailsToInvite.length, + }, + }, + { status: 400 } + ) } - const inviter = await db - .select({ name: user.name }) + const [inviterRow] = await db + .select({ name: user.name, email: user.email }) .from(user) .where(eq(user.id, session.user.id)) .limit(1) + const inviterName = inviterRow?.name || inviterRow?.email || 'A user' - for (const email of emailsToInvite) { - const orgInvitation = invitationsToCreate.find((inv) => inv.email === email) - if (!orgInvitation) continue + const sentInvitations: Array<{ id: string; email: string }> = [] + const failedInvitations: Array<{ email: string; error: string }> = [] - let emailResult - if (isBatch && validWorkspaceInvitations.length > 0) { - const workspaceDetails = await db - .select({ - id: workspace.id, - name: workspace.name, - }) - .from(workspace) - .where( - inArray( - workspace.id, - validWorkspaceInvitations.map((w) => w.workspaceId) - ) - ) - - const workspaceInvitationsWithNames = validWorkspaceInvitations.map((wsInv) => ({ - workspaceId: wsInv.workspaceId, - workspaceName: - workspaceDetails.find((w) => w.id === wsInv.workspaceId)?.name || 'Unknown Workspace', - permission: wsInv.permission, - })) - - const emailHtml = await renderBatchInvitationEmail( - inviter[0]?.name || 'Someone', - organizationEntry[0]?.name || 'organization', - role, - workspaceInvitationsWithNames, - `${getBaseUrl()}/invite/${orgInvitation.id}` - ) - - emailResult = await sendEmail({ - to: email, - subject: getEmailSubject('batch-invitation'), - html: emailHtml, - emailType: 'transactional', + for (const email of emailsToInvite) { + try { + const { invitationId, token } = await createPendingInvitation({ + kind: 'organization', + email, + inviterId: session.user.id, + organizationId, + role: role as 'admin' | 'member', + grants: validGrants, }) - } else { - const emailHtml = await renderInvitationEmail( - inviter[0]?.name || 'Someone', - organizationEntry[0]?.name || 'organization', - `${getBaseUrl()}/invite/${orgInvitation.id}` - ) - emailResult = await sendEmail({ - to: email, - subject: getEmailSubject('invitation'), - html: emailHtml, - emailType: 'transactional', + const emailResult = await sendInvitationEmail({ + invitationId, + token, + kind: 'organization', + email, + inviterName, + organizationId, + organizationRole: role as 'admin' | 'member', + grants: validGrants, }) - } - if (!emailResult.success) { - logger.error('Failed to send invitation email', { + if (!emailResult.success) { + logger.error('Failed to send organization invitation email', { + email, + error: emailResult.error, + }) + failedInvitations.push({ + email, + error: emailResult.error || 'Unknown email delivery error', + }) + await cancelPendingInvitation(invitationId) + continue + } + + sentInvitations.push({ id: invitationId, email }) + } catch (creationError) { + logger.error('Failed to create organization invitation', { email, error: creationError }) + failedInvitations.push({ email, - error: emailResult.message, + error: + creationError instanceof Error ? creationError.message : 'Failed to create invitation', }) } } - logger.info('Organization invitations created', { - organizationId, - invitedBy: session.user.id, - invitationCount: invitationsToCreate.length, - emails: emailsToInvite, - role, - isBatch, - workspaceInvitationCount: workspaceInvitationIds.length, - }) - - for (const inv of invitationsToCreate) { + for (const inv of sentInvitations) { recordAudit({ workspaceId: null, actorId: session.user.id, @@ -421,163 +351,71 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ resourceId: organizationId, actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, - resourceName: organizationEntry[0]?.name, + resourceName: organizationEntry.name, description: `Invited ${inv.email} to organization as ${role}`, metadata: { invitationId: inv.id, targetEmail: inv.email, targetRole: role, isBatch, - workspaceInvitationCount: validWorkspaceInvitations.length, + workspaceGrantCount: validGrants.length, }, request, }) } - return NextResponse.json({ - success: true, - message: `${invitationsToCreate.length} invitation(s) sent successfully`, - data: { - invitationsSent: invitationsToCreate.length, - invitedEmails: emailsToInvite, - existingMembers: processedEmails.filter((email: string) => existingEmails.includes(email)), - pendingInvitations: processedEmails.filter((email: string) => - pendingEmails.includes(email) - ), - invalidEmails: invitationEmails.filter( - (email: string) => !quickValidateEmail(email.trim().toLowerCase()).isValid - ), - workspaceInvitations: isBatch ? validWorkspaceInvitations.length : 0, - seatInfo: { - seatsUsed: seatValidation.currentSeats + invitationsToCreate.length, - maxSeats: seatValidation.maxSeats, - availableSeats: seatValidation.availableSeats - invitationsToCreate.length, - }, + const sentEmails = sentInvitations.map((inv) => inv.email) + const responseData = { + invitationsSent: sentInvitations.length, + invitedEmails: sentEmails, + failedInvitations, + existingMembers: processedEmails.filter((email) => existingEmails.includes(email)), + pendingInvitations: processedEmails.filter((email) => pendingEmails.includes(email)), + invalidEmails: invitationEmails.filter( + (email: string) => !quickValidateEmail(email.trim().toLowerCase()).isValid + ), + workspaceGrantsPerInvite: validGrants.length, + seatInfo: { + seatsUsed: seatValidation.currentSeats + sentInvitations.length, + maxSeats: seatValidation.maxSeats, + availableSeats: seatValidation.availableSeats - sentInvitations.length, }, - }) - } catch (error) { - if (error instanceof InvitationsNotAllowedError) { - return NextResponse.json({ error: error.message }, { status: 403 }) } - logger.error('Failed to create organization invitations', { - organizationId: (await params).id, - error, - }) - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -/** - * DELETE /api/organizations/[id]/invitations?invitationId=... - * Cancel a pending invitation - */ -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: organizationId } = await params - const url = new URL(request.url) - const invitationId = url.searchParams.get('invitationId') - - if (!invitationId) { + if (failedInvitations.length > 0 && sentInvitations.length === 0) { return NextResponse.json( - { error: 'Invitation ID is required as query parameter' }, - { status: 400 } - ) - } - - const memberEntry = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) - - if (memberEntry.length === 0) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } + { + success: false, + error: 'Failed to send invitation emails.', + message: 'No invitation emails could be delivered.', + data: responseData, + }, + { status: 502 } ) } - if (!['owner', 'admin'].includes(memberEntry[0].role)) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } - - const result = await db - .update(invitation) - .set({ status: 'cancelled' }) - .where( - and( - eq(invitation.id, invitationId), - eq(invitation.organizationId, organizationId), - or(eq(invitation.status, 'pending'), eq(invitation.status, 'rejected')) - ) - ) - .returning() - - if (result.length === 0) { + if (failedInvitations.length > 0) { return NextResponse.json( - { error: 'Invitation not found or already processed' }, - { status: 404 } + { + success: false, + error: 'Some invitation emails failed to send.', + message: `${sentInvitations.length} invitation(s) sent, ${failedInvitations.length} failed`, + data: responseData, + }, + { status: 207 } ) } - await db - .update(workspaceInvitation) - .set({ status: 'cancelled' as WorkspaceInvitationStatus }) - .where(eq(workspaceInvitation.orgInvitationId, invitationId)) - - await db - .update(workspaceInvitation) - .set({ status: 'cancelled' as WorkspaceInvitationStatus }) - .where( - and( - isNull(workspaceInvitation.orgInvitationId), - eq(workspaceInvitation.email, result[0].email), - eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus), - eq(workspaceInvitation.inviterId, session.user.id) - ) - ) - - logger.info('Organization invitation cancelled', { - organizationId, - invitationId, - cancelledBy: session.user.id, - email: result[0].email, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.ORG_INVITATION_REVOKED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Revoked organization invitation for ${result[0].email}`, - metadata: { invitationId, targetEmail: result[0].email, targetRole: result[0].role }, - request, - }) - return NextResponse.json({ success: true, - message: 'Invitation cancelled successfully', + message: `${sentInvitations.length} invitation(s) sent successfully`, + data: responseData, }) } catch (error) { - logger.error('Failed to cancel organization invitation', { - organizationId: (await params).id, - error, - }) - + if (error instanceof InvitationsNotAllowedError) { + return NextResponse.json({ error: error.message }, { status: 403 }) + } + logger.error('Failed to create organization invitations', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts index 3d850d1f971..f7989367e50 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' +import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' import { getUserUsageData } from '@/lib/billing/core/usage' import { removeUserFromOrganization } from '@/lib/billing/organizations/membership' @@ -193,17 +194,16 @@ export async function PUT( return NextResponse.json({ error: 'Cannot change owner role' }, { status: 400 }) } - if (role === 'admin' && userMember[0].role !== 'owner') { + if (role === 'owner') { return NextResponse.json( - { error: 'Only owners can promote members to admin' }, - { status: 403 } + { + error: + 'Ownership transfer is not supported via this endpoint. Use POST /organizations/[id]/transfer-ownership instead.', + }, + { status: 400 } ) } - if (targetMember[0].role === 'admin' && userMember[0].role !== 'owner') { - return NextResponse.json({ error: 'Only owners can change admin roles' }, { status: 403 }) - } - const updatedMember = await db .update(member) .set({ role }) @@ -324,6 +324,18 @@ export async function DELETE( return NextResponse.json({ error: result.error }, { status: 500 }) } + if (session.user.id === targetUserId) { + try { + await setActiveOrganizationForCurrentSession(null) + } catch (clearError) { + logger.warn('Failed to clear active organization after self-removal', { + userId: session.user.id, + organizationId, + error: clearError, + }) + } + } + logger.info('Organization member removed', { organizationId, removedMemberId: targetUserId, diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 063699d12bc..b56456588b9 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -2,23 +2,27 @@ import { db } from '@sim/db' import { invitation, member, - organization, subscription as subscriptionTable, user, userStats, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { getEmailSubject, renderInvitationEmail } from '@/components/emails' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { sendEmail } from '@/lib/messaging/email/mailer' +import { + cancelPendingInvitation, + createPendingInvitation, + sendInvitationEmail, +} from '@/lib/invitations/send' import { quickValidateEmail } from '@/lib/messaging/email/validation' +import { + InvitationsNotAllowedError, + validateInvitationsAllowed, +} from '@/ee/access-control/utils/permission-check' const logger = createLogger('OrganizationMembersAPI') @@ -157,10 +161,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + await validateInvitationsAllowed(session.user.id) + const { id: organizationId } = await params const { email, role = 'member' } = await request.json() - // Validate input if (!email) { return NextResponse.json({ error: 'Email is required' }, { status: 400 }) } @@ -253,62 +258,53 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ) } - // Create invitation - const invitationId = generateId() - const expiresAt = new Date() - expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry - - await db.insert(invitation).values({ - id: invitationId, + const { invitationId, token } = await createPendingInvitation({ + kind: 'organization', email: normalizedEmail, inviterId: session.user.id, organizationId, - role, - status: 'pending', - expiresAt, - createdAt: new Date(), + role: role as 'admin' | 'member', + grants: [], }) - const organizationEntry = await db - .select({ name: organization.name }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) - - const inviter = await db - .select({ name: user.name }) + const [inviterRow] = await db + .select({ name: user.name, email: user.email }) .from(user) .where(eq(user.id, session.user.id)) .limit(1) + const inviterName = inviterRow?.name || inviterRow?.email || 'A user' - const emailHtml = await renderInvitationEmail( - inviter[0]?.name || 'Someone', - organizationEntry[0]?.name || 'organization', - `${getBaseUrl()}/invite/organization?id=${invitationId}` - ) - - const emailResult = await sendEmail({ - to: normalizedEmail, - subject: getEmailSubject('invitation'), - html: emailHtml, - emailType: 'transactional', + const emailResult = await sendInvitationEmail({ + invitationId, + token, + kind: 'organization', + email: normalizedEmail, + inviterName, + organizationId, + organizationRole: role as 'admin' | 'member', + grants: [], }) - if (emailResult.success) { - logger.info('Member invitation sent', { + if (!emailResult.success) { + logger.error('Failed to send organization invitation email', { email: normalizedEmail, - organizationId, invitationId, - role, + error: emailResult.error, }) - } else { - logger.error('Failed to send invitation email', { - email: normalizedEmail, - error: emailResult.message, - }) - // Don't fail the request if email fails + await cancelPendingInvitation(invitationId) + return NextResponse.json( + { error: emailResult.error || 'Failed to send invitation email' }, + { status: 502 } + ) } + logger.info('Member invitation sent', { + email: normalizedEmail, + organizationId, + invitationId, + role, + }) + recordAudit({ workspaceId: null, actorId: session.user.id, @@ -317,7 +313,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ resourceId: organizationId, actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, - resourceName: organizationEntry[0]?.name ?? undefined, description: `Invited ${normalizedEmail} to organization as ${role}`, metadata: { invitationId, targetEmail: normalizedEmail, targetRole: role }, request, @@ -330,10 +325,12 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ invitationId, email: normalizedEmail, role, - expiresAt, }, }) } catch (error) { + if (error instanceof InvitationsNotAllowedError) { + return NextResponse.json({ error: error.message }, { status: 403 }) + } logger.error('Failed to invite organization member', { organizationId: (await params).id, error, diff --git a/apps/sim/app/api/organizations/[id]/roster/route.ts b/apps/sim/app/api/organizations/[id]/roster/route.ts new file mode 100644 index 00000000000..a392ab50d0d --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/roster/route.ts @@ -0,0 +1,182 @@ +import { db } from '@sim/db' +import { + invitation, + invitationWorkspaceGrant, + member, + permissions, + user, + workspace, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { expireStalePendingInvitationsForOrganization } from '@/lib/invitations/core' + +const logger = createLogger('OrganizationRosterAPI') + +interface RosterWorkspaceAccess { + workspaceId: string + workspaceName: string + permission: 'admin' | 'write' | 'read' +} + +export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: organizationId } = await params + + const [callerMembership] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!callerMembership) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + + if (callerMembership.role !== 'owner' && callerMembership.role !== 'admin') { + return NextResponse.json( + { error: 'Forbidden - Organization admin access required' }, + { status: 403 } + ) + } + + await expireStalePendingInvitationsForOrganization(organizationId) + + const orgWorkspaces = await db + .select({ id: workspace.id, name: workspace.name }) + .from(workspace) + .where(eq(workspace.organizationId, organizationId)) + + const orgWorkspaceIds = orgWorkspaces.map((ws) => ws.id) + const workspaceNameById = new Map(orgWorkspaces.map((ws) => [ws.id, ws.name])) + + const memberRows = await db + .select({ + memberId: member.id, + userId: member.userId, + role: member.role, + createdAt: member.createdAt, + userName: user.name, + userEmail: user.email, + userImage: user.image, + }) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .where(eq(member.organizationId, organizationId)) + + const memberUserIds = memberRows.map((row) => row.userId) + + const memberPermissions = + memberUserIds.length > 0 && orgWorkspaceIds.length > 0 + ? await db + .select({ + userId: permissions.userId, + workspaceId: permissions.entityId, + permission: permissions.permissionType, + }) + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + inArray(permissions.userId, memberUserIds), + inArray(permissions.entityId, orgWorkspaceIds) + ) + ) + : [] + + const permissionsByUser = new Map() + for (const row of memberPermissions) { + const list = permissionsByUser.get(row.userId) ?? [] + list.push({ + workspaceId: row.workspaceId, + workspaceName: workspaceNameById.get(row.workspaceId) ?? 'Workspace', + permission: row.permission, + }) + permissionsByUser.set(row.userId, list) + } + + const members = memberRows.map((row) => ({ + memberId: row.memberId, + userId: row.userId, + role: row.role, + createdAt: row.createdAt, + name: row.userName, + email: row.userEmail, + image: row.userImage, + workspaces: permissionsByUser.get(row.userId) ?? [], + })) + + const pendingInvitationRows = await db + .select({ + id: invitation.id, + email: invitation.email, + role: invitation.role, + kind: invitation.kind, + createdAt: invitation.createdAt, + expiresAt: invitation.expiresAt, + inviteeName: user.name, + inviteeImage: user.image, + }) + .from(invitation) + .leftJoin(user, sql`lower(${user.email}) = lower(${invitation.email})`) + .where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending'))) + + const pendingInvitationIds = pendingInvitationRows.map((row) => row.id) + const pendingGrants = + pendingInvitationIds.length > 0 + ? await db + .select({ + invitationId: invitationWorkspaceGrant.invitationId, + workspaceId: invitationWorkspaceGrant.workspaceId, + permission: invitationWorkspaceGrant.permission, + }) + .from(invitationWorkspaceGrant) + .where(inArray(invitationWorkspaceGrant.invitationId, pendingInvitationIds)) + : [] + + const grantsByInvitation = new Map() + for (const row of pendingGrants) { + const list = grantsByInvitation.get(row.invitationId) ?? [] + list.push({ + workspaceId: row.workspaceId, + workspaceName: workspaceNameById.get(row.workspaceId) ?? 'Workspace', + permission: row.permission, + }) + grantsByInvitation.set(row.invitationId, list) + } + + const pendingInvitations = pendingInvitationRows.map((row) => ({ + id: row.id, + email: row.email, + role: row.role, + kind: row.kind, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + inviteeName: row.inviteeName, + inviteeImage: row.inviteeImage, + workspaces: grantsByInvitation.get(row.id) ?? [], + })) + + return NextResponse.json({ + success: true, + data: { + members, + pendingInvitations, + workspaces: orgWorkspaces, + }, + }) + } catch (error) { + logger.error('Failed to fetch organization roster', { error }) + return NextResponse.json({ error: 'Failed to fetch organization roster' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/organizations/[id]/seats/route.ts b/apps/sim/app/api/organizations/[id]/seats/route.ts index 3ea2b3f06ed..4e227b6c232 100644 --- a/apps/sim/app/api/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/organizations/[id]/seats/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' -import { member, organization, subscription } from '@sim/db/schema' +import { invitation, member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' +import { and, count, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' @@ -106,17 +106,27 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ ) } - // Validate that we're not reducing below current member count - const memberCount = await db - .select({ userId: member.userId }) + const [memberCountRow] = await db + .select({ count: count() }) .from(member) .where(eq(member.organizationId, organizationId)) - if (newSeatCount < memberCount.length) { + const [pendingCountRow] = await db + .select({ count: count() }) + .from(invitation) + .where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending'))) + + const memberCount = memberCountRow?.count ?? 0 + const pendingCount = pendingCountRow?.count ?? 0 + const occupiedSeats = memberCount + pendingCount + + if (newSeatCount < occupiedSeats) { return NextResponse.json( { - error: `Cannot reduce seats below current member count (${memberCount.length})`, - currentMembers: memberCount.length, + error: `Cannot reduce seats below current occupancy (${memberCount} member${memberCount === 1 ? '' : 's'} + ${pendingCount} pending invite${pendingCount === 1 ? '' : 's'}). Cancel pending invites first or remove members.`, + currentMembers: memberCount, + pendingInvitations: pendingCount, + occupiedSeats, }, { status: 400 } ) diff --git a/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts b/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts new file mode 100644 index 00000000000..44c2bf15701 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts @@ -0,0 +1,224 @@ +import { db } from '@sim/db' +import { member, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { getSession } from '@/lib/auth' +import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' +import { + removeUserFromOrganization, + transferOrganizationOwnership, +} from '@/lib/billing/organizations/membership' + +const logger = createLogger('TransferOwnershipAPI') + +const transferOwnershipSchema = z.object({ + newOwnerUserId: z.string().min(1), + alsoLeave: z.boolean().optional().default(false), +}) + +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: organizationId } = await params + const body = await request.json().catch(() => ({})) + const validation = transferOwnershipSchema.safeParse(body) + if (!validation.success) { + return NextResponse.json( + { error: validation.error.errors[0]?.message ?? 'Invalid request' }, + { status: 400 } + ) + } + + const { newOwnerUserId, alsoLeave } = validation.data + + if (newOwnerUserId === session.user.id) { + return NextResponse.json( + { error: 'New owner must differ from current owner' }, + { status: 400 } + ) + } + + const [currentOwnerMember] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!currentOwnerMember) { + return NextResponse.json( + { error: 'You are not a member of this organization' }, + { status: 403 } + ) + } + + if (currentOwnerMember.role !== 'owner') { + return NextResponse.json( + { error: 'Only the current owner can transfer ownership' }, + { status: 403 } + ) + } + + const [targetMember] = await db + .select({ + id: member.id, + role: member.role, + email: user.email, + name: user.name, + }) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, newOwnerUserId))) + .limit(1) + + if (!targetMember) { + return NextResponse.json( + { error: 'Target user is not a member of this organization' }, + { status: 400 } + ) + } + + const transferResult = await transferOrganizationOwnership({ + organizationId, + currentOwnerUserId: session.user.id, + newOwnerUserId, + }) + + if (!transferResult.success) { + return NextResponse.json( + { error: transferResult.error ?? 'Failed to transfer ownership' }, + { status: 500 } + ) + } + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + action: AuditAction.ORG_MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + description: `Transferred ownership to ${targetMember.email}`, + metadata: { + targetUserId: newOwnerUserId, + targetEmail: targetMember.email ?? undefined, + targetName: targetMember.name ?? undefined, + workspacesReassigned: transferResult.workspacesReassigned, + billedAccountReassigned: transferResult.billedAccountReassigned, + overageMigrated: transferResult.overageMigrated, + billingBlockInherited: transferResult.billingBlockInherited, + }, + request, + }) + + if (!alsoLeave) { + return NextResponse.json({ + success: true, + transferred: true, + left: false, + details: { + workspacesReassigned: transferResult.workspacesReassigned, + billedAccountReassigned: transferResult.billedAccountReassigned, + overageMigrated: transferResult.overageMigrated, + billingBlockInherited: transferResult.billingBlockInherited, + }, + }) + } + + const [selfMember] = await db + .select({ id: member.id }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!selfMember) { + return NextResponse.json({ + success: true, + transferred: true, + left: true, + details: { + workspacesReassigned: transferResult.workspacesReassigned, + billedAccountReassigned: transferResult.billedAccountReassigned, + overageMigrated: transferResult.overageMigrated, + billingBlockInherited: transferResult.billingBlockInherited, + }, + }) + } + + const removeResult = await removeUserFromOrganization({ + userId: session.user.id, + organizationId, + memberId: selfMember.id, + }) + + if (!removeResult.success) { + logger.error('Transfer succeeded but self-removal failed', { + organizationId, + userId: session.user.id, + error: removeResult.error, + }) + return NextResponse.json( + { + success: true, + transferred: true, + left: false, + warning: removeResult.error ?? 'Failed to leave after transfer', + }, + { status: 207 } + ) + } + + try { + await setActiveOrganizationForCurrentSession(null) + } catch (clearError) { + logger.warn('Failed to clear active organization after transfer-and-leave', { + userId: session.user.id, + organizationId, + error: clearError, + }) + } + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + action: AuditAction.ORG_MEMBER_REMOVED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + description: 'Left the organization after transferring ownership', + metadata: { + targetUserId: session.user.id, + wasSelfRemoval: true, + followedOwnershipTransfer: true, + }, + request, + }) + + return NextResponse.json({ + success: true, + transferred: true, + left: true, + details: { + workspacesReassigned: transferResult.workspacesReassigned, + billedAccountReassigned: transferResult.billedAccountReassigned, + overageMigrated: transferResult.overageMigrated, + billingBlockInherited: transferResult.billingBlockInherited, + billingActions: removeResult.billingActions, + }, + }) + } catch (error) { + logger.error('Failed to transfer organization ownership', { + organizationId: (await params).id, + error, + }) + return NextResponse.json({ error: 'Failed to transfer ownership' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/organizations/route.test.ts b/apps/sim/app/api/organizations/route.test.ts new file mode 100644 index 00000000000..c52185a0278 --- /dev/null +++ b/apps/sim/app/api/organizations/route.test.ts @@ -0,0 +1,258 @@ +/** + * @vitest-environment node + */ +import { auditMock, createSession, loggerMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockDbState, + mockGetSession, + mockSetActiveOrganizationForCurrentSession, + mockCreateOrganizationForTeamPlan, + mockEnsureOrganizationForTeamSubscription, + mockAttachOwnedWorkspacesToOrganization, + WorkspaceOrganizationMembershipConflictError, +} = vi.hoisted(() => ({ + mockDbState: { + selectResults: [] as any[], + }, + mockGetSession: vi.fn(), + mockSetActiveOrganizationForCurrentSession: vi.fn().mockResolvedValue(undefined), + mockCreateOrganizationForTeamPlan: vi.fn(), + mockEnsureOrganizationForTeamSubscription: vi.fn(), + mockAttachOwnedWorkspacesToOrganization: vi.fn().mockResolvedValue(undefined), + WorkspaceOrganizationMembershipConflictError: class WorkspaceOrganizationMembershipConflictError extends Error {}, +})) + +vi.mock('@sim/db', () => ({ + db: { + select: vi.fn().mockImplementation(() => { + const chain: any = {} + chain.from = vi.fn().mockReturnValue(chain) + chain.where = vi.fn().mockReturnValue(chain) + chain.limit = vi + .fn() + .mockImplementation(() => Promise.resolve(mockDbState.selectResults.shift() ?? [])) + chain.then = vi + .fn() + .mockImplementation((callback: (rows: any[]) => any) => + Promise.resolve(callback(mockDbState.selectResults.shift() ?? [])) + ) + return chain + }), + }, +})) + +vi.mock('@sim/db/schema', () => ({ + member: { + organizationId: 'member.organizationId', + role: 'member.role', + userId: 'member.userId', + }, + organization: { + id: 'organization.id', + name: 'organization.name', + }, + subscription: { + id: 'subscription.id', + plan: 'subscription.plan', + referenceId: 'subscription.referenceId', + status: 'subscription.status', + seats: 'subscription.seats', + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), + inArray: vi.fn((field: unknown, value: unknown[]) => ({ field, value })), + or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })), +})) + +vi.mock('@sim/logger', () => loggerMock) + +vi.mock('@/lib/audit/log', () => auditMock) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/auth/active-organization', () => ({ + setActiveOrganizationForCurrentSession: mockSetActiveOrganizationForCurrentSession, +})) + +vi.mock('@/lib/billing/organization', () => ({ + createOrganizationForTeamPlan: mockCreateOrganizationForTeamPlan, + ensureOrganizationForTeamSubscription: mockEnsureOrganizationForTeamSubscription, +})) + +vi.mock('@/lib/billing/organizations/create-organization', () => ({ + OrganizationSlugInvalidError: class OrganizationSlugInvalidError extends Error {}, + OrganizationSlugTakenError: class OrganizationSlugTakenError extends Error {}, +})) + +vi.mock('@/lib/billing/plan-helpers', () => ({ + isOrgPlan: (plan: string) => plan === 'team' || plan === 'enterprise', +})) + +vi.mock('@/lib/billing/subscriptions/utils', () => ({ + ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'trialing'], +})) + +vi.mock('@/lib/workspaces/organization-workspaces', () => ({ + attachOwnedWorkspacesToOrganization: mockAttachOwnedWorkspacesToOrganization, + WorkspaceOrganizationMembershipConflictError, +})) + +import { POST } from '@/app/api/organizations/route' + +describe('POST /api/organizations', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDbState.selectResults = [] + }) + + it('recovers an owner org when the subscription was already moved onto the organization', async () => { + mockGetSession.mockResolvedValue( + createSession({ + userId: 'user-1', + email: 'owner@example.com', + name: 'Owner', + }) + ) + mockDbState.selectResults = [ + [{ organizationId: 'legacy-org-id', role: 'owner' }], + [{ id: 'sub-1', plan: 'team', referenceId: 'legacy-org-id', status: 'active', seats: 5 }], + ] + + const response = await POST( + new Request('http://localhost/api/organizations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Recovered Org' }), + }) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + organizationId: 'legacy-org-id', + created: false, + }) + expect(mockAttachOwnedWorkspacesToOrganization).toHaveBeenCalledWith({ + ownerUserId: 'user-1', + organizationId: 'legacy-org-id', + }) + expect(mockCreateOrganizationForTeamPlan).not.toHaveBeenCalled() + expect(mockEnsureOrganizationForTeamSubscription).not.toHaveBeenCalled() + expect(mockSetActiveOrganizationForCurrentSession).toHaveBeenCalledWith('legacy-org-id') + expect(auditMock.recordAudit).not.toHaveBeenCalled() + }) + + it('recovers an owner org when the subscription is still linked to the user', async () => { + mockGetSession.mockResolvedValue( + createSession({ + userId: 'user-1', + email: 'owner@example.com', + name: 'Owner', + }) + ) + mockEnsureOrganizationForTeamSubscription.mockResolvedValue({ + id: 'sub-1', + plan: 'team', + referenceId: 'legacy-org-id', + status: 'active', + seats: 5, + }) + mockDbState.selectResults = [ + [{ organizationId: 'legacy-org-id', role: 'owner' }], + [{ id: 'sub-1', plan: 'team', referenceId: 'user-1', status: 'active', seats: 5 }], + ] + + const response = await POST( + new Request('http://localhost/api/organizations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Recovered Org' }), + }) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + organizationId: 'legacy-org-id', + created: false, + }) + expect(mockEnsureOrganizationForTeamSubscription).toHaveBeenCalledWith({ + id: 'sub-1', + plan: 'team', + referenceId: 'user-1', + status: 'active', + seats: 5, + }) + expect(mockAttachOwnedWorkspacesToOrganization).not.toHaveBeenCalled() + expect(mockCreateOrganizationForTeamPlan).not.toHaveBeenCalled() + }) + + it('still blocks users who are only members of another organization', async () => { + mockGetSession.mockResolvedValue( + createSession({ + userId: 'user-1', + email: 'member@example.com', + name: 'Member', + }) + ) + mockDbState.selectResults = [[{ organizationId: 'org-1', role: 'member' }]] + + const response = await POST( + new Request('http://localhost/api/organizations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Blocked Org' }), + }) + ) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toEqual({ + error: + 'You are already a member of an organization. Leave your current organization before creating a new one.', + }) + expect(mockEnsureOrganizationForTeamSubscription).not.toHaveBeenCalled() + expect(mockCreateOrganizationForTeamPlan).not.toHaveBeenCalled() + expect(mockAttachOwnedWorkspacesToOrganization).not.toHaveBeenCalled() + }) + + it('returns a conflict when existing shared workspace members block organization attachment', async () => { + mockGetSession.mockResolvedValue( + createSession({ + userId: 'user-1', + email: 'owner@example.com', + name: 'Owner', + }) + ) + mockDbState.selectResults = [ + [{ organizationId: 'legacy-org-id', role: 'owner' }], + [{ id: 'sub-1', plan: 'team', referenceId: 'legacy-org-id', status: 'active', seats: 5 }], + ] + mockAttachOwnedWorkspacesToOrganization.mockRejectedValueOnce( + new WorkspaceOrganizationMembershipConflictError([ + { userId: 'user-2', organizationId: 'org-2' }, + ]) + ) + + const response = await POST( + new Request('http://localhost/api/organizations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Recovered Org' }), + }) + ) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toEqual({ + error: + 'One or more members of your existing shared workspaces already belong to another organization. Remove them from those workspaces before converting them to organization-owned workspaces.', + }) + expect(mockSetActiveOrganizationForCurrentSession).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/organizations/route.ts b/apps/sim/app/api/organizations/route.ts index 6185f120f46..6bbcc31ab88 100644 --- a/apps/sim/app/api/organizations/route.ts +++ b/apps/sim/app/api/organizations/route.ts @@ -1,11 +1,25 @@ import { db } from '@sim/db' -import { member, organization } from '@sim/db/schema' +import { member, organization, subscription as subscriptionTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, or } from 'drizzle-orm' +import { and, eq, inArray, or } from 'drizzle-orm' import { NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' -import { createOrganizationForTeamPlan } from '@/lib/billing/organization' +import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' +import { + createOrganizationForTeamPlan, + ensureOrganizationForTeamSubscription, +} from '@/lib/billing/organization' +import { + OrganizationSlugInvalidError, + OrganizationSlugTakenError, +} from '@/lib/billing/organizations/create-organization' +import { isOrgPlan } from '@/lib/billing/plan-helpers' +import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import { + attachOwnedWorkspacesToOrganization, + WorkspaceOrganizationMembershipConflictError, +} from '@/lib/workspaces/organization-workspaces' const logger = createLogger('OrganizationsAPI') @@ -78,22 +92,21 @@ export async function POST(request: Request) { // If no body or invalid JSON, use defaults } - logger.info('Creating organization for team plan', { - userId: user.id, - userName: user.name, - userEmail: user.email, - organizationName, - organizationSlug, - }) - - // Enforce: a user can only belong to one organization at a time const existingOrgMembership = await db - .select({ id: member.id }) + .select({ + organizationId: member.organizationId, + role: member.role, + }) .from(member) .where(eq(member.userId, user.id)) .limit(1) - if (existingOrgMembership.length > 0) { + const existingAdminMembership = + existingOrgMembership.length > 0 && ['owner', 'admin'].includes(existingOrgMembership[0].role) + ? existingOrgMembership[0] + : null + + if (existingOrgMembership.length > 0 && !existingAdminMembership) { return NextResponse.json( { error: @@ -103,38 +116,166 @@ export async function POST(request: Request) { ) } - // Create organization and make user the owner/admin - const organizationId = await createOrganizationForTeamPlan( - user.id, - organizationName || undefined, - user.email, - organizationSlug - ) + const subscriptionReferenceIds = existingAdminMembership + ? [user.id, existingAdminMembership.organizationId] + : [user.id] + + const activeOrgSubscriptions = await db + .select({ + id: subscriptionTable.id, + plan: subscriptionTable.plan, + referenceId: subscriptionTable.referenceId, + status: subscriptionTable.status, + seats: subscriptionTable.seats, + }) + .from(subscriptionTable) + .where( + and( + inArray(subscriptionTable.referenceId, subscriptionReferenceIds), + inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES) + ) + ) + + const activeOrgSubscription = + (existingAdminMembership + ? activeOrgSubscriptions.find( + (subscription) => + isOrgPlan(subscription.plan) && + subscription.referenceId === existingAdminMembership.organizationId + ) + : undefined) ?? + activeOrgSubscriptions.find( + (subscription) => isOrgPlan(subscription.plan) && subscription.referenceId === user.id + ) ?? + activeOrgSubscriptions.find((subscription) => isOrgPlan(subscription.plan)) + + if (!activeOrgSubscription) { + return NextResponse.json( + { error: 'Organization creation requires an active Team or Enterprise subscription.' }, + { status: 403 } + ) + } - logger.info('Successfully created organization for team plan', { + logger.info('Creating organization for team plan', { userId: user.id, - organizationId, + userName: user.name, + userEmail: user.email, + organizationName, + organizationSlug, + existingOrganizationId: existingAdminMembership?.organizationId ?? null, + subscriptionReferenceId: activeOrgSubscription.referenceId, }) - recordAudit({ - workspaceId: null, - actorId: user.id, - action: AuditAction.ORGANIZATION_CREATED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: user.name ?? undefined, - actorEmail: user.email ?? undefined, - resourceName: organizationName ?? undefined, - description: `Created organization "${organizationName}"`, - metadata: { organizationSlug }, - request, + let organizationId: string + let createdOrganization = false + + if (existingAdminMembership) { + organizationId = existingAdminMembership.organizationId + + if (activeOrgSubscription.referenceId === organizationId) { + await attachOwnedWorkspacesToOrganization({ + ownerUserId: user.id, + organizationId, + }) + } else { + const resolvedSubscription = + await ensureOrganizationForTeamSubscription(activeOrgSubscription) + + if (resolvedSubscription.referenceId !== organizationId) { + logger.error('Recovered organization did not match existing owner/admin membership', { + userId: user.id, + expectedOrganizationId: organizationId, + resolvedReferenceId: resolvedSubscription.referenceId, + subscriptionId: activeOrgSubscription.id, + }) + throw new Error('Organization recovery resolved to an unexpected subscription owner') + } + } + } else { + createdOrganization = true + organizationId = await createOrganizationForTeamPlan( + user.id, + organizationName || undefined, + user.email, + organizationSlug + ) + + const resolvedSubscription = + await ensureOrganizationForTeamSubscription(activeOrgSubscription) + + if (resolvedSubscription.referenceId !== organizationId) { + logger.error('Newly created organization was not attached to the active subscription', { + userId: user.id, + expectedOrganizationId: organizationId, + resolvedReferenceId: resolvedSubscription.referenceId, + subscriptionId: activeOrgSubscription.id, + }) + throw new Error('Failed to link the new organization to the active subscription') + } + } + + try { + await setActiveOrganizationForCurrentSession(organizationId) + } catch (error) { + logger.error('Failed to activate organization after creation', { + organizationId, + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }) + } + + logger.info('Successfully ensured organization for team plan', { + userId: user.id, + organizationId, + createdOrganization, }) + if (createdOrganization) { + recordAudit({ + workspaceId: null, + actorId: user.id, + action: AuditAction.ORGANIZATION_CREATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: user.name ?? undefined, + actorEmail: user.email ?? undefined, + resourceName: organizationName ?? undefined, + description: `Created organization "${organizationName}"`, + metadata: { organizationSlug }, + request, + }) + } + return NextResponse.json({ success: true, organizationId, + created: createdOrganization, }) } catch (error) { + if (error instanceof OrganizationSlugInvalidError) { + return NextResponse.json( + { + error: + 'Organization slug can only contain lowercase letters, numbers, hyphens, and underscores.', + }, + { status: 400 } + ) + } + + if (error instanceof OrganizationSlugTakenError) { + return NextResponse.json({ error: 'This slug is already taken' }, { status: 400 }) + } + + if (error instanceof WorkspaceOrganizationMembershipConflictError) { + return NextResponse.json( + { + error: + 'One or more members of your existing shared workspaces already belong to another organization. Remove them from those workspaces before converting them to organization-owned workspaces.', + }, + { status: 409 } + ) + } + logger.error('Failed to create organization for team plan', { error: error instanceof Error ? error.message : 'Unknown error', stack: error instanceof Error ? error.stack : undefined, diff --git a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts new file mode 100644 index 00000000000..43182d88d2d --- /dev/null +++ b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts @@ -0,0 +1,185 @@ +/** + * @vitest-environment node + */ +import { createSession, loggerMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDbState, mockGetSession, mockHasPaidSubscription } = vi.hoisted(() => ({ + mockDbState: { + selectResults: [] as any[], + updateCalls: [] as Array<{ table: unknown; values: Record }>, + }, + mockGetSession: vi.fn(), + mockHasPaidSubscription: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: vi.fn().mockImplementation(() => { + const chain: any = {} + chain.from = vi.fn().mockReturnValue(chain) + chain.where = vi.fn().mockReturnValue(chain) + chain.then = vi + .fn() + .mockImplementation((callback: (rows: any[]) => any) => + Promise.resolve(callback(mockDbState.selectResults.shift() ?? [])) + ) + return chain + }), + update: vi.fn().mockImplementation((table: unknown) => ({ + set: vi.fn().mockImplementation((values: Record) => { + mockDbState.updateCalls.push({ table, values }) + return { + where: vi.fn().mockResolvedValue(undefined), + } + }), + })), + }, +})) + +vi.mock('@sim/db/schema', () => ({ + member: { + userId: 'member.userId', + organizationId: 'member.organizationId', + }, + organization: { + id: 'organization.id', + }, + subscription: { + id: 'subscription.id', + referenceId: 'subscription.referenceId', + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), +})) + +vi.mock('@sim/logger', () => loggerMock) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/billing', () => ({ + hasPaidSubscription: mockHasPaidSubscription, +})) + +vi.mock('@/lib/billing/plan-helpers', () => ({ + isOrgPlan: (plan: string) => plan === 'team' || plan === 'enterprise', +})) + +vi.mock('@/lib/billing/subscriptions/utils', () => ({ + hasPaidSubscriptionStatus: (status: string) => status === 'active' || status === 'past_due', +})) + +import { POST } from '@/app/api/users/me/subscription/[id]/transfer/route' + +describe('POST /api/users/me/subscription/[id]/transfer', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDbState.selectResults = [] + mockDbState.updateCalls = [] + mockHasPaidSubscription.mockResolvedValue(false) + }) + + it('rejects transfers for non-organization subscriptions', async () => { + mockGetSession.mockResolvedValue( + createSession({ + userId: 'user-1', + email: 'owner@example.com', + name: 'Owner', + }) + ) + mockDbState.selectResults = [ + [{ id: 'sub-1', referenceId: 'user-1', plan: 'pro', status: 'active' }], + ] + + const response = await POST( + new Request('http://localhost/api/users/me/subscription/sub-1/transfer', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ organizationId: 'org-1' }), + }) as any, + { params: Promise.resolve({ id: 'sub-1' }) } + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: 'Only active Team or Enterprise subscriptions can be transferred to an organization.', + }) + expect(mockDbState.updateCalls).toEqual([]) + }) + + it('transfers an active organization subscription to an admin-owned organization', async () => { + mockGetSession.mockResolvedValue( + createSession({ + userId: 'user-1', + email: 'owner@example.com', + name: 'Owner', + }) + ) + mockDbState.selectResults = [ + [{ id: 'sub-1', referenceId: 'user-1', plan: 'team', status: 'active' }], + [{ id: 'org-1' }], + [{ id: 'member-1', role: 'owner' }], + ] + + const response = await POST( + new Request('http://localhost/api/users/me/subscription/sub-1/transfer', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ organizationId: 'org-1' }), + }) as any, + { params: Promise.resolve({ id: 'sub-1' }) } + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + message: 'Subscription transferred successfully', + }) + expect(mockDbState.updateCalls).toEqual([ + { + table: expect.objectContaining({ + id: 'subscription.id', + referenceId: 'subscription.referenceId', + }), + values: { referenceId: 'org-1' }, + }, + ]) + }) + + it('treats an already-transferred organization subscription as a successful no-op', async () => { + mockGetSession.mockResolvedValue( + createSession({ + userId: 'user-1', + email: 'owner@example.com', + name: 'Owner', + }) + ) + mockDbState.selectResults = [ + [{ id: 'sub-1', referenceId: 'org-1', plan: 'team', status: 'active' }], + [{ id: 'org-1' }], + [{ id: 'member-1', role: 'owner' }], + ] + + const response = await POST( + new Request('http://localhost/api/users/me/subscription/sub-1/transfer', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ organizationId: 'org-1' }), + }) as any, + { params: Promise.resolve({ id: 'sub-1' }) } + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + message: 'Subscription already belongs to this organization', + }) + expect(mockDbState.updateCalls).toEqual([]) + expect(mockHasPaidSubscription).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts index a9e977d9633..df3dddd1af7 100644 --- a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts +++ b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts @@ -7,6 +7,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { hasPaidSubscription } from '@/lib/billing' +import { isOrgPlan } from '@/lib/billing/plan-helpers' +import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils' const logger = createLogger('SubscriptionTransferAPI') @@ -60,10 +62,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Subscription not found' }, { status: 404 }) } - if (sub.referenceId !== session.user.id) { + if (!isOrgPlan(sub.plan) || !hasPaidSubscriptionStatus(sub.status)) { return NextResponse.json( - { error: 'Unauthorized - subscription does not belong to user' }, - { status: 403 } + { + error: + 'Only active Team or Enterprise subscriptions can be transferred to an organization.', + }, + { status: 400 } ) } @@ -90,6 +95,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ) } + if (sub.referenceId === organizationId) { + return NextResponse.json({ + success: true, + message: 'Subscription already belongs to this organization', + }) + } + + if (sub.referenceId !== session.user.id) { + return NextResponse.json( + { error: 'Unauthorized - subscription does not belong to user' }, + { status: 403 } + ) + } + // Check if org already has an active subscription (prevent duplicates) if (await hasPaidSubscription(organizationId)) { return NextResponse.json( 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 1da561752ad..1630a5aeca5 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 @@ -169,6 +169,12 @@ export const POST = withAdminAuthParams(async (request, context) => if (existingMember) { if (existingMember.organizationId === organizationId) { + if (existingMember.role === 'owner') { + return badRequestResponse( + 'Cannot change the owner role via this endpoint. Use POST /api/v1/admin/organizations/[id]/transfer-ownership instead.' + ) + } + if (existingMember.role !== body.role) { await db.update(member).set({ role: body.role }).where(eq(member.id, existingMember.id)) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts index 5542d8b3131..186163005b2 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -20,6 +20,12 @@ import { db } from '@sim/db' import { member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq, inArray } from 'drizzle-orm' +import { + ensureOrganizationSlugAvailable, + OrganizationSlugInvalidError, + OrganizationSlugTakenError, + validateOrganizationSlugOrThrow, +} from '@/lib/billing/organizations/create-organization' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { @@ -114,7 +120,13 @@ export const PATCH = withAdminAuthParams(async (request, context) = if (typeof body.slug !== 'string' || body.slug.trim().length === 0) { return badRequestResponse('slug must be a non-empty string') } - updateData.slug = body.slug.trim() + const nextSlug = body.slug.trim() + validateOrganizationSlugOrThrow(nextSlug) + await ensureOrganizationSlugAvailable({ + slug: nextSlug, + excludeOrganizationId: organizationId, + }) + updateData.slug = nextSlug } if (Object.keys(updateData).length === 1) { @@ -135,6 +147,16 @@ export const PATCH = withAdminAuthParams(async (request, context) = return singleResponse(toAdminOrganization(updated)) } catch (error) { + if (error instanceof OrganizationSlugInvalidError) { + return badRequestResponse( + 'Organization slug can only contain lowercase letters, numbers, hyphens, and underscores.' + ) + } + + if (error instanceof OrganizationSlugTakenError) { + return badRequestResponse('This slug is already taken') + } + logger.error('Admin API: Failed to update organization', { error, organizationId }) return internalErrorResponse('Failed to update organization') } diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/transfer-ownership/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/transfer-ownership/route.ts new file mode 100644 index 00000000000..146564871a9 --- /dev/null +++ b/apps/sim/app/api/v1/admin/organizations/[id]/transfer-ownership/route.ts @@ -0,0 +1,123 @@ +import { db } from '@sim/db' +import { member, organization, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { transferOrganizationOwnership } from '@/lib/billing/organizations/membership' +import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' +import { + badRequestResponse, + internalErrorResponse, + notFoundResponse, + singleResponse, +} from '@/app/api/v1/admin/responses' + +const logger = createLogger('AdminTransferOwnershipAPI') + +interface RouteParams { + id: string +} + +export const POST = withAdminAuthParams(async (request, context) => { + const { id: organizationId } = await context.params + + try { + const body = await request.json().catch(() => null) + const newOwnerUserId: unknown = body?.newOwnerUserId + const currentOwnerUserIdOverride: unknown = body?.currentOwnerUserId + + if (typeof newOwnerUserId !== 'string' || newOwnerUserId.length === 0) { + return badRequestResponse('newOwnerUserId is required') + } + + if ( + currentOwnerUserIdOverride !== undefined && + (typeof currentOwnerUserIdOverride !== 'string' || currentOwnerUserIdOverride.length === 0) + ) { + return badRequestResponse('currentOwnerUserId must be a non-empty string when provided') + } + + const [orgRow] = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + if (!orgRow) { + return notFoundResponse('Organization') + } + + let currentOwnerUserId: string + if (typeof currentOwnerUserIdOverride === 'string') { + currentOwnerUserId = currentOwnerUserIdOverride + } else { + const [ownerMembership] = await db + .select({ userId: member.userId }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner'))) + .limit(1) + + if (!ownerMembership) { + return badRequestResponse( + 'Organization has no owner; provide currentOwnerUserId explicitly to seed ownership' + ) + } + + currentOwnerUserId = ownerMembership.userId + } + + if (currentOwnerUserId === newOwnerUserId) { + return badRequestResponse('New owner must differ from current owner') + } + + const [newOwnerMember] = await db + .select({ + id: member.id, + role: member.role, + email: user.email, + name: user.name, + }) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, newOwnerUserId))) + .limit(1) + + if (!newOwnerMember) { + return badRequestResponse('Target user is not a member of this organization') + } + + const result = await transferOrganizationOwnership({ + organizationId, + currentOwnerUserId, + newOwnerUserId, + }) + + if (!result.success) { + return internalErrorResponse(result.error ?? 'Failed to transfer ownership') + } + + logger.info(`Admin API: Transferred ownership of organization ${organizationId}`, { + currentOwnerUserId, + newOwnerUserId, + workspacesReassigned: result.workspacesReassigned, + billedAccountReassigned: result.billedAccountReassigned, + overageMigrated: result.overageMigrated, + billingBlockInherited: result.billingBlockInherited, + }) + + return singleResponse({ + organizationId, + currentOwnerUserId, + newOwnerUserId, + workspacesReassigned: result.workspacesReassigned, + billedAccountReassigned: result.billedAccountReassigned, + overageMigrated: result.overageMigrated, + billingBlockInherited: result.billingBlockInherited, + }) + } catch (error) { + logger.error('Admin API: Failed to transfer organization ownership', { + organizationId, + error, + }) + return internalErrorResponse('Failed to transfer ownership') + } +}) diff --git a/apps/sim/app/api/v1/admin/organizations/route.ts b/apps/sim/app/api/v1/admin/organizations/route.ts index 0a87080fdf9..6960986aff9 100644 --- a/apps/sim/app/api/v1/admin/organizations/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/route.ts @@ -24,8 +24,12 @@ import { db } from '@sim/db' import { member, organization, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' import { count, eq } from 'drizzle-orm' +import { + createOrganizationWithOwner, + OrganizationSlugInvalidError, + OrganizationSlugTakenError, +} from '@/lib/billing/organizations/create-organization' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -108,26 +112,10 @@ export const POST = withAdminAuth(async (request) => { .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') - const organizationId = generateId() - const memberId = generateId() - const now = new Date() - - await db.transaction(async (tx) => { - await tx.insert(organization).values({ - id: organizationId, - name, - slug, - createdAt: now, - updatedAt: now, - }) - - await tx.insert(member).values({ - id: memberId, - userId: body.ownerId, - organizationId, - role: 'owner', - createdAt: now, - }) + const { organizationId, memberId } = await createOrganizationWithOwner({ + ownerUserId: body.ownerId, + name, + slug, }) const [createdOrg] = await db @@ -148,6 +136,16 @@ export const POST = withAdminAuth(async (request) => { memberId, }) } catch (error) { + if (error instanceof OrganizationSlugInvalidError) { + return badRequestResponse( + 'Organization slug can only contain lowercase letters, numbers, hyphens, and underscores.' + ) + } + + if (error instanceof OrganizationSlugTakenError) { + return badRequestResponse('This slug is already taken') + } + logger.error('Admin API: Failed to create organization', { error }) return internalErrorResponse('Failed to create organization') } diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index 5254ddb1cc5..662e3af48e4 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -167,6 +167,16 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< } if (billedAccountUserId !== undefined) { + if (existingWorkspace.organizationId && existingWorkspace.workspaceMode === 'organization') { + return NextResponse.json( + { + error: + 'Organization workspaces use organization billing and cannot change billed account.', + }, + { status: 400 } + ) + } + const candidateId = billedAccountUserId const isOwner = candidateId === existingWorkspace.ownerId diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts deleted file mode 100644 index 6c6262769c4..00000000000 --- a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts +++ /dev/null @@ -1,625 +0,0 @@ -import { - auditMock, - authMockFns, - createSession, - createWorkspaceRecord, - permissionsMock, - permissionsMockFns, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mockHasWorkspaceAdminAccess = permissionsMockFns.mockHasWorkspaceAdminAccess -const mockGetWorkspaceById = permissionsMockFns.mockGetWorkspaceById - -let dbSelectResults: any[] = [] -let dbSelectCallIndex = 0 - -const mockDbSelect = vi.fn().mockImplementation(() => { - const makeThen = () => - vi.fn().mockImplementation((callback: (rows: any[]) => any) => { - const result = dbSelectResults[dbSelectCallIndex] || [] - dbSelectCallIndex++ - return Promise.resolve(callback ? callback(result) : result) - }) - const makeLimit = () => - vi.fn().mockImplementation(() => { - const result = dbSelectResults[dbSelectCallIndex] || [] - dbSelectCallIndex++ - return Promise.resolve(result) - }) - - const chain: any = {} - chain.from = vi.fn().mockReturnValue(chain) - chain.where = vi.fn().mockReturnValue(chain) - chain.limit = makeLimit() - chain.then = makeThen() - return chain -}) - -const mockDbInsert = vi.fn().mockImplementation(() => ({ - values: vi.fn().mockResolvedValue(undefined), -})) - -const mockDbUpdate = vi.fn().mockImplementation(() => ({ - set: vi.fn().mockReturnThis(), - where: vi.fn().mockResolvedValue(undefined), -})) - -const mockDbDelete = vi.fn().mockImplementation(() => ({ - where: vi.fn().mockResolvedValue(undefined), -})) - -const mockDbTransaction = vi.fn().mockImplementation(async (callback: any) => { - await callback({ - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockResolvedValue(undefined), - }), - update: vi.fn().mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(undefined), - }), - }), - }) -}) - -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) - -vi.mock('@/lib/credentials/environment', () => ({ - syncWorkspaceEnvCredentials: vi.fn().mockResolvedValue(undefined), -})) - -vi.mock('@/lib/audit/log', () => auditMock) - -vi.mock('@/lib/core/utils/urls', () => ({ - getBaseUrl: vi.fn().mockReturnValue('https://test.sim.ai'), -})) - -vi.mock('@/components/emails', () => ({ - WorkspaceInvitationEmail: vi.fn().mockReturnValue(null), -})) - -vi.mock('@/lib/messaging/email/mailer', () => ({ - sendEmail: vi.fn().mockResolvedValue({ success: true }), -})) - -vi.mock('@/lib/messaging/email/utils', () => ({ - getFromEmailAddress: vi.fn().mockReturnValue('noreply@test.com'), -})) - -vi.mock('@react-email/render', () => ({ - render: vi.fn().mockResolvedValue(''), -})) - -vi.mock('@sim/db', () => ({ - db: { - select: () => mockDbSelect(), - insert: (table: any) => mockDbInsert(table), - update: (table: any) => mockDbUpdate(table), - delete: (table: any) => mockDbDelete(table), - transaction: (callback: any) => mockDbTransaction(callback), - }, -})) - -vi.mock('drizzle-orm', () => ({ - eq: vi.fn((a: unknown, b: unknown) => ({ type: 'eq', a, b })), - and: vi.fn((...args: unknown[]) => ({ type: 'and', args })), - isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), -})) - -vi.mock('@sim/utils/id', () => ({ - generateId: vi.fn().mockReturnValue('mock-uuid-1234'), - generateShortId: vi.fn(() => 'mock-short-id'), - isValidUuid: vi.fn((v: string) => - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v) - ), -})) - -import { DELETE, GET } from './route' - -const mockUser = { - id: 'user-123', - email: 'test@example.com', - name: 'Test User', -} - -const mockWorkspaceData = createWorkspaceRecord({ - id: 'workspace-456', - name: 'Test Workspace', -}) - -const mockWorkspace = { - id: mockWorkspaceData.id, - name: mockWorkspaceData.name, -} - -const mockInvitation = { - id: 'invitation-789', - workspaceId: 'workspace-456', - email: 'invited@example.com', - inviterId: 'inviter-321', - status: 'pending', - token: 'token-abc123', - permissions: 'read', - expiresAt: new Date(Date.now() + 86400000), - createdAt: new Date(), - updatedAt: new Date(), -} - -describe('Workspace Invitation [invitationId] API Route', () => { - beforeEach(() => { - vi.clearAllMocks() - dbSelectResults = [] - dbSelectCallIndex = 0 - mockGetWorkspaceById.mockResolvedValue({ id: 'workspace-456', name: 'Test Workspace' }) - }) - - describe('GET /api/workspaces/invitations/[invitationId]', () => { - it('should return invitation details when caller is the invitee', async () => { - const session = createSession({ userId: mockUser.id, email: 'invited@example.com' }) - authMockFns.mockGetSession.mockResolvedValue(session) - mockHasWorkspaceAdminAccess.mockResolvedValue(false) - dbSelectResults = [[mockInvitation], [mockWorkspace]] - - const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789') - const params = Promise.resolve({ invitationId: 'invitation-789' }) - - const response = await GET(request, { params }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data).toMatchObject({ - id: 'invitation-789', - email: 'invited@example.com', - status: 'pending', - workspaceName: 'Test Workspace', - }) - }) - - it('should return invitation details when caller is a workspace admin', async () => { - const session = createSession({ userId: mockUser.id, email: mockUser.email }) - authMockFns.mockGetSession.mockResolvedValue(session) - mockHasWorkspaceAdminAccess.mockResolvedValue(true) - dbSelectResults = [[mockInvitation], [mockWorkspace]] - - const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789') - const params = Promise.resolve({ invitationId: 'invitation-789' }) - - const response = await GET(request, { params }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data).toMatchObject({ - id: 'invitation-789', - email: 'invited@example.com', - status: 'pending', - workspaceName: 'Test Workspace', - }) - }) - - it('should return 403 when caller is neither invitee nor workspace admin', async () => { - const session = createSession({ userId: mockUser.id, email: 'unrelated@example.com' }) - authMockFns.mockGetSession.mockResolvedValue(session) - mockHasWorkspaceAdminAccess.mockResolvedValue(false) - dbSelectResults = [[mockInvitation], [mockWorkspace]] - - const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789') - const params = Promise.resolve({ invitationId: 'invitation-789' }) - - const response = await GET(request, { params }) - const data = await response.json() - - expect(response.status).toBe(403) - expect(data).toEqual({ error: 'Insufficient permissions' }) - }) - - it('should redirect to login when unauthenticated with token', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const request = new NextRequest( - 'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123' - ) - const params = Promise.resolve({ invitationId: 'token-abc123' }) - - const response = await GET(request, { params }) - - expect(response.status).toBe(307) - expect(response.headers.get('location')).toBe( - 'https://test.sim.ai/invite/token-abc123?token=token-abc123' - ) - }) - - it('should return 401 when unauthenticated without token', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789') - const params = Promise.resolve({ invitationId: 'invitation-789' }) - - const response = await GET(request, { params }) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data).toEqual({ error: 'Unauthorized' }) - }) - - it('should accept invitation when called with valid token', async () => { - const session = createSession({ - userId: mockUser.id, - email: 'invited@example.com', - name: mockUser.name, - }) - authMockFns.mockGetSession.mockResolvedValue(session) - - dbSelectResults = [ - [mockInvitation], - [mockWorkspace], - [{ ...mockUser, email: 'invited@example.com' }], - [], - [], - ] - - const request = new NextRequest( - 'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123' - ) - const params = Promise.resolve({ invitationId: 'token-abc123' }) - - const response = await GET(request, { params }) - - expect(response.status).toBe(307) - expect(response.headers.get('location')).toBe( - 'https://test.sim.ai/workspace/workspace-456/home' - ) - }) - - it('should redirect to error page with token preserved when invitation expired', async () => { - const session = createSession({ - userId: mockUser.id, - email: 'invited@example.com', - name: mockUser.name, - }) - authMockFns.mockGetSession.mockResolvedValue(session) - - const expiredInvitation = { - ...mockInvitation, - expiresAt: new Date(Date.now() - 86400000), - } - - dbSelectResults = [[expiredInvitation], [mockWorkspace]] - - const request = new NextRequest( - 'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123' - ) - const params = Promise.resolve({ invitationId: 'token-abc123' }) - - const response = await GET(request, { params }) - - expect(response.status).toBe(307) - const location = response.headers.get('location') - expect(location).toBe( - 'https://test.sim.ai/invite/invitation-789?error=expired&token=token-abc123' - ) - }) - - it('should redirect to error page with token preserved when email mismatch', async () => { - const session = createSession({ - userId: mockUser.id, - email: 'wrong@example.com', - name: mockUser.name, - }) - authMockFns.mockGetSession.mockResolvedValue(session) - - dbSelectResults = [ - [mockInvitation], - [mockWorkspace], - [{ ...mockUser, email: 'wrong@example.com' }], - ] - - const request = new NextRequest( - 'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123' - ) - const params = Promise.resolve({ invitationId: 'token-abc123' }) - - const response = await GET(request, { params }) - - expect(response.status).toBe(307) - const location = response.headers.get('location') - expect(location).toBe( - 'https://test.sim.ai/invite/invitation-789?error=email-mismatch&token=token-abc123' - ) - }) - - it('should return 404 when invitation not found (without token)', async () => { - const session = createSession({ userId: mockUser.id, email: mockUser.email }) - authMockFns.mockGetSession.mockResolvedValue(session) - dbSelectResults = [[]] - - const request = new NextRequest('http://localhost/api/workspaces/invitations/non-existent') - const params = Promise.resolve({ invitationId: 'non-existent' }) - - const response = await GET(request, { params }) - const data = await response.json() - - expect(response.status).toBe(404) - expect(data).toEqual({ error: 'Invitation not found or has expired' }) - }) - - it('should redirect to error page with token preserved when invitation not found (with token)', async () => { - const session = createSession({ userId: mockUser.id, email: mockUser.email }) - authMockFns.mockGetSession.mockResolvedValue(session) - dbSelectResults = [[]] - - const request = new NextRequest( - 'http://localhost/api/workspaces/invitations/non-existent?token=some-invalid-token' - ) - const params = Promise.resolve({ invitationId: 'non-existent' }) - - const response = await GET(request, { params }) - - expect(response.status).toBe(307) - const location = response.headers.get('location') - expect(location).toBe( - 'https://test.sim.ai/invite/non-existent?error=invalid-token&token=some-invalid-token' - ) - }) - - it('should redirect to error page with token preserved when invitation already processed', async () => { - const session = createSession({ - userId: mockUser.id, - email: 'invited@example.com', - name: mockUser.name, - }) - authMockFns.mockGetSession.mockResolvedValue(session) - - const acceptedInvitation = { - ...mockInvitation, - status: 'accepted', - } - - dbSelectResults = [[acceptedInvitation], [mockWorkspace]] - - const request = new NextRequest( - 'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123' - ) - const params = Promise.resolve({ invitationId: 'token-abc123' }) - - const response = await GET(request, { params }) - - expect(response.status).toBe(307) - const location = response.headers.get('location') - expect(location).toBe( - 'https://test.sim.ai/invite/invitation-789?error=already-processed&token=token-abc123' - ) - }) - - it('should redirect to error page with token preserved when workspace not found', async () => { - const session = createSession({ - userId: mockUser.id, - email: 'invited@example.com', - name: mockUser.name, - }) - authMockFns.mockGetSession.mockResolvedValue(session) - - dbSelectResults = [[mockInvitation], []] - - const request = new NextRequest( - 'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123' - ) - const params = Promise.resolve({ invitationId: 'token-abc123' }) - - const response = await GET(request, { params }) - - expect(response.status).toBe(307) - const location = response.headers.get('location') - expect(location).toBe( - 'https://test.sim.ai/invite/invitation-789?error=workspace-not-found&token=token-abc123' - ) - }) - - it('should redirect to error page with token preserved when user not found', async () => { - const session = createSession({ - userId: mockUser.id, - email: 'invited@example.com', - name: mockUser.name, - }) - authMockFns.mockGetSession.mockResolvedValue(session) - - dbSelectResults = [[mockInvitation], [mockWorkspace], []] - - const request = new NextRequest( - 'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123' - ) - const params = Promise.resolve({ invitationId: 'token-abc123' }) - - const response = await GET(request, { params }) - - expect(response.status).toBe(307) - const location = response.headers.get('location') - expect(location).toBe( - 'https://test.sim.ai/invite/invitation-789?error=user-not-found&token=token-abc123' - ) - }) - - it('should URL encode special characters in token when preserving in error redirects', async () => { - const session = createSession({ - userId: mockUser.id, - email: 'wrong@example.com', - name: mockUser.name, - }) - authMockFns.mockGetSession.mockResolvedValue(session) - - dbSelectResults = [ - [mockInvitation], - [mockWorkspace], - [{ ...mockUser, email: 'wrong@example.com' }], - ] - - const specialToken = 'token+with/special=chars&more' - const request = new NextRequest( - `http://localhost/api/workspaces/invitations/token-abc123?token=${encodeURIComponent(specialToken)}` - ) - const params = Promise.resolve({ invitationId: 'token-abc123' }) - - const response = await GET(request, { params }) - - expect(response.status).toBe(307) - const location = response.headers.get('location') - expect(location).toContain('error=email-mismatch') - expect(location).toContain(`token=${encodeURIComponent(specialToken)}`) - }) - }) - - describe('Token Preservation - Full Flow Scenario', () => { - it('should preserve token through email mismatch so user can retry with correct account', async () => { - const wrongSession = createSession({ - userId: 'wrong-user', - email: 'wrong@example.com', - name: 'Wrong User', - }) - authMockFns.mockGetSession.mockResolvedValue(wrongSession) - - dbSelectResults = [ - [mockInvitation], - [mockWorkspace], - [{ id: 'wrong-user', email: 'wrong@example.com' }], - ] - - const request1 = new NextRequest( - 'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123' - ) - const params1 = Promise.resolve({ invitationId: 'token-abc123' }) - - const response1 = await GET(request1, { params: params1 }) - - expect(response1.status).toBe(307) - const location1 = response1.headers.get('location') - expect(location1).toBe( - 'https://test.sim.ai/invite/invitation-789?error=email-mismatch&token=token-abc123' - ) - - vi.clearAllMocks() - dbSelectCallIndex = 0 - - const correctSession = createSession({ - userId: mockUser.id, - email: 'invited@example.com', - name: mockUser.name, - }) - authMockFns.mockGetSession.mockResolvedValue(correctSession) - - dbSelectResults = [ - [mockInvitation], - [mockWorkspace], - [{ ...mockUser, email: 'invited@example.com' }], - [], - [], - ] - - const request2 = new NextRequest( - 'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123' - ) - const params2 = Promise.resolve({ invitationId: 'token-abc123' }) - - const response2 = await GET(request2, { params: params2 }) - - expect(response2.status).toBe(307) - expect(response2.headers.get('location')).toBe( - 'https://test.sim.ai/workspace/workspace-456/home' - ) - }) - }) - - describe('DELETE /api/workspaces/invitations/[invitationId]', () => { - it('should return 401 when user is not authenticated', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const request = new NextRequest( - 'http://localhost/api/workspaces/invitations/invitation-789', - { method: 'DELETE' } - ) - const params = Promise.resolve({ invitationId: 'invitation-789' }) - - const response = await DELETE(request, { params }) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data).toEqual({ error: 'Unauthorized' }) - }) - - it('should return 404 when invitation does not exist', async () => { - const session = createSession({ userId: mockUser.id, email: mockUser.email }) - authMockFns.mockGetSession.mockResolvedValue(session) - dbSelectResults = [[]] - - const request = new NextRequest('http://localhost/api/workspaces/invitations/non-existent', { - method: 'DELETE', - }) - const params = Promise.resolve({ invitationId: 'non-existent' }) - - const response = await DELETE(request, { params }) - const data = await response.json() - - expect(response.status).toBe(404) - expect(data).toEqual({ error: 'Invitation not found' }) - }) - - it('should return 403 when user lacks admin access', async () => { - const session = createSession({ userId: mockUser.id, email: mockUser.email }) - authMockFns.mockGetSession.mockResolvedValue(session) - mockHasWorkspaceAdminAccess.mockResolvedValue(false) - dbSelectResults = [[mockInvitation]] - - const request = new NextRequest( - 'http://localhost/api/workspaces/invitations/invitation-789', - { method: 'DELETE' } - ) - const params = Promise.resolve({ invitationId: 'invitation-789' }) - - const response = await DELETE(request, { params }) - const data = await response.json() - - expect(response.status).toBe(403) - expect(data).toEqual({ error: 'Insufficient permissions' }) - expect(mockHasWorkspaceAdminAccess).toHaveBeenCalledWith('user-123', 'workspace-456') - }) - - it('should return 400 when trying to delete non-pending invitation', async () => { - const session = createSession({ userId: mockUser.id, email: mockUser.email }) - authMockFns.mockGetSession.mockResolvedValue(session) - mockHasWorkspaceAdminAccess.mockResolvedValue(true) - - const acceptedInvitation = { ...mockInvitation, status: 'accepted' } - dbSelectResults = [[acceptedInvitation]] - - const request = new NextRequest( - 'http://localhost/api/workspaces/invitations/invitation-789', - { method: 'DELETE' } - ) - const params = Promise.resolve({ invitationId: 'invitation-789' }) - - const response = await DELETE(request, { params }) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data).toEqual({ error: 'Can only delete pending invitations' }) - }) - - it('should successfully delete pending invitation when user has admin access', async () => { - const session = createSession({ userId: mockUser.id, email: mockUser.email }) - authMockFns.mockGetSession.mockResolvedValue(session) - mockHasWorkspaceAdminAccess.mockResolvedValue(true) - dbSelectResults = [[mockInvitation]] - - const request = new NextRequest( - 'http://localhost/api/workspaces/invitations/invitation-789', - { method: 'DELETE' } - ) - const params = Promise.resolve({ invitationId: 'invitation-789' }) - - const response = await DELETE(request, { params }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data).toEqual({ success: true }) - }) - }) -}) diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts deleted file mode 100644 index 2580c0df140..00000000000 --- a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { render } from '@react-email/render' -import { db } from '@sim/db' -import { - permissions, - user, - type WorkspaceInvitationStatus, - workspace, - workspaceEnvironment, - workspaceInvitation, -} from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq, isNull } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { WorkspaceInvitationEmail } from '@/components/emails' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' -import { getSession } from '@/lib/auth' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' -import { sendEmail } from '@/lib/messaging/email/mailer' -import { getFromEmailAddress } from '@/lib/messaging/email/utils' -import { getWorkspaceById, hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('WorkspaceInvitationAPI') - -// GET /api/workspaces/invitations/[invitationId] - Get invitation details OR accept via token -export async function GET( - req: NextRequest, - { params }: { params: Promise<{ invitationId: string }> } -) { - const { invitationId } = await params - const session = await getSession() - const token = req.nextUrl.searchParams.get('token') - const isAcceptFlow = !!token // If token is provided, this is an acceptance flow - - if (!session?.user?.id) { - if (isAcceptFlow) { - return NextResponse.redirect(new URL(`/invite/${invitationId}?token=${token}`, getBaseUrl())) - } - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const whereClause = token - ? eq(workspaceInvitation.token, token) - : eq(workspaceInvitation.id, invitationId) - - const invitation = await db - .select() - .from(workspaceInvitation) - .where(whereClause) - .then((rows) => rows[0]) - - if (!invitation) { - if (isAcceptFlow) { - const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '' - return NextResponse.redirect( - new URL(`/invite/${invitationId}?error=invalid-token${tokenParam}`, getBaseUrl()) - ) - } - return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 }) - } - - if (new Date() > new Date(invitation.expiresAt)) { - if (isAcceptFlow) { - const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '' - return NextResponse.redirect( - new URL(`/invite/${invitation.id}?error=expired${tokenParam}`, getBaseUrl()) - ) - } - return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 }) - } - - const workspaceDetails = await db - .select() - .from(workspace) - .where(and(eq(workspace.id, invitation.workspaceId), isNull(workspace.archivedAt))) - .then((rows) => rows[0]) - - if (!workspaceDetails) { - if (isAcceptFlow) { - const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '' - return NextResponse.redirect( - new URL(`/invite/${invitation.id}?error=workspace-not-found${tokenParam}`, getBaseUrl()) - ) - } - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - if (isAcceptFlow) { - const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '' - - if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) { - return NextResponse.redirect( - new URL(`/invite/${invitation.id}?error=already-processed${tokenParam}`, getBaseUrl()) - ) - } - - const userEmail = session.user.email.toLowerCase() - const invitationEmail = invitation.email.toLowerCase() - - const userData = await db - .select() - .from(user) - .where(eq(user.id, session.user.id)) - .then((rows) => rows[0]) - - if (!userData) { - return NextResponse.redirect( - new URL(`/invite/${invitation.id}?error=user-not-found${tokenParam}`, getBaseUrl()) - ) - } - - const isValidMatch = userEmail === invitationEmail - - if (!isValidMatch) { - return NextResponse.redirect( - new URL(`/invite/${invitation.id}?error=email-mismatch${tokenParam}`, getBaseUrl()) - ) - } - - const existingPermission = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityId, invitation.workspaceId), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, session.user.id) - ) - ) - .then((rows) => rows[0]) - - if (existingPermission) { - await db - .update(workspaceInvitation) - .set({ - status: 'accepted' as WorkspaceInvitationStatus, - updatedAt: new Date(), - }) - .where(eq(workspaceInvitation.id, invitation.id)) - - return NextResponse.redirect( - new URL(`/workspace/${invitation.workspaceId}/home`, getBaseUrl()) - ) - } - - await db.transaction(async (tx) => { - await tx.insert(permissions).values({ - id: generateId(), - entityType: 'workspace' as const, - entityId: invitation.workspaceId, - userId: session.user.id, - permissionType: invitation.permissions || 'read', - createdAt: new Date(), - updatedAt: new Date(), - }) - - await tx - .update(workspaceInvitation) - .set({ - status: 'accepted' as WorkspaceInvitationStatus, - updatedAt: new Date(), - }) - .where(eq(workspaceInvitation.id, invitation.id)) - }) - - const [wsEnvRow] = await db - .select({ variables: workspaceEnvironment.variables }) - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, invitation.workspaceId)) - .limit(1) - const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record) || {}) - if (wsEnvKeys.length > 0) { - await syncWorkspaceEnvCredentials({ - workspaceId: invitation.workspaceId, - envKeys: wsEnvKeys, - actingUserId: session.user.id, - }) - } - - recordAudit({ - workspaceId: invitation.workspaceId, - actorId: session.user.id, - action: AuditAction.INVITATION_ACCEPTED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: invitation.workspaceId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: workspaceDetails.name, - description: `Accepted workspace invitation to "${workspaceDetails.name}"`, - metadata: { - targetEmail: invitation.email, - workspaceName: workspaceDetails.name, - assignedPermission: invitation.permissions || 'read', - invitationId: invitation.id, - inviterId: invitation.inviterId, - }, - request: req, - }) - - return NextResponse.redirect( - new URL(`/workspace/${invitation.workspaceId}/home`, getBaseUrl()) - ) - } - - const isInvitee = session.user.email?.toLowerCase() === invitation.email.toLowerCase() - - if (!isInvitee) { - const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId) - if (!hasAdminAccess) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - } - - return NextResponse.json({ - ...invitation, - workspaceName: workspaceDetails.name, - }) - } catch (error) { - logger.error('Error fetching workspace invitation:', error) - return NextResponse.json({ error: 'Failed to fetch invitation details' }, { status: 500 }) - } -} - -// DELETE /api/workspaces/invitations/[invitationId] - Delete a workspace invitation -export async function DELETE( - _request: NextRequest, - { params }: { params: Promise<{ invitationId: string }> } -) { - const { invitationId } = await params - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const invitation = await db - .select({ - id: workspaceInvitation.id, - workspaceId: workspaceInvitation.workspaceId, - email: workspaceInvitation.email, - inviterId: workspaceInvitation.inviterId, - status: workspaceInvitation.status, - }) - .from(workspaceInvitation) - .where(eq(workspaceInvitation.id, invitationId)) - .then((rows) => rows[0]) - - if (!invitation) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - - const activeWorkspace = await getWorkspaceById(invitation.workspaceId) - if (!activeWorkspace) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId) - - if (!hasAdminAccess) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) { - return NextResponse.json({ error: 'Can only delete pending invitations' }, { status: 400 }) - } - - await db.delete(workspaceInvitation).where(eq(workspaceInvitation.id, invitationId)) - - recordAudit({ - workspaceId: invitation.workspaceId, - actorId: session.user.id, - action: AuditAction.INVITATION_REVOKED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: invitation.workspaceId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Revoked workspace invitation for ${invitation.email}`, - metadata: { - invitationId, - targetEmail: invitation.email, - invitationStatus: invitation.status, - }, - request: _request, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error deleting workspace invitation:', error) - return NextResponse.json({ error: 'Failed to delete invitation' }, { status: 500 }) - } -} - -// POST /api/workspaces/invitations/[invitationId] - Resend a workspace invitation -export async function POST( - _request: NextRequest, - { params }: { params: Promise<{ invitationId: string }> } -) { - const { invitationId } = await params - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const invitation = await db - .select() - .from(workspaceInvitation) - .where(eq(workspaceInvitation.id, invitationId)) - .then((rows) => rows[0]) - - if (!invitation) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - - const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId) - if (!hasAdminAccess) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) { - return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 }) - } - - const ws = await db - .select() - .from(workspace) - .where(eq(workspace.id, invitation.workspaceId)) - .then((rows) => rows[0]) - - if (!ws) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - const newToken = generateId() - const newExpiresAt = new Date() - newExpiresAt.setDate(newExpiresAt.getDate() + 7) - - await db - .update(workspaceInvitation) - .set({ token: newToken, expiresAt: newExpiresAt, updatedAt: new Date() }) - .where(eq(workspaceInvitation.id, invitationId)) - - const baseUrl = getBaseUrl() - const invitationLink = `${baseUrl}/invite/${invitationId}?token=${newToken}` - - const emailHtml = await render( - WorkspaceInvitationEmail({ - workspaceName: ws.name, - inviterName: session.user.name || session.user.email || 'A user', - invitationLink, - }) - ) - - const result = await sendEmail({ - to: invitation.email, - subject: `You've been invited to join "${ws.name}" on Sim`, - html: emailHtml, - from: getFromEmailAddress(), - emailType: 'transactional', - }) - - if (!result.success) { - return NextResponse.json( - { error: 'Failed to send invitation email. Please try again.' }, - { status: 500 } - ) - } - - recordAudit({ - workspaceId: invitation.workspaceId, - actorId: session.user.id, - action: AuditAction.INVITATION_RESENT, - resourceType: AuditResourceType.WORKSPACE, - resourceId: invitation.workspaceId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: ws.name, - description: `Resent workspace invitation to ${invitation.email}`, - metadata: { - invitationId, - targetEmail: invitation.email, - workspaceName: ws.name, - }, - request: _request, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error resending workspace invitation:', error) - return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index 63e02a2fc70..38d3cdd63fe 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -1,353 +1,356 @@ /** * @vitest-environment node */ -import { authMockFns, createMockRequest, permissionsMock, permissionsMockFns } from '@sim/testing' +import { + auditMock, + authMock, + authMockFns, + createMockRequest, + permissionsMock, + permissionsMockFns, + schemaMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockInsertValues, - mockDbResults, - mockResendSend, - mockDbChain, - mockRender, - mockWorkspaceInvitationEmail, - mockGetEmailDomain, + mockGetWorkspaceInvitePolicy, mockValidateInvitationsAllowed, - mockRandomUUID, -} = vi.hoisted(() => { - const mockInsertValues = vi.fn().mockResolvedValue(undefined) - const mockResendSend = vi.fn().mockResolvedValue({ id: 'email-id' }) - const mockRender = vi.fn().mockResolvedValue('email content') - const mockWorkspaceInvitationEmail = vi.fn() - const mockGetEmailDomain = vi.fn().mockReturnValue('sim.ai') - const mockValidateInvitationsAllowed = vi.fn().mockResolvedValue(undefined) - const mockRandomUUID = vi.fn().mockReturnValue('mock-uuid-1234') - - const mockDbResults: { value: any[] } = { value: [] } - - const mockDbChain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - innerJoin: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - then: vi.fn().mockImplementation((callback: any) => { - const result = mockDbResults.value.shift() || [] - return callback ? callback(result) : Promise.resolve(result) - }), - insert: vi.fn().mockReturnThis(), - values: mockInsertValues, - } - - return { - mockInsertValues, - mockDbResults, - mockResendSend, - mockDbChain, - mockRender, - mockWorkspaceInvitationEmail, - mockGetEmailDomain, - mockValidateInvitationsAllowed, - mockRandomUUID, - } -}) - -const mockGetWorkspaceById = permissionsMockFns.mockGetWorkspaceById - -vi.mock('@sim/utils/id', () => ({ - generateId: mockRandomUUID, - generateShortId: vi.fn(() => 'mock-short-id'), - isValidUuid: vi.fn((v: string) => - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v) - ), + mockValidateSeatAvailability, + mockGetUserOrganization, + mockCreatePendingInvitation, + mockSendInvitationEmail, + mockCancelPendingInvitation, + mockFindPendingGrantForWorkspaceEmail, + mockDbResults, +} = vi.hoisted(() => ({ + mockGetWorkspaceInvitePolicy: vi.fn(), + mockValidateInvitationsAllowed: vi.fn().mockResolvedValue(undefined), + mockValidateSeatAvailability: vi.fn(), + mockGetUserOrganization: vi.fn(), + mockCreatePendingInvitation: vi.fn(), + mockSendInvitationEmail: vi.fn(), + mockCancelPendingInvitation: vi.fn(), + mockFindPendingGrantForWorkspaceEmail: vi.fn(), + mockDbResults: { value: [] as any[] }, })) vi.mock('@sim/db', () => ({ - db: mockDbChain, + db: { + select: vi.fn().mockImplementation(() => { + const chain: any = {} + chain.from = vi.fn().mockReturnValue(chain) + chain.innerJoin = vi.fn().mockReturnValue(chain) + chain.where = vi.fn().mockReturnValue(chain) + chain.limit = vi + .fn() + .mockImplementation(() => Promise.resolve(mockDbResults.value.shift() || [])) + chain.then = vi.fn().mockImplementation((callback: (rows: any[]) => unknown) => { + const result = mockDbResults.value.shift() || [] + return Promise.resolve(callback ? callback(result) : result) + }) + return chain + }), + }, })) -vi.mock('resend', () => ({ - Resend: vi.fn().mockImplementation(() => ({ - emails: { send: mockResendSend }, - })), +vi.mock('@sim/db/schema', () => schemaMock) + +vi.mock('@/lib/auth', () => authMock) + +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) + +vi.mock('@/lib/workspaces/policy', () => ({ + getWorkspaceInvitePolicy: mockGetWorkspaceInvitePolicy, + isOrganizationWorkspace: (ws: { + workspaceMode?: string | null + organizationId?: string | null + }) => ws.workspaceMode === 'organization' && !!ws.organizationId, })) -vi.mock('@react-email/render', () => ({ - render: mockRender, +vi.mock('@/lib/billing/validation/seat-management', () => ({ + validateSeatAvailability: mockValidateSeatAvailability, })) -vi.mock('@/components/emails/workspace-invitation', () => ({ - WorkspaceInvitationEmail: mockWorkspaceInvitationEmail, +vi.mock('@/lib/billing/organizations/membership', () => ({ + getUserOrganization: mockGetUserOrganization, })) -vi.mock('@/lib/core/config/env', async () => { - const { createEnvMock } = await import('@sim/testing') - return createEnvMock() -}) +vi.mock('@/lib/invitations/send', () => ({ + createPendingInvitation: mockCreatePendingInvitation, + sendInvitationEmail: mockSendInvitationEmail, + cancelPendingInvitation: mockCancelPendingInvitation, + findPendingGrantForWorkspaceEmail: mockFindPendingGrantForWorkspaceEmail, +})) -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@/lib/invitations/core', () => ({ + normalizeEmail: (email: string) => email.trim().toLowerCase(), + listInvitationsForWorkspaces: vi.fn().mockResolvedValue([]), +})) -vi.mock('@/lib/core/utils/urls', () => ({ - getEmailDomain: mockGetEmailDomain, +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + validateInvitationsAllowed: mockValidateInvitationsAllowed, + InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error {}, })) -vi.mock('@/lib/audit/log', async () => { - const { auditMock } = await import('@sim/testing') - return auditMock -}) +vi.mock('@/lib/audit/log', () => auditMock) -vi.mock('drizzle-orm', () => ({ - and: vi.fn().mockImplementation((...args: any[]) => ({ type: 'and', conditions: args })), - eq: vi.fn().mockImplementation((field: any, value: any) => ({ type: 'eq', field, value })), - inArray: vi - .fn() - .mockImplementation((field: any, values: any) => ({ type: 'inArray', field, values })), - isNull: vi.fn().mockImplementation((field: any) => ({ type: 'isNull', field })), +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: vi.fn(), })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ - validateInvitationsAllowed: mockValidateInvitationsAllowed, - InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error { - constructor() { - super('Invitations are not allowed based on your permission group settings') - this.name = 'InvitationsNotAllowedError' - } +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { + workspaceMemberInvited: vi.fn(), }, })) -import { GET, POST } from '@/app/api/workspaces/invitations/route' +const mockGetSession = authMockFns.mockGetSession +const mockGetWorkspaceWithOwner = permissionsMockFns.mockGetWorkspaceWithOwner -describe('Workspace Invitations API Route', () => { - const mockWorkspace = { id: 'workspace-1', name: 'Test Workspace' } - const mockUser = { id: 'user-1', email: 'test@example.com' } - const mockInvitation = { id: 'invitation-1', status: 'pending' } +import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants' +import { POST } from '@/app/api/workspaces/invitations/route' +describe('POST /api/workspaces/invitations', () => { beforeEach(() => { vi.clearAllMocks() mockDbResults.value = [] - - // Reset mockDbChain methods that need fresh returnThis behavior - mockDbChain.select.mockReturnThis() - mockDbChain.from.mockReturnThis() - mockDbChain.where.mockReturnThis() - mockDbChain.innerJoin.mockReturnThis() - mockDbChain.limit.mockReturnThis() - mockDbChain.insert.mockReturnThis() - mockDbChain.then.mockImplementation((callback: any) => { - const result = mockDbResults.value.shift() || [] - return callback ? callback(result) : Promise.resolve(result) + mockGetSession.mockResolvedValue({ + user: { id: 'user-1', email: 'owner@test.com', name: 'Owner User' }, + }) + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + name: 'Workspace', + ownerId: 'user-1', + organizationId: null, + workspaceMode: 'grandfathered_shared', + billedAccountUserId: 'user-1', }) - mockDbChain.values = mockInsertValues - mockInsertValues.mockResolvedValue(undefined) - mockResendSend.mockResolvedValue({ id: 'email-id' }) - mockRandomUUID.mockReturnValue('mock-uuid-1234') - mockRender.mockResolvedValue('email content') - mockGetEmailDomain.mockReturnValue('sim.ai') mockValidateInvitationsAllowed.mockResolvedValue(undefined) - mockGetWorkspaceById.mockResolvedValue({ id: 'workspace-1', name: 'Test Workspace' }) + mockGetWorkspaceInvitePolicy.mockResolvedValue({ + allowed: true, + reason: null, + requiresSeat: false, + organizationId: null, + upgradeRequired: false, + }) + mockValidateSeatAvailability.mockResolvedValue({ + canInvite: true, + currentSeats: 1, + maxSeats: 5, + availableSeats: 4, + }) + mockGetUserOrganization.mockResolvedValue(null) + mockCreatePendingInvitation.mockResolvedValue({ + invitationId: 'inv-1', + token: 'tok-1', + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }) + mockSendInvitationEmail.mockResolvedValue({ success: true }) + mockFindPendingGrantForWorkspaceEmail.mockResolvedValue(null) }) - describe('GET /api/workspaces/invitations', () => { - it('should return 401 when user is not authenticated', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const req = createMockRequest('GET') - const response = await GET(req) - const data = await response.json() - - expect(response.status).toBe(401) - expect(data).toEqual({ error: 'Unauthorized' }) + it('blocks invites for personal workspaces with an upgrade prompt', async () => { + mockGetWorkspaceWithOwner.mockResolvedValueOnce({ + id: 'workspace-1', + name: 'Personal Workspace', + ownerId: 'user-1', + organizationId: null, + workspaceMode: 'personal', + billedAccountUserId: 'user-1', }) - - it('should return empty invitations when user has no workspaces', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockDbResults.value = [[], []] // No workspaces, no invitations - - const req = createMockRequest('GET') - const response = await GET(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data).toEqual({ invitations: [] }) + mockGetWorkspaceInvitePolicy.mockResolvedValueOnce({ + allowed: false, + reason: UPGRADE_TO_INVITE_REASON, + requiresSeat: false, + organizationId: null, + upgradeRequired: true, }) + mockDbResults.value = [[{ permissionType: 'admin' }]] - it('should return invitations for user workspaces', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - const mockWorkspaces = [{ id: 'workspace-1' }, { id: 'workspace-2' }] - const mockInvitations = [ - { id: 'invitation-1', workspaceId: 'workspace-1', email: 'test@example.com' }, - { id: 'invitation-2', workspaceId: 'workspace-2', email: 'test2@example.com' }, - ] - mockDbResults.value = [mockWorkspaces, mockInvitations] - - const req = createMockRequest('GET') - const response = await GET(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data).toEqual({ invitations: mockInvitations }) + const request = createMockRequest('POST', { + workspaceId: 'workspace-1', + email: 'new@example.com', + permission: 'read', }) - }) - describe('POST /api/workspaces/invitations', () => { - it('should return 401 when user is not authenticated', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) + const response = await POST(request) + const data = await response.json() - const req = createMockRequest('POST', { - workspaceId: 'workspace-1', - email: 'test@example.com', - }) - const response = await POST(req) - const data = await response.json() + expect(response.status).toBe(403) + expect(data.error).toBe(UPGRADE_TO_INVITE_REASON) + expect(data.upgradeRequired).toBe(true) + }) - expect(response.status).toBe(401) - expect(data).toEqual({ error: 'Unauthorized' }) + it('blocks invites for grandfathered workspaces without a team plan', async () => { + mockGetWorkspaceWithOwner.mockResolvedValueOnce({ + id: 'workspace-1', + name: 'Grandfathered Workspace', + ownerId: 'user-1', + organizationId: null, + workspaceMode: 'grandfathered_shared', + billedAccountUserId: 'user-1', }) + mockGetWorkspaceInvitePolicy.mockResolvedValueOnce({ + allowed: false, + reason: UPGRADE_TO_INVITE_REASON, + requiresSeat: false, + organizationId: null, + upgradeRequired: true, + }) + mockDbResults.value = [[{ permissionType: 'admin' }]] - it('should return 400 when workspaceId is missing', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const req = createMockRequest('POST', { email: 'test@example.com' }) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data).toEqual({ error: 'Workspace ID and email are required' }) + const request = createMockRequest('POST', { + workspaceId: 'workspace-1', + email: 'new@example.com', + permission: 'read', }) - it('should return 400 when email is missing', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) + const response = await POST(request) + const data = await response.json() - const req = createMockRequest('POST', { workspaceId: 'workspace-1' }) - const response = await POST(req) - const data = await response.json() + expect(response.status).toBe(403) + expect(data.upgradeRequired).toBe(true) + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + }) - expect(response.status).toBe(400) - expect(data).toEqual({ error: 'Workspace ID and email are required' }) + it('rejects org-owned invites when the organization has no available seats', async () => { + mockGetWorkspaceWithOwner.mockResolvedValueOnce({ + id: 'workspace-1', + name: 'Org Workspace', + ownerId: 'user-1', + organizationId: 'org-1', + workspaceMode: 'organization', + billedAccountUserId: 'owner-1', }) + mockGetWorkspaceInvitePolicy.mockResolvedValueOnce({ + allowed: true, + reason: null, + requiresSeat: true, + organizationId: 'org-1', + upgradeRequired: false, + }) + mockValidateSeatAvailability.mockResolvedValueOnce({ + canInvite: false, + reason: 'No available seats. Currently using 5 of 5 seats.', + currentSeats: 5, + maxSeats: 5, + availableSeats: 0, + }) + mockDbResults.value = [[{ permissionType: 'admin' }], []] - it('should return 400 when permission type is invalid', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const req = createMockRequest('POST', { - workspaceId: 'workspace-1', - email: 'test@example.com', - permission: 'invalid-permission', - }) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data).toEqual({ - error: 'Invalid permission: must be one of admin, write, read', - }) + const request = createMockRequest('POST', { + workspaceId: 'workspace-1', + email: 'new@example.com', + permission: 'read', }) - it('should return 403 when user does not have admin permissions', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockDbResults.value = [[]] // No admin permissions found + const response = await POST(request) + const data = await response.json() - const req = createMockRequest('POST', { - workspaceId: 'workspace-1', - email: 'test@example.com', - }) - const response = await POST(req) - const data = await response.json() + expect(response.status).toBe(400) + expect(data.error).toContain('No available seats') + expect(mockValidateSeatAvailability).toHaveBeenCalledWith('org-1', 1) + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + }) - expect(response.status).toBe(403) - expect(data).toEqual({ error: 'You need admin permissions to invite users' }) + it('rejects org-owned invites for users already in another organization', async () => { + mockGetWorkspaceWithOwner.mockResolvedValueOnce({ + id: 'workspace-1', + name: 'Org Workspace', + ownerId: 'user-1', + organizationId: 'org-1', + workspaceMode: 'organization', + billedAccountUserId: 'owner-1', + }) + mockGetWorkspaceInvitePolicy.mockResolvedValueOnce({ + allowed: true, + reason: null, + requiresSeat: true, + organizationId: 'org-1', + upgradeRequired: false, + }) + mockGetUserOrganization.mockResolvedValueOnce({ + organizationId: 'org-2', + role: 'member', + memberId: 'member-1', + }) + mockDbResults.value = [ + [{ permissionType: 'admin' }], + [{ id: 'existing-user', email: 'new@example.com' }], + ] + + const request = createMockRequest('POST', { + workspaceId: 'workspace-1', + email: 'new@example.com', + permission: 'read', }) - it('should return 404 when workspace is not found', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockGetWorkspaceById.mockResolvedValueOnce(null) - mockDbResults.value = [ - [{ permissionType: 'admin' }], // User has admin permissions - ] + const response = await POST(request) + const data = await response.json() - const req = createMockRequest('POST', { - workspaceId: 'workspace-1', - email: 'test@example.com', - }) - const response = await POST(req) - const data = await response.json() + expect(response.status).toBe(409) + expect(data.error).toContain('already a member of another organization') + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + }) - expect(response.status).toBe(404) - expect(data).toEqual({ error: 'Workspace not found' }) + it('creates a unified workspace invitation for a grandfathered workspace', async () => { + mockGetWorkspaceWithOwner.mockResolvedValueOnce({ + id: 'workspace-1', + name: 'Grandfathered Workspace', + ownerId: 'user-1', + organizationId: null, + workspaceMode: 'grandfathered_shared', + billedAccountUserId: 'user-1', }) + mockDbResults.value = [[{ permissionType: 'admin' }], []] - it('should return 400 when user already has workspace access', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockDbResults.value = [ - [{ permissionType: 'admin' }], // User has admin permissions - [mockWorkspace], // Workspace exists - [mockUser], // User exists - [{ permissionType: 'read' }], // User already has access - ] - - const req = createMockRequest('POST', { - workspaceId: 'workspace-1', - email: 'test@example.com', - }) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data).toEqual({ - error: 'test@example.com already has access to this workspace', - email: 'test@example.com', - }) + const request = createMockRequest('POST', { + workspaceId: 'workspace-1', + email: 'new@example.com', + permission: 'write', }) - it('should return 400 when invitation already exists', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockDbResults.value = [ - [{ permissionType: 'admin' }], // User has admin permissions - [mockWorkspace], // Workspace exists - [], // User doesn't exist - [mockInvitation], // Invitation exists - ] - - const req = createMockRequest('POST', { - workspaceId: 'workspace-1', - email: 'test@example.com', + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'workspace', + email: 'new@example.com', + organizationId: null, + grants: [{ workspaceId: 'workspace-1', permission: 'write' }], }) - const response = await POST(req) - const data = await response.json() + ) + expect(mockSendInvitationEmail).toHaveBeenCalled() + expect(mockValidateSeatAvailability).not.toHaveBeenCalled() + }) - expect(response.status).toBe(400) - expect(data).toEqual({ - error: 'test@example.com has already been invited to this workspace', - email: 'test@example.com', - }) + it('rolls back the unified invitation when email delivery fails', async () => { + mockGetWorkspaceWithOwner.mockResolvedValueOnce({ + id: 'workspace-1', + name: 'Org Workspace', + ownerId: 'user-1', + organizationId: 'org-1', + workspaceMode: 'organization', + billedAccountUserId: 'owner-1', }) + mockSendInvitationEmail.mockResolvedValueOnce({ + success: false, + error: 'mailer unavailable', + }) + mockDbResults.value = [[{ permissionType: 'admin' }], []] - it('should successfully create invitation and send email', async () => { - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-123', name: 'Test User', email: 'sender@example.com' }, - }) - mockDbResults.value = [ - [{ permissionType: 'admin' }], // User has admin permissions - [mockWorkspace], // Workspace exists - [], // User doesn't exist - [], // No existing invitation - ] - - const req = createMockRequest('POST', { - workspaceId: 'workspace-1', - email: 'test@example.com', - permission: 'read', - }) - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.invitation).toBeDefined() - expect(data.invitation.email).toBe('test@example.com') - expect(data.invitation.permissions).toBe('read') - expect(data.invitation.token).toBe('mock-uuid-1234') - expect(mockInsertValues).toHaveBeenCalled() + const request = createMockRequest('POST', { + workspaceId: 'workspace-1', + email: 'new@example.com', + permission: 'read', }) + + const response = await POST(request) + + expect(response.status).toBe(502) + expect(mockCancelPendingInvitation).toHaveBeenCalledWith('inv-1') }) }) diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index ff1edf8d55c..7eacb5fe9a3 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -1,26 +1,23 @@ -import { render } from '@react-email/render' import { db } from '@sim/db' -import { - permissions, - type permissionTypeEnum, - user, - type WorkspaceInvitationStatus, - workspace, - workspaceInvitation, -} from '@sim/db/schema' +import { permissions, type permissionTypeEnum, user, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq, inArray, isNull } from 'drizzle-orm' +import { and, eq, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { WorkspaceInvitationEmail } from '@/components/emails' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' +import { getUserOrganization } from '@/lib/billing/organizations/membership' +import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' import { PlatformEvents } from '@/lib/core/telemetry' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { sendEmail } from '@/lib/messaging/email/mailer' -import { getFromEmailAddress } from '@/lib/messaging/email/utils' +import { listInvitationsForWorkspaces, normalizeEmail } from '@/lib/invitations/core' +import { + cancelPendingInvitation, + createPendingInvitation, + findPendingGrantForWorkspaceEmail, + sendInvitationEmail, +} from '@/lib/invitations/send' import { captureServerEvent } from '@/lib/posthog/server' -import { getWorkspaceById } from '@/lib/workspaces/permissions/utils' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' +import { getWorkspaceInvitePolicy } from '@/lib/workspaces/policy' import { InvitationsNotAllowedError, validateInvitationsAllowed, @@ -32,10 +29,8 @@ const logger = createLogger('WorkspaceInvitationsAPI') type PermissionType = (typeof permissionTypeEnum.enumValues)[number] -// Get all invitations for the user's workspaces export async function GET(req: NextRequest) { const session = await getSession() - if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -58,13 +53,7 @@ export async function GET(req: NextRequest) { return NextResponse.json({ invitations: [] }) } - const workspaceIds = userWorkspaces.map((w) => w.id) - - const invitations = await db - .select() - .from(workspaceInvitation) - .where(inArray(workspaceInvitation.workspaceId, workspaceIds)) - + const invitations = await listInvitationsForWorkspaces(userWorkspaces.map((w) => w.id)) return NextResponse.json({ invitations }) } catch (error) { logger.error('Error fetching workspace invitations:', error) @@ -72,10 +61,8 @@ export async function GET(req: NextRequest) { } } -// Create a new invitation export async function POST(req: NextRequest) { const session = await getSession() - if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -83,7 +70,7 @@ export async function POST(req: NextRequest) { try { await validateInvitationsAllowed(session.user.id) - const { workspaceId, email, role = 'member', permission = 'read' } = await req.json() + const { workspaceId, email, permission = 'read' } = await req.json() if (!workspaceId || !email) { return NextResponse.json({ error: 'Workspace ID and email are required' }, { status: 400 }) @@ -97,6 +84,8 @@ export async function POST(req: NextRequest) { ) } + const normalizedEmail = normalizeEmail(email) + const userPermission = await db .select() .from(permissions) @@ -117,25 +106,26 @@ export async function POST(req: NextRequest) { ) } - const activeWorkspace = await getWorkspaceById(workspaceId) - if (!activeWorkspace) { + const workspaceDetails = await getWorkspaceWithOwner(workspaceId) + if (!workspaceDetails) { return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) } - const workspaceDetails = await db - .select() - .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .then((rows) => rows[0]) - - if (!workspaceDetails) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + const invitePolicy = await getWorkspaceInvitePolicy(workspaceDetails) + if (!invitePolicy.allowed) { + return NextResponse.json( + { + error: invitePolicy.reason ?? 'Invites are disabled for this workspace.', + upgradeRequired: invitePolicy.upgradeRequired, + }, + { status: 403 } + ) } const existingUser = await db .select() .from(user) - .where(eq(user.email, email)) + .where(sql`lower(${user.email}) = ${normalizedEmail}`) .then((rows) => rows[0]) if (existingUser) { @@ -154,65 +144,92 @@ export async function POST(req: NextRequest) { if (existingPermission) { return NextResponse.json( { - error: `${email} already has access to this workspace`, - email, + error: `${normalizedEmail} already has access to this workspace`, + email: normalizedEmail, }, { status: 400 } ) } - } - const existingInvitation = await db - .select() - .from(workspaceInvitation) - .where( - and( - eq(workspaceInvitation.workspaceId, workspaceId), - eq(workspaceInvitation.email, email), - eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus) + if (invitePolicy.requiresSeat && invitePolicy.organizationId) { + const existingMembership = await getUserOrganization(existingUser.id) + if ( + existingMembership && + existingMembership.organizationId !== invitePolicy.organizationId + ) { + return NextResponse.json( + { + error: + 'This user is already a member of another organization. They must leave it before joining this workspace.', + email: normalizedEmail, + }, + { status: 409 } + ) + } + + if (!existingMembership) { + const seatValidation = await validateSeatAvailability(invitePolicy.organizationId, 1) + if (!seatValidation.canInvite) { + return NextResponse.json( + { + error: seatValidation.reason || 'No available seats for this organization.', + email: normalizedEmail, + }, + { status: 400 } + ) + } + } + } + } else if (invitePolicy.requiresSeat && invitePolicy.organizationId) { + const seatValidation = await validateSeatAvailability(invitePolicy.organizationId, 1) + if (!seatValidation.canInvite) { + return NextResponse.json( + { + error: seatValidation.reason || 'No available seats for this organization.', + email: normalizedEmail, + }, + { status: 400 } ) - ) - .then((rows) => rows[0]) + } + } + const existingInvitation = await findPendingGrantForWorkspaceEmail({ + workspaceId, + email: normalizedEmail, + }) if (existingInvitation) { return NextResponse.json( { - error: `${email} has already been invited to this workspace`, - email, + error: `${normalizedEmail} has already been invited to this workspace`, + email: normalizedEmail, }, { status: 400 } ) } - const token = generateId() - const expiresAt = new Date() - expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry - - const invitationData = { - id: generateId(), - workspaceId, - email, + const { invitationId, token } = await createPendingInvitation({ + kind: 'workspace', + email: normalizedEmail, inviterId: session.user.id, - role, - status: 'pending' as WorkspaceInvitationStatus, - token, - permissions: permission, - expiresAt, - createdAt: new Date(), - updatedAt: new Date(), - } - - await db.insert(workspaceInvitation).values(invitationData) + organizationId: workspaceDetails.organizationId, + role: 'member', + grants: [ + { + workspaceId, + permission, + }, + ], + }) try { PlatformEvents.workspaceMemberInvited({ workspaceId, invitedBy: session.user.id, - inviteeEmail: email, + inviteeEmail: normalizedEmail, role: permission, }) } catch { - // Telemetry should not fail the operation + // telemetry must not fail the operation } captureServerEvent( @@ -225,14 +242,25 @@ export async function POST(req: NextRequest) { } ) - await sendInvitationEmail({ - to: email, + const emailResult = await sendInvitationEmail({ + invitationId, + token, + kind: 'workspace', + email: normalizedEmail, inviterName: session.user.name || session.user.email || 'A user', - workspaceName: workspaceDetails.name, - invitationId: invitationData.id, - token: token, + organizationId: workspaceDetails.organizationId, + organizationRole: 'member', + grants: [{ workspaceId, permission }], }) + if (!emailResult.success) { + await cancelPendingInvitation(invitationId) + return NextResponse.json( + { error: emailResult.error || 'Failed to send invitation email' }, + { status: 502 } + ) + } + recordAudit({ workspaceId, actorId: session.user.id, @@ -241,18 +269,27 @@ export async function POST(req: NextRequest) { action: AuditAction.MEMBER_INVITED, resourceType: AuditResourceType.WORKSPACE, resourceId: workspaceId, - resourceName: email, - description: `Invited ${email} as ${permission}`, + resourceName: normalizedEmail, + description: `Invited ${normalizedEmail} as ${permission}`, metadata: { - targetEmail: email, + targetEmail: normalizedEmail, targetRole: permission, workspaceName: workspaceDetails.name, - invitationId: invitationData.id, + invitationId, }, request: req, }) - return NextResponse.json({ success: true, invitation: invitationData }) + return NextResponse.json({ + success: true, + invitation: { + id: invitationId, + workspaceId, + email: normalizedEmail, + permission, + expiresAt: undefined, + }, + }) } catch (error) { if (error instanceof InvitationsNotAllowedError) { return NextResponse.json({ error: error.message }, { status: 403 }) @@ -261,50 +298,3 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 }) } } - -async function sendInvitationEmail({ - to, - inviterName, - workspaceName, - invitationId, - token, -}: { - to: string - inviterName: string - workspaceName: string - invitationId: string - token: string -}) { - try { - const baseUrl = getBaseUrl() - const invitationLink = `${baseUrl}/invite/${invitationId}?token=${token}` - - const emailHtml = await render( - WorkspaceInvitationEmail({ - workspaceName, - inviterName, - invitationLink, - }) - ) - - const fromAddress = getFromEmailAddress() - - logger.info(`Attempting to send email from ${fromAddress} to ${to}`) - - const result = await sendEmail({ - to, - subject: `You've been invited to join "${workspaceName}" on Sim`, - html: emailHtml, - from: fromAddress, - emailType: 'transactional', - }) - - if (result.success) { - logger.info(`Invitation email sent successfully to ${to}`, { result }) - } else { - logger.error(`Failed to send invitation email to ${to}`, { error: result.message }) - } - } catch (error) { - logger.error('Error sending invitation email:', error) - } -} diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 8bce8063fad..ebdeecd9b51 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { permissions, settings, workflow, workspace } from '@sim/db/schema' +import { permissions, settings, type WorkspaceMode, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, desc, eq, isNull, sql } from 'drizzle-orm' @@ -12,6 +12,15 @@ import { captureServerEvent } from '@/lib/posthog/server' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { getRandomWorkspaceColor } from '@/lib/workspaces/colors' +import { + CONTACT_OWNER_TO_UPGRADE_REASON, + evaluateWorkspaceInvitePolicy, + getWorkspaceCreationPolicy, + getWorkspaceInvitePolicy, + hasActiveTeamOrEnterpriseSubscription, + UPGRADE_TO_INVITE_REASON, + WORKSPACE_MODE, +} from '@/lib/workspaces/policy' import type { WorkspaceScope } from '@/lib/workspaces/utils' const logger = createLogger('Workspaces') @@ -33,6 +42,13 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const activeOrganizationId = + (session.session as { activeOrganizationId?: string } | null)?.activeOrganizationId ?? null + const creationPolicy = await getWorkspaceCreationPolicy({ + userId: session.user.id, + activeOrganizationId, + }) + const scope = (new URL(request.url).searchParams.get('scope') ?? 'active') as WorkspaceScope if (!['active', 'archived', 'all'].includes(scope)) { return NextResponse.json({ error: 'Invalid scope' }, { status: 400 }) @@ -74,28 +90,83 @@ export async function GET(request: Request) { const lastActiveWorkspaceId = userSettings[0]?.lastActiveWorkspaceId ?? null if (scope === 'active' && userWorkspaces.length === 0) { - const defaultWorkspace = await createDefaultWorkspace(session.user.id, session.user.name) + if (!creationPolicy.canCreate) { + return NextResponse.json({ workspaces: [], lastActiveWorkspaceId, creationPolicy }) + } + + const defaultWorkspace = await createDefaultWorkspace( + session.user.id, + session.user.name, + creationPolicy + ) await migrateExistingWorkflows(session.user.id, defaultWorkspace.id) - return NextResponse.json({ workspaces: [defaultWorkspace], lastActiveWorkspaceId }) + const refreshedCreationPolicy = await getWorkspaceCreationPolicy({ + userId: session.user.id, + activeOrganizationId, + }) + + return NextResponse.json({ + workspaces: [defaultWorkspace], + lastActiveWorkspaceId, + creationPolicy: refreshedCreationPolicy, + }) } if (scope === 'active') { await ensureWorkflowsHaveWorkspace(session.user.id, userWorkspaces[0].workspace.id) } - const workspacesWithPermissions = userWorkspaces.map( - ({ workspace: workspaceDetails, permissionType }) => ({ - ...workspaceDetails, - role: permissionType === 'admin' ? 'owner' : 'member', - permissions: permissionType, + const grandfatheredBilledUserIds = [ + ...new Set( + userWorkspaces + .filter(({ workspace: ws }) => ws.workspaceMode === WORKSPACE_MODE.GRANDFATHERED_SHARED) + .map(({ workspace: ws }) => ws.billedAccountUserId) + ), + ] + const teamOrEnterpriseByUser = new Map() + await Promise.all( + grandfatheredBilledUserIds.map(async (userId) => { + teamOrEnterpriseByUser.set(userId, await hasActiveTeamOrEnterpriseSubscription(userId)) }) ) + const workspacesWithPermissions = userWorkspaces.map( + ({ workspace: workspaceDetails, permissionType }) => { + const invitePolicy = evaluateWorkspaceInvitePolicy(workspaceDetails, { + billedUserHasTeamOrEnterprise: + teamOrEnterpriseByUser.get(workspaceDetails.billedAccountUserId) ?? false, + }) + const callerIsBilledUser = workspaceDetails.billedAccountUserId === session.user.id + + const canActOnUpgrade = invitePolicy.upgradeRequired && callerIsBilledUser + const inviteDisabledReason = invitePolicy.allowed + ? null + : callerIsBilledUser + ? (invitePolicy.reason ?? UPGRADE_TO_INVITE_REASON) + : CONTACT_OWNER_TO_UPGRADE_REASON + + return { + ...workspaceDetails, + role: + workspaceDetails.ownerId === session.user.id + ? 'owner' + : permissionType === 'admin' + ? 'admin' + : 'member', + permissions: permissionType, + inviteMembersEnabled: invitePolicy.allowed, + inviteDisabledReason, + inviteUpgradeRequired: canActOnUpgrade, + } + } + ) + return NextResponse.json({ workspaces: workspacesWithPermissions, lastActiveWorkspaceId, + creationPolicy, }) } @@ -109,13 +180,39 @@ export async function POST(req: Request) { try { const { name, color, skipDefaultWorkflow } = createWorkspaceSchema.parse(await req.json()) + const activeOrganizationId = + (session.session as { activeOrganizationId?: string } | null)?.activeOrganizationId ?? null + const creationPolicy = await getWorkspaceCreationPolicy({ + userId: session.user.id, + activeOrganizationId, + }) - const newWorkspace = await createWorkspace(session.user.id, name, skipDefaultWorkflow, color) + if (!creationPolicy.canCreate) { + return NextResponse.json( + { error: creationPolicy.reason || 'Workspace creation is not available.' }, + { status: creationPolicy.status } + ) + } + + const newWorkspace = await createWorkspace({ + userId: session.user.id, + name, + skipDefaultWorkflow, + explicitColor: color, + organizationId: creationPolicy.organizationId, + workspaceMode: creationPolicy.workspaceMode, + billedAccountUserId: creationPolicy.billedAccountUserId, + }) captureServerEvent( session.user.id, 'workspace_created', - { workspace_id: newWorkspace.id, name: newWorkspace.name }, + { + workspace_id: newWorkspace.id, + name: newWorkspace.name, + workspace_mode: newWorkspace.workspaceMode, + organization_id: newWorkspace.organizationId, + }, { groups: { workspace: newWorkspace.id }, setOnce: { first_workspace_created_at: new Date().toISOString() }, @@ -132,7 +229,12 @@ export async function POST(req: Request) { resourceId: newWorkspace.id, resourceName: newWorkspace.name, description: `Created workspace "${newWorkspace.name}"`, - metadata: { name: newWorkspace.name, color: newWorkspace.color }, + metadata: { + name: newWorkspace.name, + color: newWorkspace.color, + workspaceMode: newWorkspace.workspaceMode, + organizationId: newWorkspace.organizationId, + }, request: req, }) @@ -143,18 +245,45 @@ export async function POST(req: Request) { } } -async function createDefaultWorkspace(userId: string, userName?: string | null) { +async function createDefaultWorkspace( + userId: string, + userName: string | null | undefined, + creationPolicy: { + organizationId: string | null + workspaceMode: WorkspaceMode + billedAccountUserId: string + } +) { const firstName = userName?.split(' ')[0] || null const workspaceName = firstName ? `${firstName}'s Workspace` : 'My Workspace' - return createWorkspace(userId, workspaceName) + return createWorkspace({ + userId, + name: workspaceName, + organizationId: creationPolicy.organizationId, + workspaceMode: creationPolicy.workspaceMode, + billedAccountUserId: creationPolicy.billedAccountUserId, + }) } -async function createWorkspace( - userId: string, - name: string, - skipDefaultWorkflow = false, +interface CreateWorkspaceParams { + userId: string + name: string + skipDefaultWorkflow?: boolean explicitColor?: string -) { + organizationId: string | null + workspaceMode: WorkspaceMode + billedAccountUserId: string +} + +async function createWorkspace({ + userId, + name, + skipDefaultWorkflow = false, + explicitColor, + organizationId, + workspaceMode, + billedAccountUserId, +}: CreateWorkspaceParams) { const workspaceId = generateId() const workflowId = generateId() const now = new Date() @@ -167,21 +296,43 @@ async function createWorkspace( name, color, ownerId: userId, - billedAccountUserId: userId, + organizationId, + workspaceMode, + billedAccountUserId, allowPersonalApiKeys: true, createdAt: now, updatedAt: now, }) - await tx.insert(permissions).values({ - id: generateId(), - entityType: 'workspace' as const, - entityId: workspaceId, - userId: userId, - permissionType: 'admin' as const, - createdAt: now, - updatedAt: now, - }) + const permissionRows = [ + { + id: generateId(), + entityType: 'workspace' as const, + entityId: workspaceId, + userId, + permissionType: 'admin' as const, + createdAt: now, + updatedAt: now, + }, + ] + + if ( + workspaceMode === WORKSPACE_MODE.ORGANIZATION && + billedAccountUserId && + billedAccountUserId !== userId + ) { + permissionRows.push({ + id: generateId(), + entityType: 'workspace' as const, + entityId: workspaceId, + userId: billedAccountUserId, + permissionType: 'admin' as const, + createdAt: now, + updatedAt: now, + }) + } + + await tx.insert(permissions).values(permissionRows) if (!skipDefaultWorkflow) { await tx.insert(workflow).values({ @@ -206,8 +357,8 @@ async function createWorkspace( logger.info( skipDefaultWorkflow - ? `Created workspace ${workspaceId} for user ${userId}` - : `Created workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}` + ? `Created ${workspaceMode} workspace ${workspaceId} for user ${userId}` + : `Created ${workspaceMode} workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}` ) }) } catch (error) { @@ -225,16 +376,36 @@ async function createWorkspace( // Telemetry should not fail the operation } + const invitePolicy = await getWorkspaceInvitePolicy({ + organizationId, + workspaceMode, + billedAccountUserId, + ownerId: userId, + }) + const callerIsBilledUser = billedAccountUserId === userId + const canActOnUpgrade = invitePolicy.upgradeRequired && callerIsBilledUser + const inviteDisabledReason = invitePolicy.allowed + ? null + : callerIsBilledUser + ? (invitePolicy.reason ?? UPGRADE_TO_INVITE_REASON) + : CONTACT_OWNER_TO_UPGRADE_REASON + return { id: workspaceId, name, color, ownerId: userId, - billedAccountUserId: userId, + organizationId, + workspaceMode, + billedAccountUserId, allowPersonalApiKeys: true, createdAt: now, updatedAt: now, role: 'owner', + permissions: 'admin', + inviteMembersEnabled: invitePolicy.allowed, + inviteDisabledReason, + inviteUpgradeRequired: canActOnUpgrade, } } diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index ddf458a7dc6..718b314c4d5 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -11,7 +11,6 @@ import { subscriptionKeys } from '@/hooks/queries/subscription' const logger = createLogger('InviteById') -/** Error codes that can occur during invitation processing */ type InviteErrorCode = | 'missing-token' | 'invalid-token' @@ -22,6 +21,7 @@ type InviteErrorCode = | 'user-not-found' | 'already-member' | 'already-in-organization' + | 'no-seats-available' | 'invalid-invitation' | 'missing-invitation-id' | 'server-error' @@ -37,10 +37,27 @@ interface InviteError { canRetry?: boolean } -/** - * Maps error codes to user-friendly error objects with contextual information - */ -function getInviteError(reason: string): InviteError { +type InvitationKind = 'organization' | 'workspace' + +interface InvitationDetails { + id: string + kind: InvitationKind + email: string + organizationId: string | null + organizationName: string | null + role: string + status: string + expiresAt: string + inviterName: string | null + inviterEmail: string | null + grants: Array<{ + workspaceId: string + workspaceName: string | null + permission: 'admin' | 'write' | 'read' + }> +} + +function getInviteError(code: string): InviteError { const errorMap: Record = { 'missing-token': { code: 'missing-token', @@ -82,14 +99,18 @@ function getInviteError(reason: string): InviteError { message: 'You are already a member of an organization. Leave your current organization before accepting a new invitation.', }, + 'no-seats-available': { + code: 'no-seats-available', + message: + 'This organization has no available seats right now. Ask an admin to add seats or retry after capacity changes.', + }, 'invalid-invitation': { code: 'invalid-invitation', message: 'This invitation is invalid or no longer exists.', }, - 'missing-invitation-id': { - code: 'missing-invitation-id', - message: - 'The invitation link is missing required information. Please use the original invitation link.', + 'not-found': { + code: 'invalid-invitation', + message: 'This invitation is invalid or no longer exists.', }, 'server-error': { code: 'server-error', @@ -117,7 +138,7 @@ function getInviteError(reason: string): InviteError { } return ( - errorMap[reason] || { + errorMap[code] || { code: 'unknown', message: 'An unexpected error occurred while processing your invitation. Please try again or contact support.', @@ -126,40 +147,12 @@ function getInviteError(reason: string): InviteError { ) } -/** - * Parses API error responses and extracts a standardized error code - */ -function parseApiError(error: unknown, statusCode?: number): InviteErrorCode { - // Handle network/fetch errors - if (error instanceof TypeError && error.message.includes('fetch')) { - return 'network-error' - } - - // Handle error message patterns first (more specific matching) - const errorMessage = - typeof error === 'string' ? error.toLowerCase() : (error as Error)?.message?.toLowerCase() || '' - - // Check specific patterns before falling back to status codes - // Order matters: more specific patterns must come first - if (errorMessage.includes('already a member of an organization')) return 'already-in-organization' - if (errorMessage.includes('already a member')) return 'already-member' - if (errorMessage.includes('email mismatch') || errorMessage.includes('different email')) - return 'email-mismatch' - if (errorMessage.includes('already processed')) return 'already-processed' - if (errorMessage.includes('unauthorized')) return 'unauthorized' - if (errorMessage.includes('forbidden') || errorMessage.includes('permission')) return 'forbidden' - if (errorMessage.includes('not found') || errorMessage.includes('expired')) - return 'invalid-invitation' - - // Handle HTTP status codes as fallback - if (statusCode) { - if (statusCode === 401) return 'unauthorized' - if (statusCode === 403) return 'forbidden' - if (statusCode === 404) return 'invalid-invitation' - if (statusCode === 409) return 'already-in-organization' - if (statusCode >= 500) return 'server-error' - } - +function codeFromStatus(status: number): InviteErrorCode { + if (status === 401) return 'unauthorized' + if (status === 403) return 'forbidden' + if (status === 404) return 'invalid-invitation' + if (status === 409) return 'already-in-organization' + if (status >= 500) return 'server-error' return 'unknown' } @@ -167,18 +160,17 @@ export default function Invite() { const router = useRouter() const params = useParams() const inviteId = params.id as string + const inviteTokenStorageKey = `inviteToken:${inviteId}` const searchParams = useSearchParams() const { data: session, isPending } = useSession() const queryClient = useQueryClient() - const [invitationDetails, setInvitationDetails] = useState(null) + const [invitation, setInvitation] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [isAccepting, setIsAccepting] = useState(false) const [accepted, setAccepted] = useState(false) const [isNewUser, setIsNewUser] = useState(false) const [token, setToken] = useState(null) - const [invitationType, setInvitationType] = useState<'organization' | 'workspace'>('workspace') - const [currentOrgName, setCurrentOrgName] = useState(null) useEffect(() => { const errorReason = searchParams.get('error') @@ -188,10 +180,10 @@ export default function Invite() { const tokenFromQuery = searchParams.get('token') if (tokenFromQuery) { setToken(tokenFromQuery) - sessionStorage.setItem('inviteToken', tokenFromQuery) + sessionStorage.setItem(inviteTokenStorageKey, tokenFromQuery) } else { - const storedToken = sessionStorage.getItem('inviteToken') - if (storedToken && storedToken !== inviteId) { + const storedToken = sessionStorage.getItem(inviteTokenStorageKey) + if (storedToken) { setToken(storedToken) } } @@ -199,192 +191,95 @@ export default function Invite() { if (errorReason) { setError(getInviteError(errorReason)) setIsLoading(false) - return } - }, [searchParams, inviteId]) + }, [searchParams, inviteId, inviteTokenStorageKey]) useEffect(() => { if (!session?.user) return - async function fetchInvitationDetails() { + async function fetchInvitation() { setIsLoading(true) try { - const workspaceInviteResponse = await fetch(`/api/workspaces/invitations/${inviteId}`, { - method: 'GET', - }) - - if (workspaceInviteResponse.ok) { - const data = await workspaceInviteResponse.json() - setInvitationType('workspace') - setInvitationDetails({ - type: 'workspace', - data, - name: data.workspaceName || 'a workspace', - }) - setIsLoading(false) - return - } + const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '' + const response = await fetch(`/api/invitations/${inviteId}${tokenParam}`) - if (!workspaceInviteResponse.ok && workspaceInviteResponse.status !== 404) { - const errorCode = parseApiError(null, workspaceInviteResponse.status) - const errorData = await workspaceInviteResponse.json().catch(() => ({})) - logger.error('Workspace invitation fetch failed:', { - status: workspaceInviteResponse.status, - error: errorData, - }) - - if (errorData.error) { - const refinedCode = parseApiError(errorData.error, workspaceInviteResponse.status) - setError(getInviteError(refinedCode)) - } else { - setError(getInviteError(errorCode)) - } + if (!response.ok) { + const data = await response.json().catch(() => ({})) + const code = data.error || codeFromStatus(response.status) + setError(getInviteError(code)) setIsLoading(false) return } - try { - const { data, error: orgError } = await client.organization.getInvitation({ - query: { id: inviteId }, - }) - - if (orgError) { - logger.error('Organization invitation fetch error:', orgError) - const errorCode = parseApiError(orgError.message || orgError) - throw { code: errorCode, original: orgError } - } - - if (data) { - setInvitationType('organization') - - const activeOrgResponse = await client.organization - .getFullOrganization() - .catch(() => ({ data: null })) - - if (activeOrgResponse?.data) { - setCurrentOrgName(activeOrgResponse.data.name) - setError(getInviteError('already-in-organization')) - setIsLoading(false) - return - } - - setInvitationDetails({ - type: 'organization', - data, - name: data.organizationName || 'an organization', - }) - - if (data.organizationId) { - const orgResponse = await client.organization.getFullOrganization({ - query: { organizationId: data.organizationId }, - }) - - if (orgResponse.data) { - setInvitationDetails((prev: any) => ({ - ...prev, - name: orgResponse.data.name || 'an organization', - })) - } - } - } else { - throw { code: 'invalid-invitation' } - } - } catch (orgErr: any) { - if (orgErr.code) { - throw orgErr - } - throw { code: parseApiError(orgErr) } - } - } catch (err: any) { - logger.error('Error fetching invitation:', err) - const errorCode = err.code || parseApiError(err) - setError(getInviteError(errorCode)) + const data = await response.json() + setInvitation(data.invitation as InvitationDetails) + setError(null) + } catch (fetchError) { + logger.error('Error fetching invitation:', fetchError) + setError(getInviteError('network-error')) } finally { setIsLoading(false) } } - fetchInvitationDetails() - }, [session?.user, inviteId]) + fetchInvitation() + }, [session?.user, inviteId, token]) const handleAcceptInvitation = async () => { - if (!session?.user) return - + if (!session?.user || !invitation) return setIsAccepting(true) - if (invitationType === 'workspace') { - window.location.href = `/api/workspaces/invitations/${encodeURIComponent(inviteId)}?token=${encodeURIComponent(token || '')}` - } else { - try { - const orgId = invitationDetails?.data?.organizationId + try { + const response = await fetch(`/api/invitations/${inviteId}/accept`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ token: token ?? undefined }), + }) - if (!orgId) { - setError(getInviteError('invalid-invitation')) - setIsAccepting(false) - return - } + if (!response.ok) { + const data = await response.json().catch(() => ({})) + const code = data.error || codeFromStatus(response.status) + setError(getInviteError(code)) + setIsAccepting(false) + return + } - const response = await fetch(`/api/organizations/${orgId}/invitations/${inviteId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify({ status: 'accepted' }), - }) + const data = await response.json() - if (!response.ok) { - const data = await response.json().catch(() => ({})) - const errorCode = parseApiError(data.error || '', response.status) - logger.error('Failed to accept organization invitation:', { - status: response.status, - error: data, - }) - setError(getInviteError(errorCode)) - setIsAccepting(false) - return + if (invitation.organizationId) { + try { + await client.organization.setActive({ organizationId: invitation.organizationId }) + } catch (setActiveError) { + logger.warn('Failed to set active organization after accept', setActiveError) } + } - await client.organization.setActive({ - organizationId: orgId, - }) - - // Invalidate billing / org caches so `/workspace` doesn't flash the - // user's pre-join personal subscription while the new team-scoped - // data is being refetched. Accept-flow side effects (snapshot, - // storage transfer, plan sync, member insert) have already - // committed by the time we reach here. - await Promise.all([ - queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }), - queryClient.invalidateQueries({ queryKey: organizationKeys.all }), - ]) - - setAccepted(true) - - setTimeout(() => { - router.push('/workspace') - }, 2000) - } catch (err: any) { - logger.error('Error accepting invitation:', err) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }), + queryClient.invalidateQueries({ queryKey: organizationKeys.all }), + ]) - setAccepted(false) + setAccepted(true) + setIsAccepting(false) - const errorCode = parseApiError(err) - setError(getInviteError(errorCode)) - setIsAccepting(false) - } + const redirectPath = typeof data.redirectPath === 'string' ? data.redirectPath : '/workspace' + setTimeout(() => router.push(redirectPath), 1200) + } catch (acceptError) { + logger.error('Error accepting invitation:', acceptError) + setError(getInviteError('network-error')) + setIsAccepting(false) } } const getCallbackUrl = () => { const effectiveToken = - token || sessionStorage.getItem('inviteToken') || searchParams.get('token') - return `/invite/${inviteId}${effectiveToken && effectiveToken !== inviteId ? `?token=${effectiveToken}` : ''}` + token || sessionStorage.getItem(inviteTokenStorageKey) || searchParams.get('token') + return `/invite/${inviteId}${effectiveToken ? `?token=${effectiveToken}` : ''}` } if (!session?.user && !isPending) { const callbackUrl = encodeURIComponent(getCallbackUrl()) - return ( router.push('/workspace'), - }, - { - label: 'Return to Home', - onClick: () => router.push('/'), + label: 'Sign in with a different account', + onClick: async () => { + await client.signOut() + router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`) + }, }, + { label: 'Return to Home', onClick: () => router.push('/') }, ]} /> ) } - if (error.code === 'email-mismatch') { + if (error.code === 'already-in-organization') { return ( { - await client.signOut() - router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`) - }, - }, - { - label: 'Return to Home', - onClick: () => router.push('/'), - }, + { label: 'Manage Team Settings', onClick: () => router.push('/workspace') }, + { label: 'Return to Home', onClick: () => router.push('/') }, ]} /> @@ -513,32 +395,18 @@ export default function Invite() { label: 'Create an account', onClick: () => router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`), }, - { - label: 'Return to Home', - onClick: () => router.push('/'), - }, + { label: 'Return to Home', onClick: () => router.push('/') }, ]} /> ) } - const actions: Array<{ - label: string - onClick: () => void - }> = [] - + const actions: Array<{ label: string; onClick: () => void }> = [] if (error.canRetry) { - actions.push({ - label: 'Try Again', - onClick: () => window.location.reload(), - }) + actions.push({ label: 'Try Again', onClick: () => window.location.reload() }) } - - actions.push({ - label: 'Return to Home', - onClick: () => router.push('/'), - }) + actions.push({ label: 'Return to Home', onClick: () => router.push('/') }) return ( @@ -554,34 +422,34 @@ export default function Invite() { ) } - if (accepted && !error) { + const displayName = + invitation?.kind === 'workspace' + ? invitation.grants[0]?.workspaceName || 'a workspace' + : invitation?.organizationName || 'an organization' + + if (accepted) { return ( router.push('/'), - }, - ]} + actions={[{ label: 'Return to Home', onClick: () => router.push('/') }]} /> ) } + const isOrg = invitation?.kind === 'organization' + return ( router.push('/'), - }, + { label: 'Return to Home', onClick: () => router.push('/') }, ]} /> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index de5096caefd..560f253ed90 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -189,7 +189,7 @@ export function SettingsPage({ section }: SettingsPageProps) { const isAdminRole = session?.user?.role === 'admin' const effectiveSection = - !isBillingEnabled && (section === 'subscription' || section === 'team') + !isBillingEnabled && (section === 'subscription' || section === 'organization') ? 'general' : section === 'credential-sets' && !isCredentialSetsEnabled ? 'general' @@ -219,7 +219,7 @@ export function SettingsPage({ section }: SettingsPageProps) { {effectiveSection === 'audit-logs' && } {effectiveSection === 'apikeys' && } {isBillingEnabled && effectiveSection === 'subscription' && } - {isBillingEnabled && effectiveSection === 'team' && } + {isBillingEnabled && effectiveSection === 'organization' && } {effectiveSection === 'sso' && } {effectiveSection === 'whitelabeling' && } {effectiveSection === 'byok' && } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/plan-configs.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/plan-configs.ts index 054a6de576d..cff8770bf8a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/plan-configs.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/plan-configs.ts @@ -2,6 +2,7 @@ import { Clock, HardDrive, HeadphonesIcon, + LayoutGrid, Server, ShieldCheck, Table2, @@ -18,6 +19,7 @@ export const PRO_PLAN_FEATURES: PlanFeature[] = [ { icon: Timer, text: '50 min sync run limit' }, { icon: HardDrive, text: '50GB file storage' }, { icon: Table2, text: '25 tables · 5,000 rows each' }, + { icon: LayoutGrid, text: 'Up to 3 personal workspaces' }, ] export const MAX_PLAN_FEATURES: PlanFeature[] = [ @@ -26,6 +28,7 @@ export const MAX_PLAN_FEATURES: PlanFeature[] = [ { icon: Timer, text: '50 min sync run limit' }, { icon: HardDrive, text: '500GB file storage' }, { icon: Table2, text: '25 tables · 5,000 rows each' }, + { icon: LayoutGrid, text: 'Up to 10 personal workspaces' }, ] export const TEAM_INLINE_FEATURES: PlanFeature[] = [ @@ -33,12 +36,14 @@ export const TEAM_INLINE_FEATURES: PlanFeature[] = [ { icon: Zap, text: 'Max plan rate limits' }, { icon: HardDrive, text: 'Max plan file storage' }, { icon: Table2, text: '100 tables · 10,000 rows each' }, + { icon: LayoutGrid, text: 'Unlimited shared workspaces' }, { icon: ShieldCheck, text: 'Access controls' }, { icon: SlackMonoIcon, text: 'Dedicated Slack channel' }, ] export const ENTERPRISE_PLAN_FEATURES: PlanFeature[] = [ { icon: Zap, text: 'Custom infra limits' }, + { icon: LayoutGrid, text: 'Unlimited shared workspaces' }, { icon: Server, text: 'SSO' }, { icon: ShieldCheck, text: 'SOC2' }, { icon: HardDrive, text: 'Self hosting' }, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx index 42581c86ff2..377d17e8372 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx @@ -45,7 +45,6 @@ import { } from '@/lib/billing/subscriptions/utils' import { cn } from '@/lib/core/utils/cn' import { getBaseUrl } from '@/lib/core/utils/urls' -import { getUserRole } from '@/lib/workspaces/organization/utils' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { CreditBalance, @@ -271,7 +270,7 @@ export function Subscription() { data: subscriptionData, isLoading: isSubscriptionLoading, refetch: refetchSubscription, - } = useSubscriptionData() + } = useSubscriptionData({ includeOrg: true }) const { data: usageLimitResponse, isLoading: isUsageLimitLoading } = useUsageLimitData() const { data: workspaceData, isLoading: isWorkspaceLoading } = useWorkspaceSettings(workspaceId) const updateWorkspaceMutation = useUpdateWorkspaceSettings() @@ -279,9 +278,12 @@ export function Subscription() { const { data: orgsData } = useOrganizations() const activeOrganization = orgsData?.activeOrganization const activeOrgId = activeOrganization?.id + const workspaceOrganizationId = workspaceData?.settings?.workspace?.organizationId ?? null + const billingOrganizationId = + workspaceOrganizationId ?? subscriptionData?.data?.organization?.id ?? activeOrgId ?? null const { data: organizationBillingData, isLoading: isOrgBillingLoading } = useOrganizationBilling( - activeOrgId || '' + billingOrganizationId || '' ) const openBillingPortal = useOpenBillingPortal() @@ -343,6 +345,9 @@ export function Subscription() { const isCritical = isBlocked || usage.percentUsed >= USAGE_THRESHOLDS.CRITICAL const billedAccountUserId = workspaceData?.settings?.workspace?.billedAccountUserId ?? null + const isOrganizationWorkspace = + workspaceData?.settings?.workspace?.organizationId != null && + workspaceData?.settings?.workspace?.workspaceMode === 'organization' const workspaceAdmins: WorkspaceAdmin[] = workspaceData?.permissions?.users?.filter( (user: WorkspaceAdmin) => user.permissionType === 'admin' @@ -365,8 +370,9 @@ export function Subscription() { } }, [subscriptionData?.data?.billingInterval]) - const userRole = getUserRole(activeOrganization, session?.user?.email) + const userRole = subscriptionData?.data?.organization?.role ?? 'member' const isTeamAdmin = ['owner', 'admin'].includes(userRole) + const shouldUseOrganizationBillingContext = subscription.isOrgScoped && isTeamAdmin const planIncludedAmount = subscription.isOrgScoped && isTeamAdmin && organizationBillingData?.data @@ -390,22 +396,26 @@ export function Subscription() { const handleToggleOnDemand = useCallback(async () => { try { - const isOrgContext = subscription.isOrgScoped && isTeamAdmin && activeOrgId + if (shouldUseOrganizationBillingContext && !billingOrganizationId) { + throw new Error( + 'Organization billing context is unavailable. Please refresh and try again.' + ) + } if (isOnDemandActive) { if (!canDisableOnDemand) return - if (isOrgContext) { + if (shouldUseOrganizationBillingContext) { await updateOrgLimit.mutateAsync({ - organizationId: activeOrgId!, + organizationId: billingOrganizationId!, limit: planIncludedAmount, }) } else { await updateUserLimit.mutateAsync({ limit: planIncludedAmount }) } } else { - if (isOrgContext) { + if (shouldUseOrganizationBillingContext) { await updateOrgLimit.mutateAsync({ - organizationId: activeOrgId!, + organizationId: billingOrganizationId!, limit: ON_DEMAND_UNLIMITED, }) } else { @@ -419,9 +429,8 @@ export function Subscription() { }, [ isOnDemandActive, canDisableOnDemand, - subscription.isOrgScoped, - isTeamAdmin, - activeOrgId, + shouldUseOrganizationBillingContext, + billingOrganizationId, planIncludedAmount, logger, ]) @@ -503,10 +512,14 @@ export function Subscription() { } if (isBlocked) { const context = subscription.isOrgScoped ? 'organization' : 'user' + if (context === 'organization' && !billingOrganizationId) { + alert('Organization billing context is unavailable. Please refresh and try again.') + return + } openBillingPortal.mutate( { context, - organizationId: activeOrgId, + organizationId: billingOrganizationId ?? undefined, returnUrl: `${getBaseUrl()}/workspace?billing=updated`, }, { @@ -530,7 +543,7 @@ export function Subscription() { isBlocked, subscription.isFree, subscription.isOrgScoped, - activeOrgId, + billingOrganizationId, doUpgrade, logger, ]) @@ -621,8 +634,12 @@ export function Subscription() { ? organizationBillingData.data.minimumBillingAmount : usageLimitData.minimumLimit } - context={subscription.isOrgScoped && isTeamAdmin ? 'organization' : 'user'} - organizationId={subscription.isOrgScoped && isTeamAdmin ? activeOrgId : undefined} + context={shouldUseOrganizationBillingContext ? 'organization' : 'user'} + organizationId={ + shouldUseOrganizationBillingContext + ? (billingOrganizationId ?? undefined) + : undefined + } onLimitUpdated={() => logger.info('Usage limit updated')} /> ) : undefined @@ -880,7 +897,7 @@ export function Subscription() { onGetForTeam={() => { setManagePlanModalOpen(false) if (subscription.isTeam) { - window.location.href = `/workspace/${workspaceId}/settings/team` + window.location.href = `/workspace/${workspaceId}/settings/organization` } else { setTeamModalOpen(true) } @@ -891,8 +908,17 @@ export function Subscription() { setManagePlanModalOpen(false) if (!betterAuthSubscription.cancel) return try { - const isOrgSub = subscription.isOrgScoped && activeOrgId - const referenceId = isOrgSub ? activeOrgId : session?.user?.id || '' + const isOrgSub = subscription.isOrgScoped + const referenceId = isOrgSub + ? (() => { + if (!billingOrganizationId) { + throw new Error( + 'Organization billing context is unavailable. Please refresh and try again.' + ) + } + return billingOrganizationId + })() + : session?.user?.id || '' const returnUrl = getBaseUrl() + window.location.pathname await betterAuthSubscription.cancel({ returnUrl, referenceId }) } catch (e) { @@ -903,8 +929,17 @@ export function Subscription() { onRestore={async () => { if (!betterAuthSubscription.restore) return try { - const isOrgSub = subscription.isOrgScoped && activeOrgId - const referenceId = isOrgSub ? activeOrgId : session?.user?.id || '' + const isOrgSub = subscription.isOrgScoped + const referenceId = isOrgSub + ? (() => { + if (!billingOrganizationId) { + throw new Error( + 'Organization billing context is unavailable. Please refresh and try again.' + ) + } + return billingOrganizationId + })() + : session?.user?.id || '' await betterAuthSubscription.restore({ referenceId }) await refetchSubscription() setManagePlanModalOpen(false) @@ -951,43 +986,94 @@ export function Subscription() { {subscription.isPaid && !permissions.showTeamMemberView && !permissions.isEnterpriseMember && ( -
- - +
+ +
+ + -
+ openBillingPortal.mutate( + { + context, + organizationId: billingOrganizationId ?? undefined, + returnUrl: window.location.href, + }, + { + onSuccess: (data) => { + if (portalWindow) { + portalWindow.location.href = data.url + } else { + window.location.href = data.url + } + }, + onError: (error) => { + portalWindow?.close() + logger.error('Failed to open billing portal', { error }) + alert(error.message) + }, + } + ) + }} + > + View Invoices + + + )} - {!isLoading && isTeamAdmin && ( + {!isLoading && isTeamAdmin && !isOrganizationWorkspace && (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/index.ts index ecc8bb8c2f2..68a3f455080 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/index.ts @@ -1,6 +1,7 @@ export { MemberInvitationCard } from './member-invitation-card/member-invitation-card' export { NoOrganizationView } from './no-organization-view/no-organization-view' +export { OrganizationRoster } from './organization-roster/organization-roster' export { RemoveMemberDialog } from './remove-member-dialog/remove-member-dialog' -export { TeamMembers } from './team-members/team-members' export { TeamSeats } from './team-seats/team-seats' export { TeamSeatsOverview } from './team-seats-overview/team-seats-overview' +export { TransferOwnershipDialog } from './transfer-ownership-dialog/transfer-ownership-dialog' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/member-invitation-card/member-invitation-card.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/member-invitation-card/member-invitation-card.tsx index 12b2560eff8..9b7ed4f4a2a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/member-invitation-card/member-invitation-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/member-invitation-card/member-invitation-card.tsx @@ -1,11 +1,8 @@ 'use client' -import React from 'react' import { ChevronDown } from 'lucide-react' import { Button, - ButtonGroup, - ButtonGroupItem, Checkbox, DropdownMenu, DropdownMenuContent, @@ -14,56 +11,11 @@ import { TagInput, type TagItem, } from '@/components/emcn' +import { PermissionSelector, type PermissionType } from '@/components/permissions' import { cn } from '@/lib/core/utils/cn' import { quickValidateEmail } from '@/lib/messaging/email/validation' import type { AdminWorkspace } from '@/hooks/queries/workspace' -type PermissionType = 'read' | 'write' | 'admin' - -interface PermissionSelectorProps { - value: PermissionType - onChange: (value: PermissionType) => void - disabled?: boolean - className?: string -} - -const PermissionSelector = React.memo( - ({ value, onChange, disabled = false, className = '' }) => { - return ( - onChange(val as PermissionType)} - disabled={disabled} - className={className} - > - - Read - - - Write - - - Admin - - - ) - } -) - -PermissionSelector.displayName = 'PermissionSelector' - interface MemberInvitationCardProps { inviteEmails: TagItem[] setInviteEmails: (emails: TagItem[]) => void @@ -122,9 +74,9 @@ export function MemberInvitationCard({ return (
-

Invite Team Members

+

Invite Members

- Add new members to your team and optionally give them access to specific workspaces + Invite people to your organization and choose which workspaces they can access.

@@ -228,6 +180,7 @@ export function MemberInvitationCard({ } onChange={(permission) => onWorkspaceToggle(workspace.id, permission)} disabled={isInviting} + size='compact' />
)} @@ -241,12 +194,24 @@ export function MemberInvitationCard({
+ {selectedCount === 0 && ( +

+ Select at least one organization workspace before sending an organization invite. +

+ )} + {invitationError && (

{invitationError instanceof Error && invitationError.message diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-roster/organization-roster.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-roster/organization-roster.tsx new file mode 100644 index 00000000000..e448baf11fe --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-roster/organization-roster.tsx @@ -0,0 +1,753 @@ +'use client' + +import { Fragment, useEffect, useMemo, useRef, useState } from 'react' +import { createLogger } from '@sim/logger' +import { ChevronDown, ChevronRight, Search } from 'lucide-react' +import { + Avatar, + AvatarFallback, + AvatarImage, + Badge, + Button, + Input, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Tooltip, +} from '@/components/emcn' +import { + type OrgRole, + OrgRoleSelector, + PermissionSelector, + type PermissionType, +} from '@/components/permissions' +import { CREDIT_MULTIPLIER, dollarsToCredits } from '@/lib/billing/credits/conversion' +import { cn } from '@/lib/core/utils/cn' +import { getUserColor } from '@/lib/workspaces/colors' +import type { Member } from '@/lib/workspaces/organization' +import { useUpdateWorkspacePermissions } from '@/hooks/queries/invitations' +import { + type OrganizationRoster as OrganizationRosterData, + type RosterMember, + type RosterPendingInvitation, + type RosterWorkspaceAccess, + useCancelInvitation, + useOrganizationMembers, + useResendInvitation, + useUpdateInvitation, + useUpdateOrganizationMemberRole, +} from '@/hooks/queries/organization' + +const logger = createLogger('OrganizationRoster') + +interface OrganizationRosterProps { + organizationId: string + roster: OrganizationRosterData | null | undefined + isLoadingRoster: boolean + currentUserEmail: string + currentUserId: string + isAdminOrOwner: boolean + onRemoveMember: (member: Member) => void + onTransferOwnership?: () => void +} + +function apportionCredits( + dollarsByUser: Record, + totalCredits: number +): Record { + const entries = Object.entries(dollarsByUser).map(([userId, dollars]) => { + const exact = dollars * CREDIT_MULTIPLIER + return { userId, floor: Math.floor(exact), remainder: exact - Math.floor(exact) } + }) + let floorSum = entries.reduce((s, e) => s + e.floor, 0) + const gap = totalCredits - floorSum + entries.sort((a, b) => b.remainder - a.remainder) + for (let i = 0; i < gap && i < entries.length; i++) { + entries[i].floor += 1 + floorSum += 1 + } + const result: Record = {} + for (const e of entries) result[e.userId] = e.floor + return result +} + +function RoleBadge({ role }: { role: string }) { + const variant = role === 'owner' ? 'blue-secondary' : 'gray-secondary' + return ( + + {role.charAt(0).toUpperCase() + role.slice(1)} + + ) +} + +function MemberIdentity({ + name, + email, + image, + userId, + trailing, +}: { + name: string + email: string + image?: string | null + userId: string + trailing?: React.ReactNode +}) { + return ( +

+ + {image && } + + {name.charAt(0).toUpperCase()} + + +
+
+ {name} + {trailing} +
+
{email}
+
+
+ ) +} + +function ChevronCell({ expanded, onClick }: { expanded: boolean; onClick: () => void }) { + return ( + + ) +} + +function RosterRowSkeleton() { + return ( + + + + + +
+ +
+ + +
+
+
+ + + + + + +
+ ) +} + +function MemberExpandedPanel({ + userId, + memberRole, + workspaces, + currentUserId, + currentUserAdminWorkspaceIds, + organizationId, +}: { + userId: string + memberRole: string + workspaces: RosterWorkspaceAccess[] + currentUserId: string + currentUserAdminWorkspaceIds: Set + organizationId: string +}) { + const updatePermissions = useUpdateWorkspacePermissions() + const rowUserIsOrgAdmin = memberRole === 'owner' || memberRole === 'admin' + + const handleChange = async (workspaceId: string, next: PermissionType) => { + try { + await updatePermissions.mutateAsync({ + workspaceId, + organizationId, + updates: [{ userId, permissions: next }], + }) + } catch (error) { + logger.error('Failed to update workspace permission', { error, workspaceId, userId }) + } + } + + if (workspaces.length === 0) { + return ( +
+ Not a member of any workspace in this organization yet. +
+ ) + } + + return ( +
+ {workspaces.map((ws, idx) => { + const callerIsWorkspaceAdmin = currentUserAdminWorkspaceIds.has(ws.workspaceId) + const wouldDemoteSelf = userId === currentUserId && ws.permission === 'admin' + const disabled = + rowUserIsOrgAdmin || + !callerIsWorkspaceAdmin || + wouldDemoteSelf || + updatePermissions.isPending + + const selector = ( + handleChange(ws.workspaceId, next)} + disabled={disabled} + size='compact' + /> + ) + + return ( +
0 && 'border-[var(--border-1)] border-t' + )} + > + + {ws.workspaceName} + + {rowUserIsOrgAdmin ? ( + + +
{selector}
+
+ User is an organization {memberRole} +
+ ) : ( + selector + )} +
+ ) + })} +
+ ) +} + +function InvitationExpandedPanel({ + invitationId, + grants, + currentUserAdminWorkspaceIds, + organizationId, +}: { + invitationId: string + grants: RosterWorkspaceAccess[] + currentUserAdminWorkspaceIds: Set + organizationId: string +}) { + const updateInvitation = useUpdateInvitation() + + const handleChange = async (workspaceId: string, next: PermissionType) => { + try { + await updateInvitation.mutateAsync({ + orgId: organizationId, + invitationId, + grants: [{ workspaceId, permission: next }], + }) + } catch (error) { + logger.error('Failed to update invitation grant permission', { + error, + workspaceId, + invitationId, + }) + } + } + + if (grants.length === 0) { + return ( +
+ No workspace access will be granted when this invitation is accepted. +
+ ) + } + + return ( +
+ {grants.map((g, idx) => { + const callerIsWorkspaceAdmin = currentUserAdminWorkspaceIds.has(g.workspaceId) + const disabled = !callerIsWorkspaceAdmin || updateInvitation.isPending + return ( +
0 && 'border-[var(--border-1)] border-t' + )} + > + + {g.workspaceName} + + handleChange(g.workspaceId, next)} + disabled={disabled} + size='compact' + /> +
+ ) + })} +
+ ) +} + +function matchesQuery(haystacks: Array, q: string): boolean { + if (!q) return true + const needle = q.trim().toLowerCase() + if (!needle) return true + return haystacks.some((h) => (h ?? '').toLowerCase().includes(needle)) +} + +export function OrganizationRoster({ + organizationId, + roster, + isLoadingRoster, + currentUserEmail, + currentUserId, + isAdminOrOwner, + onRemoveMember, + onTransferOwnership, +}: OrganizationRosterProps) { + const [query, setQuery] = useState('') + const [expandedRows, setExpandedRows] = useState>(() => new Set()) + const [cancellingIds, setCancellingIds] = useState>(() => new Set()) + const [resendingIds, setResendingIds] = useState>(() => new Set()) + const [resendCooldowns, setResendCooldowns] = useState>({}) + const cooldownIntervalsRef = useRef>>(new Map()) + + useEffect(() => { + const intervals = cooldownIntervalsRef.current + return () => { + intervals.forEach((interval) => clearInterval(interval)) + intervals.clear() + } + }, []) + + const cancelInvitation = useCancelInvitation() + const resendInvitation = useResendInvitation() + + const { data: memberUsageResponse, isLoading: isLoadingUsage } = + useOrganizationMembers(organizationId) + + const memberUsageData: Record = {} + if (memberUsageResponse?.data) { + memberUsageResponse.data.forEach( + (entry: { userId: string; currentPeriodCost?: number | null }) => { + if (entry.currentPeriodCost !== null && entry.currentPeriodCost !== undefined) { + memberUsageData[entry.userId] = Number.parseFloat(entry.currentPeriodCost.toString()) + } + } + ) + } + + const rawDollars = Object.values(memberUsageData) + const totalCredits = dollarsToCredits(rawDollars.reduce((sum, d) => sum + d, 0)) + const memberCredits = apportionCredits(memberUsageData, totalCredits) + + const members: RosterMember[] = roster?.members ?? [] + const pendingInvitations: RosterPendingInvitation[] = roster?.pendingInvitations ?? [] + + const currentUserAdminWorkspaceIds = useMemo(() => { + if (isAdminOrOwner) { + return new Set((roster?.workspaces ?? []).map((ws) => ws.id)) + } + const self = members.find((m) => m.userId === currentUserId) + if (!self) return new Set() + return new Set( + self.workspaces.filter((ws) => ws.permission === 'admin').map((ws) => ws.workspaceId) + ) + }, [isAdminOrOwner, roster?.workspaces, members, currentUserId]) + + const canEditRoles = isAdminOrOwner + + const updateMemberRole = useUpdateOrganizationMemberRole() + const updateInvitation = useUpdateInvitation() + + const filteredMembers = useMemo(() => { + return members.filter((m) => { + const workspaceNames = m.workspaces.map((ws) => ws.workspaceName) + return matchesQuery([m.name, m.email, ...workspaceNames], query) + }) + }, [members, query]) + + const filteredInvitations = useMemo(() => { + return pendingInvitations.filter((inv) => { + const workspaceNames = inv.workspaces.map((ws) => ws.workspaceName) + return matchesQuery([inv.inviteeName, inv.email, ...workspaceNames], query) + }) + }, [pendingInvitations, query]) + + const totalFiltered = filteredMembers.length + filteredInvitations.length + const totalRows = members.length + pendingInvitations.length + const currentUser = members.find((m) => m.userId === currentUserId) + const canLeave = currentUser && currentUser.role !== 'owner' + + const toggleRow = (id: string) => { + setExpandedRows((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const handleCancelInvitation = async (invitationId: string) => { + setCancellingIds((prev) => new Set([...prev, invitationId])) + try { + await cancelInvitation.mutateAsync({ invitationId, orgId: organizationId }) + } catch (error) { + logger.error('Failed to cancel invitation', { error }) + } finally { + setCancellingIds((prev) => { + const next = new Set(prev) + next.delete(invitationId) + return next + }) + } + } + + const handleResendInvitation = async (invitationId: string) => { + const secondsLeft = resendCooldowns[invitationId] + if (secondsLeft && secondsLeft > 0) return + + setResendingIds((prev) => new Set([...prev, invitationId])) + try { + await resendInvitation.mutateAsync({ invitationId, orgId: organizationId }) + setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 })) + const existing = cooldownIntervalsRef.current.get(invitationId) + if (existing) clearInterval(existing) + const interval = setInterval(() => { + setResendCooldowns((prev) => { + const current = prev[invitationId] + if (current === undefined) return prev + if (current <= 1) { + const next = { ...prev } + delete next[invitationId] + const tracked = cooldownIntervalsRef.current.get(invitationId) + if (tracked) { + clearInterval(tracked) + cooldownIntervalsRef.current.delete(invitationId) + } + return next + } + return { ...prev, [invitationId]: current - 1 } + }) + }, 1000) + cooldownIntervalsRef.current.set(invitationId, interval) + } catch (error) { + logger.error('Failed to resend invitation', { error }) + } finally { + setResendingIds((prev) => { + const next = new Set(prev) + next.delete(invitationId) + return next + }) + } + } + + const showEmpty = !isLoadingRoster && totalRows === 0 + const showNoMatches = !isLoadingRoster && totalRows > 0 && totalFiltered === 0 + + return ( +
+
+ + setQuery(e.target.value)} + placeholder='Search by name, email, or workspace' + className='pl-9' + /> +
+ + {showEmpty ? ( +
+

No members yet

+

+ Invite someone above to get started. +

+
+ ) : ( +
+ + + + + Member + Role + + {isAdminOrOwner ? 'Usage' : ''} + + + + + {isLoadingRoster && totalRows === 0 + ? Array.from({ length: 3 }).map((_, i) => ( + + )) + : null} + + {showNoMatches && ( + + + No matches for “{query}” + + + )} + + {filteredMembers.map((m) => { + const rowKey = `member-${m.memberId}` + const expanded = expandedRows.has(rowKey) + const isSelf = m.email === currentUserEmail + const credits = memberCredits[m.userId] ?? 0 + const canRemove = isAdminOrOwner && m.role !== 'owner' && !isSelf + const canTransferAndLeave = isSelf && m.role === 'owner' && !!onTransferOwnership + return ( + + + + toggleRow(rowKey)} /> + + + + + + {m.role === 'owner' || !canEditRoles || m.userId === currentUserId ? ( + + ) : ( + + updateMemberRole + .mutateAsync({ + orgId: organizationId, + userId: m.userId, + role: next, + }) + .catch((error) => + logger.error('Failed to update member role', { error }) + ) + } + disabled={updateMemberRole.isPending} + /> + )} + + +
+ {isAdminOrOwner ? ( + isLoadingUsage ? ( + + ) : ( + + {credits.toLocaleString()} credits + + ) + ) : null} + {canRemove && ( + + )} + {canTransferAndLeave && ( + + )} +
+
+
+ {expanded && ( + + + + + + )} +
+ ) + })} + + {filteredInvitations.map((inv) => { + const rowKey = `invite-${inv.id}` + const expanded = expandedRows.has(rowKey) + const isResending = resendingIds.has(inv.id) + const isCancelling = cancellingIds.has(inv.id) + const cooldown = resendCooldowns[inv.id] ?? 0 + const resendDisabled = isResending || cooldown > 0 + return ( + + + + toggleRow(rowKey)} /> + + + + + + {isAdminOrOwner ? ( + + updateInvitation + .mutateAsync({ + orgId: organizationId, + invitationId: inv.id, + role: next, + }) + .catch((error) => + logger.error('Failed to update invitation role', { error }) + ) + } + disabled={updateInvitation.isPending} + /> + ) : ( + + )} + + + {isAdminOrOwner && ( +
+ + +
+ )} +
+
+ {expanded && ( + + + + + + )} +
+ ) + })} +
+
+
+ )} + + {canLeave && currentUser && ( +
+ +
+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-members/team-members.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-members/team-members.tsx deleted file mode 100644 index 3424c88223f..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-members/team-members.tsx +++ /dev/null @@ -1,360 +0,0 @@ -'use client' - -import { useState } from 'react' -import { createLogger } from '@sim/logger' -import { Avatar, AvatarFallback, AvatarImage, Badge, Button } from '@/components/emcn' -import { CREDIT_MULTIPLIER, dollarsToCredits } from '@/lib/billing/credits/conversion' -import { getUserColor } from '@/lib/workspaces/colors' -import type { Invitation, Member, Organization } from '@/lib/workspaces/organization' -import { - useCancelInvitation, - useOrganizationMembers, - useResendInvitation, -} from '@/hooks/queries/organization' - -const logger = createLogger('TeamMembers') - -/** - * Distributes a known credit total across members so per-member values always sum exactly. - * Uses the largest-remainder method: floor each share, then give +1 to the entries - * with the biggest fractional parts until the sum matches the total. - */ -function apportionCredits( - dollarsByUser: Record, - totalCredits: number -): Record { - const entries = Object.entries(dollarsByUser).map(([userId, dollars]) => { - const exact = dollars * CREDIT_MULTIPLIER - return { userId, floor: Math.floor(exact), remainder: exact - Math.floor(exact) } - }) - - let floorSum = entries.reduce((s, e) => s + e.floor, 0) - const gap = totalCredits - floorSum - - entries.sort((a, b) => b.remainder - a.remainder) - for (let i = 0; i < gap && i < entries.length; i++) { - entries[i].floor += 1 - floorSum += 1 - } - - const result: Record = {} - for (const e of entries) result[e.userId] = e.floor - return result -} - -interface TeamMembersProps { - organization: Organization - currentUserEmail: string - isAdminOrOwner: boolean - onRemoveMember: (member: Member) => void -} - -interface BaseItem { - id: string - name: string - email: string - avatarInitial: string - avatarUrl?: string | null - userId?: string - usage: string -} - -interface MemberItem extends BaseItem { - type: 'member' - role: string - member: Member -} - -interface InvitationItem extends BaseItem { - type: 'invitation' - invitation: Invitation -} - -type TeamMemberItem = MemberItem | InvitationItem - -export function TeamMembers({ - organization, - currentUserEmail, - isAdminOrOwner, - onRemoveMember, -}: TeamMembersProps) { - const [cancellingInvitations, setCancellingInvitations] = useState>(() => new Set()) - const [resendingInvitations, setResendingInvitations] = useState>(() => new Set()) - const [resentInvitations, setResentInvitations] = useState>(() => new Set()) - const [resendCooldowns, setResendCooldowns] = useState>({}) - - const { data: memberUsageResponse, isLoading: isLoadingUsage } = useOrganizationMembers( - organization?.id || '' - ) - - const cancelInvitationMutation = useCancelInvitation() - const resendInvitationMutation = useResendInvitation() - - const memberUsageData: Record = {} - if (memberUsageResponse?.data) { - memberUsageResponse.data.forEach( - (member: { userId: string; currentPeriodCost?: number | null }) => { - if (member.currentPeriodCost !== null && member.currentPeriodCost !== undefined) { - memberUsageData[member.userId] = Number.parseFloat(member.currentPeriodCost.toString()) - } - } - ) - } - - const rawDollars = Object.values(memberUsageData) - const totalCredits = dollarsToCredits(rawDollars.reduce((sum, d) => sum + d, 0)) - const memberCredits = apportionCredits(memberUsageData, totalCredits) - - const teamItems: TeamMemberItem[] = [] - - if (organization.members) { - organization.members.forEach((member: Member) => { - const userId = member.user?.id - const credits = userId ? (memberCredits[userId] ?? 0) : 0 - const name = member.user?.name || 'Unknown' - - const memberItem: MemberItem = { - type: 'member', - id: member.id, - name, - email: member.user?.email || '', - avatarInitial: name.charAt(0).toUpperCase(), - avatarUrl: member.user?.image, - userId: member.user?.id, - usage: `${credits.toLocaleString()} credits`, - role: member.role, - member, - } - - teamItems.push(memberItem) - }) - } - - const pendingInvitations = organization.invitations?.filter( - (invitation) => invitation.status === 'pending' - ) - if (pendingInvitations) { - pendingInvitations.forEach((invitation: Invitation) => { - const emailPrefix = invitation.email.split('@')[0] - - const invitationItem: InvitationItem = { - type: 'invitation', - id: invitation.id, - name: emailPrefix, - email: invitation.email, - avatarInitial: emailPrefix.charAt(0).toUpperCase(), - avatarUrl: null, - userId: invitation.email, - usage: '-', - invitation, - } - - teamItems.push(invitationItem) - }) - } - - if (teamItems.length === 0) { - return
No team members yet.
- } - - const currentUserMember = organization.members?.find((m) => m.user?.email === currentUserEmail) - const canLeaveOrganization = - currentUserMember && currentUserMember.role !== 'owner' && currentUserMember.user?.id - - const handleCancelInvitation = async (invitationId: string) => { - if (!organization?.id) return - - setCancellingInvitations((prev) => new Set([...prev, invitationId])) - try { - await cancelInvitationMutation.mutateAsync({ - invitationId, - orgId: organization.id, - }) - } catch (error) { - logger.error('Failed to cancel invitation', { error }) - } finally { - setCancellingInvitations((prev) => { - const next = new Set(prev) - next.delete(invitationId) - return next - }) - } - } - - const handleResendInvitation = async (invitationId: string) => { - if (!organization?.id) return - - const secondsLeft = resendCooldowns[invitationId] - if (secondsLeft && secondsLeft > 0) return - - setResendingInvitations((prev) => new Set([...prev, invitationId])) - try { - await resendInvitationMutation.mutateAsync({ - invitationId, - orgId: organization.id, - }) - - setResentInvitations((prev) => new Set([...prev, invitationId])) - setTimeout(() => { - setResentInvitations((prev) => { - const next = new Set(prev) - next.delete(invitationId) - return next - }) - }, 4000) - - // Start 60s cooldown - setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 })) - const interval = setInterval(() => { - setResendCooldowns((prev) => { - const current = prev[invitationId] - if (current === undefined) return prev - if (current <= 1) { - const next = { ...prev } - delete next[invitationId] - clearInterval(interval) - return next - } - return { ...prev, [invitationId]: current - 1 } - }) - }, 1000) - } catch (error) { - logger.error('Failed to resend invitation', { error }) - } finally { - setResendingInvitations((prev) => { - const next = new Set(prev) - next.delete(invitationId) - return next - }) - } - } - - return ( -
- {/* Header */} -
-

Team Members

-
- - {/* Members list */} -
- {teamItems.map((item) => ( -
- {/* Left section: Avatar + Name/Role + Action buttons */} -
- {/* Avatar */} - - {item.avatarUrl && } - - {item.avatarInitial} - - - - {/* Name and email */} -
-
- - {item.name} - - {item.type === 'member' && ( - - {item.role.charAt(0).toUpperCase() + item.role.slice(1)} - - )} - {item.type === 'invitation' && ( - - Pending - - )} -
-
{item.email}
-
- - {/* Action buttons for members */} - {isAdminOrOwner && - item.type === 'member' && - item.role !== 'owner' && - item.email !== currentUserEmail && ( - - )} -
- - {/* Right section */} - {isAdminOrOwner && ( -
- {item.type === 'member' ? ( - <> -
Usage
-
- {isLoadingUsage ? ( - - ) : ( - item.usage - )} -
- - ) : ( -
- - -
- )} -
- )} -
- ))} -
- - {/* Leave Organization button */} - {canLeaveOrganization && ( -
- -
- )} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx index 82ec25ece76..52f6d11c67a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx @@ -1,3 +1,4 @@ +import { useParams, useRouter } from 'next/navigation' import { Badge, Button, Skeleton } from '@/components/emcn' import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils' import { cn } from '@/lib/core/utils/cn' @@ -18,8 +19,6 @@ interface TeamSeatsOverviewProps { totalSeats: number usedSeats: number isLoading: boolean - onConfirmTeamUpgrade: (seats: number) => Promise - onReduceSeats: () => Promise onAddSeatDialog: () => void } @@ -50,10 +49,12 @@ export function TeamSeatsOverview({ totalSeats, usedSeats, isLoading, - onConfirmTeamUpgrade, - onReduceSeats, onAddSeatDialog, }: TeamSeatsOverviewProps) { + const router = useRouter() + const params = useParams<{ workspaceId: string }>() + const workspaceId = params?.workspaceId + if (isLoadingSubscription) { return } @@ -64,20 +65,22 @@ export function TeamSeatsOverview({

- No Team Subscription Found + No active Team subscription

- Your subscription may need to be transferred to this organization. + Purchase a Team plan to invite members and manage seats for this organization.

@@ -85,15 +88,26 @@ export function TeamSeatsOverview({ } const isEnterprise = checkEnterprisePlan(subscriptionData) + const isSeatDataPending = !isEnterprise && totalSeats === 0 + const isOverLimit = totalSeats > 0 && usedSeats > totalSeats + const pillCount = Math.max(totalSeats, usedSeats) + + if (isSeatDataPending) { + return + } return (
- {/* Top row - matching UsageHeader */}
Seats - {!isEnterprise && ( + {isOverLimit && ( + + Over limit + + )} + {!isEnterprise && !isOverLimit && (
- {/* Pills row - one pill per seat */}
- {Array.from({ length: totalSeats }).map((_, i) => { + {Array.from({ length: pillCount }).map((_, i) => { const isFilled = i < usedSeats + const isOverage = i >= totalSeats return (
) })}
- {/* Enterprise message */} + {isOverLimit && !isEnterprise && ( +
+

+ You have more members than seats. New invites are paused until you add seats or remove + members. +

+ +
+ )} + {isEnterprise && (

- Contact support for enterprise usage limit changes + {isOverLimit + ? 'You have more members than seats. Contact support to adjust your enterprise seat count.' + : 'Contact support for enterprise usage limit changes'}

)} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx new file mode 100644 index 00000000000..c417e8b5226 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx @@ -0,0 +1,220 @@ +'use client' + +import { useMemo, useState } from 'react' +import { + Avatar, + AvatarFallback, + AvatarImage, + Badge, + Banner, + Button, + Input, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Skeleton, +} from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import { getUserColor } from '@/lib/workspaces/colors' +import type { RosterMember } from '@/hooks/queries/organization' + +interface TransferOwnershipDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + members: RosterMember[] + isLoadingMembers: boolean + currentUserId: string + isSubmitting: boolean + error?: Error | null + portalError?: string | null + hasPaidSubscription: boolean + isOpeningBillingPortal: boolean + onConfirm: (newOwnerUserId: string) => Promise + onOpenBillingPortal: () => void +} + +export function TransferOwnershipDialog({ + open, + onOpenChange, + members, + isLoadingMembers, + currentUserId, + isSubmitting, + error, + portalError, + hasPaidSubscription, + isOpeningBillingPortal, + onConfirm, + onOpenBillingPortal, +}: TransferOwnershipDialogProps) { + const [search, setSearch] = useState('') + const [selectedUserId, setSelectedUserId] = useState(null) + + const candidates = useMemo(() => { + const others = members.filter((m) => m.userId !== currentUserId && m.role !== 'owner') + others.sort((a, b) => { + if (a.role === 'admin' && b.role !== 'admin') return -1 + if (a.role !== 'admin' && b.role === 'admin') return 1 + return a.name.localeCompare(b.name) + }) + if (!search.trim()) return others + const q = search.trim().toLowerCase() + return others.filter( + (m) => m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q) + ) + }, [members, currentUserId, search]) + + const hasCandidates = members.some((m) => m.userId !== currentUserId && m.role !== 'owner') + + const handleClose = (next: boolean) => { + if (!next) { + setSearch('') + setSelectedUserId(null) + } + onOpenChange(next) + } + + const handleConfirm = async () => { + if (!selectedUserId) return + await onConfirm(selectedUserId) + } + + return ( + + + Leave organization + + {isLoadingMembers ? ( +
+ + +
+ + + +
+
+ ) : !hasCandidates ? ( +

+ You're the only member of this organization. Invite another admin before leaving. +

+ ) : ( +
+

+ As the owner, you need to hand off the organization before you can leave. Pick a + member to become the new owner. They'll inherit billing access, seat management, and + all owner-only permissions. You'll lose access to every shared workspace in this + organization. +

+ + {hasPaidSubscription && ( + + + Your payment method stays on this organization + + + Future charges will keep hitting the card you added. Open the Stripe billing + portal to remove it before you leave. + + + } + /> + )} + + {portalError && ( +

{portalError}

+ )} + + setSearch(e.target.value)} + placeholder='Search members...' + /> + +
+ {candidates.length === 0 ? ( +
+ No members match "{search}" +
+ ) : ( +
    + {candidates.map((m) => { + const isSelected = selectedUserId === m.userId + return ( +
  • + +
  • + ) + })} +
+ )} +
+
+ )} + + {error && ( +

+ {error instanceof Error && error.message ? error.message : String(error)} +

+ )} +
+ + + + +
+
+ ) +} 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 9c9f7d8add7..dbd86c9f498 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 @@ -7,32 +7,35 @@ import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client/utils' import { getPlanTierCredits, getPlanTierDollars } from '@/lib/billing/plan-helpers' import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils' +import { getBaseUrl } from '@/lib/core/utils/urls' import { generateSlug, getUsedSeats, - getUserRole, isAdminOrOwner, type Member, } from '@/lib/workspaces/organization' import { MemberInvitationCard, NoOrganizationView, + OrganizationRoster, RemoveMemberDialog, - TeamMembers, TeamSeats, TeamSeatsOverview, + TransferOwnershipDialog, } from '@/app/workspace/[workspaceId]/settings/components/team-management/components' import { useCreateOrganization, useInviteMember, useOrganization, useOrganizationBilling, + useOrganizationRoster, useOrganizationSubscription, useOrganizations, useRemoveMember, + useTransferOwnership, useUpdateSeats, } from '@/hooks/queries/organization' -import { useSubscriptionData } from '@/hooks/queries/subscription' +import { useOpenBillingPortal, useSubscriptionData } from '@/hooks/queries/subscription' import { useAdminWorkspaces } from '@/hooks/queries/workspace' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -64,8 +67,12 @@ export function TeamManagement() { const { data: organizationBillingData } = useOrganizationBilling(activeOrganization?.id || '') + const { data: roster, isLoading: isLoadingRoster } = useOrganizationRoster(activeOrganization?.id) + const inviteMutation = useInviteMember() const removeMemberMutation = useRemoveMember() + const transferOwnershipMutation = useTransferOwnership() + const openBillingPortal = useOpenBillingPortal() const updateSeatsMutation = useUpdateSeats() const createOrgMutation = useCreateOrganization() @@ -87,6 +94,8 @@ export function TeamManagement() { shouldReduceSeats: boolean isSelfRemoval?: boolean }>({ open: false, memberId: '', memberName: '', shouldReduceSeats: false }) + const [transferDialogOpen, setTransferDialogOpen] = useState(false) + const [transferPortalError, setTransferPortalError] = useState(null) const [orgName, setOrgName] = useState('') const [orgSlug, setOrgSlug] = useState('') const [isAddSeatDialogOpen, setIsAddSeatDialogOpen] = useState(false) @@ -94,10 +103,10 @@ export function TeamManagement() { const [isUpdatingSeats, setIsUpdatingSeats] = useState(false) const { data: adminWorkspaces = [], isLoading: isLoadingWorkspaces } = useAdminWorkspaces( - session?.user?.id + session?.user?.id, + activeOrganization?.id ) - const userRole = getUserRole(organization, session?.user?.email) const adminOrOwner = isAdminOrOwner(organization, session?.user?.email) const usedSeats = getUsedSeats(organization) const totalSeats = organizationBillingData?.data?.totalSeats ?? 0 @@ -136,15 +145,16 @@ export function TeamManagement() { const handleInviteMember = useCallback(async () => { const validEmails = inviteEmails.filter((e) => e.isValid).map((e) => e.value) if (!session?.user || !activeOrganization?.id || validEmails.length === 0) return + if (selectedWorkspaces.length === 0) { + setShowWorkspaceInvite(true) + return + } try { - const workspaceInvitations = - selectedWorkspaces.length > 0 - ? selectedWorkspaces.map((w) => ({ - workspaceId: w.workspaceId, - permission: w.permission as 'admin' | 'write' | 'read', - })) - : undefined + const workspaceInvitations = selectedWorkspaces.map((w) => ({ + workspaceId: w.workspaceId, + permission: w.permission as 'admin' | 'write' | 'read', + })) await inviteMutation.mutateAsync({ emails: validEmails, @@ -206,7 +216,7 @@ export function TeamManagement() { const confirmRemoveMember = useCallback( async (shouldReduceSeats = false) => { - const { memberId } = removeMemberDialog + const { memberId, isSelfRemoval } = removeMemberDialog if (!session?.user || !activeOrganization?.id || !memberId) return try { @@ -221,32 +231,93 @@ export function TeamManagement() { memberName: '', shouldReduceSeats: false, }) + + if (isSelfRemoval) { + window.location.href = '/workspace' + } } catch (error) { logger.error('Failed to remove member', error) } }, - [removeMemberDialog.memberId, session?.user?.id, activeOrganization?.id, removeMemberMutation] + [ + removeMemberDialog.memberId, + removeMemberDialog.isSelfRemoval, + session?.user?.id, + activeOrganization?.id, + removeMemberMutation, + ] ) - const handleReduceSeats = useCallback(async () => { - if (!session?.user || !activeOrganization?.id || !subscriptionData) return - if (checkEnterprisePlan(subscriptionData)) return + const handleTransferDialogOpenChange = useCallback( + (next: boolean) => { + setTransferDialogOpen(next) + if (!next) { + transferOwnershipMutation.reset() + setTransferPortalError(null) + } + }, + [transferOwnershipMutation] + ) - const currentSeats = subscriptionData.seats || 0 - if (currentSeats <= 1) return + const handleOpenTransferDialog = useCallback(() => { + transferOwnershipMutation.reset() + setTransferPortalError(null) + setTransferDialogOpen(true) + }, [transferOwnershipMutation]) - const { used: totalCount } = usedSeats - if (totalCount >= currentSeats) return + const handleConfirmTransfer = useCallback( + async (newOwnerUserId: string) => { + if (!activeOrganization?.id) return - try { - await updateSeatsMutation.mutateAsync({ - orgId: activeOrganization?.id, - seats: currentSeats - 1, - }) - } catch (error) { - logger.error('Failed to reduce seats', error) - } - }, [session?.user?.id, activeOrganization?.id, subscriptionData, usedSeats, updateSeatsMutation]) + try { + const result = await transferOwnershipMutation.mutateAsync({ + orgId: activeOrganization.id, + newOwnerUserId, + alsoLeave: true, + }) + + setTransferDialogOpen(false) + + if (result.left) { + window.location.href = '/workspace' + } + } catch (error) { + logger.error('Failed to transfer ownership', error) + } + }, + [activeOrganization?.id, transferOwnershipMutation] + ) + + const handleOpenTransferBillingPortal = useCallback(() => { + if (!activeOrganization?.id) return + setTransferPortalError(null) + const portalWindow = window.open('', '_blank') + openBillingPortal.mutate( + { + context: 'organization', + organizationId: activeOrganization.id, + returnUrl: `${getBaseUrl()}/workspace`, + }, + { + onSuccess: (data) => { + if (portalWindow) { + portalWindow.location.href = data.url + } else { + window.location.href = data.url + } + }, + onError: (error) => { + portalWindow?.close() + logger.error('Failed to open billing portal from transfer dialog', { error }) + setTransferPortalError( + error instanceof Error + ? error.message + : 'Failed to open Stripe billing portal. Please try again.' + ) + }, + } + ) + }, [activeOrganization?.id, openBillingPortal]) const handleAddSeatDialog = useCallback(() => { if (subscriptionData && !checkEnterprisePlan(subscriptionData)) { @@ -277,15 +348,6 @@ export function TeamManagement() { [subscriptionData, activeOrganization?.id, newSeatCount, updateSeatsMutation] ) - const confirmTeamUpgrade = useCallback( - async (seats: number) => { - if (!session?.user || !activeOrganization?.id) return - logger.info('Team upgrade requested', { seats, organizationId: activeOrganization?.id }) - alert(`Team upgrade to ${seats} seats - integration needed`) - }, - [session?.user?.id, activeOrganization?.id] - ) - const queryError = orgError || subscriptionError const errorMessage = queryError instanceof Error ? queryError.message : null const displayOrganization = organization || activeOrganization @@ -373,26 +435,24 @@ export function TeamManagement() { ) } + if (!adminOrOwner) { + return null + } + return (
- {/* Seats Overview - Full Width */} - {adminOrOwner && ( -
- -
- )} +
+ +
- {/* Action: Invite New Members - hidden when invitations are disabled */} - {adminOrOwner && !isInvitationsDisabled && ( + {!isInvitationsDisabled && (
)} - {/* Main Content: Team Members */} -
- -
+ - {/* Additional Info - Subtle and collapsed */} -
- {/* Single Organization Notice */} - {adminOrOwner && ( -
-

- Note: Users can only be part of one organization - at a time. -

-
- )} - - {/* Team Information */} -
- - Team Information - - - - -
-
- Team ID: - - {displayOrganization.id} - -
-
- Created: - - {new Date(displayOrganization.createdAt).toLocaleDateString()} - -
-
- Your Role: - {userRole} -
-
-
- - {/* Team Billing Information (only show for Team Plan, not Enterprise) */} - {hasTeamPlan && !hasEnterprisePlan && ( -
- - Billing Information - - - - -
-
    -
  • - Your team is billed a minimum of $ - {((subscriptionData?.seats ?? 0) * costPerSeat).toLocaleString()}/month for{' '} - {subscriptionData?.seats ?? 0} licensed seats -
  • -
  • All team member usage is pooled together from a shared limit
  • -
  • - When pooled usage exceeds the limit, all members are blocked from using the - service -
  • -
  • You can increase the usage limit to allow for higher usage
  • -
  • - Any usage beyond the minimum seat cost is billed as overage at the end of the - billing period -
  • -
-
-
- )} -
+ { - navigateToSettings({ section: 'team' }) + navigateToSettings({ section: 'organization' }) }, [navigateToSettings]) const handleUpgradeToEnterprise = useCallback(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permission-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permission-selector.tsx deleted file mode 100644 index 19f8dceb832..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permission-selector.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react' -import { ButtonGroup, ButtonGroupItem } from '@/components/emcn' -import { cn } from '@/lib/core/utils/cn' -import type { PermissionType } from '@/lib/workspaces/permissions/utils' - -export interface PermissionSelectorProps { - value: PermissionType - onChange: (value: PermissionType) => void - disabled?: boolean - className?: string -} - -export const PermissionSelector = React.memo( - ({ value, onChange, disabled = false, className = '' }) => { - return ( - onChange(val as PermissionType)} - disabled={disabled} - className={cn(className, disabled && 'cursor-not-allowed')} - > - Read - Write - Admin - - ) - } -) - -PermissionSelector.displayName = 'PermissionSelector' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permissions-table.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permissions-table.tsx index 7cc22d65827..eed06f628a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permissions-table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permissions-table.tsx @@ -1,11 +1,11 @@ import { useMemo, useRef } from 'react' import { Loader2, RotateCw, X } from 'lucide-react' import { Badge, Button, Skeleton, Tooltip } from '@/components/emcn' +import { PermissionSelector } from '@/components/permissions' import { useSession } from '@/lib/auth/auth-client' import type { PermissionType } from '@/lib/workspaces/permissions/utils' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import type { WorkspacePermissions } from '@/hooks/queries/workspace' -import { PermissionSelector } from './permission-selector' import type { UserPermissions } from './types' const PermissionsTableSkeleton = () => ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/index.ts index 91f467fbd5e..85ebb852b30 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/index.ts @@ -1,4 +1 @@ -export { PermissionSelector } from './components/permission-selector' -export { PermissionsTable } from './components/permissions-table' -export type { PermissionType, UserPermissions } from './components/types' export { InviteModal } from './invite-modal' 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 19fe82bb06d..12869466262 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 @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { useParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import { Button, type FileInputOptions, @@ -27,6 +27,7 @@ import { useResendWorkspaceInvitation, useUpdateWorkspacePermissions, } from '@/hooks/queries/invitations' +import { useOrganizationBilling } from '@/hooks/queries/organization' import type { PermissionType, UserPermissions } from './components/types' const logger = createLogger('InviteModal') @@ -35,9 +36,18 @@ interface InviteModalProps { open: boolean onOpenChange: (open: boolean) => void workspaceName?: string + inviteDisabledReason?: string | null + organizationId?: string | null } -export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalProps) { +export function InviteModal({ + open, + onOpenChange, + workspaceName, + inviteDisabledReason = null, + organizationId = null, +}: InviteModalProps) { + const router = useRouter() const formRef = useRef(null) const [emailItems, setEmailItems] = useState([]) const [userPermissions, setUserPermissions] = useState([]) @@ -70,6 +80,8 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr const { data: pendingInvitations = [], isLoading: isPendingInvitationsLoading } = usePendingInvitations(open ? workspaceId : undefined) + const { data: organizationBillingData } = useOrganizationBilling(organizationId ?? '') + const batchSendInvitations = useBatchSendWorkspaceInvitations() const cancelInvitation = useCancelWorkspaceInvitation() const resendInvitation = useResendWorkspaceInvitation() @@ -79,6 +91,23 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr const hasPendingChanges = Object.keys(existingUserPermissionChanges).length > 0 const validEmails = emailItems.filter((item) => item.isValid).map((item) => item.value) const hasNewInvites = validEmails.length > 0 + const canInviteMembers = userPerms.canAdmin && !inviteDisabledReason + + const totalSeats = organizationBillingData?.data?.totalSeats ?? 0 + const usedSeats = organizationBillingData?.data?.usedSeats ?? 0 + const availableSeats = Math.max(0, totalSeats - usedSeats) + const hasSeatData = !!organizationId && totalSeats > 0 + const exceedsSeatCapacity = + hasSeatData && userPerms.canAdmin && validEmails.length > availableSeats + const isAtSeatCapacity = hasSeatData && userPerms.canAdmin && availableSeats === 0 + const isOutOfSeats = exceedsSeatCapacity || isAtSeatCapacity + const seatLimitReason = hasSeatData + ? availableSeats === 0 + ? `No available seats. Using ${usedSeats} of ${totalSeats}.` + : exceedsSeatCapacity + ? `Only ${availableSeats} seat${availableSeats === 1 ? '' : 's'} available.` + : null + : null const isSubmitting = batchSendInvitations.isPending const isSaving = updatePermissionsMutation.isPending @@ -157,7 +186,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr const fileInputOptions: FileInputOptions = useMemo( () => ({ - enabled: userPerms.canAdmin, + enabled: canInviteMembers, accept: '.csv,.txt,text/csv,text/plain', extractValues: (text: string) => { const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g @@ -167,7 +196,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr }, tooltip: 'Upload emails', }), - [userPerms.canAdmin] + [canInviteMembers] ) const handlePermissionChange = useCallback( @@ -392,13 +421,24 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr [workspaceId, userPerms.canAdmin, resendCooldowns, resendingInvitationIds, resendInvitation] ) + const handleUpgradeRedirect = useCallback(() => { + if (!workspaceId) return + onOpenChange(false) + router.push(`/workspace/${workspaceId}/settings/subscription`) + }, [onOpenChange, router, workspaceId]) + const handleSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault() setErrorMessage(null) - if (validEmails.length === 0 || !workspaceId) { + if (isOutOfSeats) { + handleUpgradeRedirect() + return + } + + if (!canInviteMembers || validEmails.length === 0 || !workspaceId) { return } @@ -432,7 +472,15 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr } ) }, - [validEmails, workspaceId, userPermissions, batchSendInvitations] + [ + canInviteMembers, + isOutOfSeats, + handleUpgradeRedirect, + validEmails, + workspaceId, + userPermissions, + batchSendInvitations, + ] ) const resetState = useCallback(() => { @@ -524,15 +572,21 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr onRemove={removeEmailItem} onInputChange={() => setErrorMessage(null)} placeholder={ - !userPerms.canAdmin - ? 'Only administrators can invite new members' + !canInviteMembers + ? inviteDisabledReason || 'Only administrators can invite new members' : 'Enter emails' } placeholderWithTags='Add email' - disabled={isSubmitting || !userPerms.canAdmin} + disabled={isSubmitting || !canInviteMembers} fileInputOptions={fileInputOptions} />
+ {inviteDisabledReason && ( +

{inviteDisabledReason}

+ )} + {isOutOfSeats && seatLimitReason && ( +

{seatLimitReason}

+ )} {errorMessage && (

{errorMessage}

)} @@ -586,17 +640,32 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index 0b78dadf74d..073b2b09d92 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -1,8 +1,9 @@ 'use client' -import { memo, useEffect, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { MoreHorizontal, Search } from 'lucide-react' +import { useRouter } from 'next/navigation' import { Button, ChevronDown, @@ -29,7 +30,7 @@ import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/ import { CreateWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal' import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal' import { useSubscriptionData } from '@/hooks/queries/subscription' -import type { Workspace } from '@/hooks/queries/workspace' +import type { Workspace, WorkspaceCreationPolicy } from '@/hooks/queries/workspace' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -45,6 +46,8 @@ interface WorkspaceHeaderProps { workspaceId: string /** List of available workspaces */ workspaces: Workspace[] + /** Server-derived workspace creation policy for the current user context */ + workspaceCreationPolicy?: WorkspaceCreationPolicy | null /** Whether workspaces are loading */ isWorkspacesLoading: boolean /** Whether workspace creation is in progress */ @@ -94,6 +97,7 @@ function WorkspaceHeaderImpl({ activeWorkspace, workspaceId, workspaces, + workspaceCreationPolicy, isWorkspacesLoading, isCreatingWorkspace, isWorkspaceMenuOpen, @@ -115,6 +119,7 @@ function WorkspaceHeaderImpl({ sessionUserId, isCollapsed = false, }: WorkspaceHeaderProps) { + const router = useRouter() const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) @@ -168,17 +173,34 @@ function WorkspaceHeaderImpl({ : `${rawPlanName} Plan` : '' const isFreePlan = showPlanInfo && isFree(currentPlan) + const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null + const canCreateWorkspace = workspaceCreationPolicy?.canCreate ?? true + const createWorkspaceDisabledReason = + workspaceCreationPolicy?.canCreate === false ? workspaceCreationPolicy.reason : null + const inviteMembersEnabled = activeWorkspaceFull?.inviteMembersEnabled ?? false + const inviteUpgradeRequired = activeWorkspaceFull?.inviteUpgradeRequired ?? false + const inviteDisabledReason = inviteMembersEnabled + ? null + : (activeWorkspaceFull?.inviteDisabledReason ?? null) + const inviteButtonDisabled = !inviteMembersEnabled && !inviteUpgradeRequired + + const handleInviteClick = useCallback(() => { + if (isInvitationsDisabled) return + if (!inviteMembersEnabled && inviteUpgradeRequired && workspaceId) { + router.push(`/workspace/${workspaceId}/settings/subscription`) + return + } + if (!inviteMembersEnabled) return + setIsInviteModalOpen(true) + }, [isInvitationsDisabled, inviteMembersEnabled, inviteUpgradeRequired, workspaceId, router]) - // Listen for open-invite-modal event from context menu useEffect(() => { const handleOpenInvite = () => { - if (!isInvitationsDisabled) { - setIsInviteModalOpen(true) - } + handleInviteClick() } window.addEventListener('open-invite-modal', handleOpenInvite) return () => window.removeEventListener('open-invite-modal', handleOpenInvite) - }, [isInvitationsDisabled]) + }, [handleInviteClick]) /** * Save and exit edit mode when popover closes @@ -201,9 +223,6 @@ function WorkspaceHeaderImpl({ } setWorkspaceSearch('') }, [isWorkspaceMenuOpen]) - - const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null - const workspaceInitial = (() => { const name = activeWorkspace?.name || '' const stripped = name.replace(/workspace/gi, '').trim() @@ -666,7 +685,8 @@ function WorkspaceHeaderImpl({ setIsWorkspaceMenuOpen(false) setIsCreateModalOpen(true) }} - disabled={isCreatingWorkspace} + disabled={isCreatingWorkspace || !canCreateWorkspace} + title={createWorkspaceDisabledReason ?? undefined} > Create new workspace @@ -678,11 +698,13 @@ function WorkspaceHeaderImpl({