diff --git a/apps/sim/app/api/enterprise-owner-claims/[id]/accept/route.ts b/apps/sim/app/api/enterprise-owner-claims/[id]/accept/route.ts index e9ade0386a5..d88de154171 100644 --- a/apps/sim/app/api/enterprise-owner-claims/[id]/accept/route.ts +++ b/apps/sim/app/api/enterprise-owner-claims/[id]/accept/route.ts @@ -14,15 +14,6 @@ export const POST = withRouteHandler( if (!session?.user?.id || !session.user.email) { return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) } - if (!session.user.emailVerified) { - return NextResponse.json( - { - error: 'email-unverified', - message: 'Verify the invited email before accepting Enterprise ownership.', - }, - { status: 403 } - ) - } const parsed = await parseRequest(acceptEnterpriseOwnerClaimContract, request, context) if (!parsed.success) return parsed.response const result = await acceptEnterpriseOwnerClaim({ diff --git a/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts b/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts new file mode 100644 index 00000000000..65e2fe04550 --- /dev/null +++ b/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + acceptClaim: vi.fn(), + getClaimDetails: vi.fn(), +})) + +vi.mock('@/lib/billing/enterprise-owner-claim', () => ({ + acceptEnterpriseOwnerClaim: mocks.acceptClaim, + getEnterpriseOwnerClaimDetails: mocks.getClaimDetails, + EnterpriseOwnerClaimEmailMismatchError: class EnterpriseOwnerClaimEmailMismatchError extends Error {}, + EnterpriseOwnerClaimWorkspaceLimitError: class EnterpriseOwnerClaimWorkspaceLimitError extends Error {}, +})) + +vi.mock('@/lib/billing/enterprise-provisioning', () => ({ + EnterpriseProvisioningError: class EnterpriseProvisioningError extends Error {}, +})) + +import { POST } from '@/app/api/enterprise-owner-claims/[id]/accept/route' +import { GET } from '@/app/api/enterprise-owner-claims/[id]/route' + +const claim = { + id: 'claim-1', + ownerEmail: 'owner@example.com', + organizationName: 'Acme', + organizationId: null, + provisioningOperationId: null, + stage: 'owner_acceptance' as const, + status: 'awaiting_owner' as const, + error: null, + expiresAt: '2026-09-04T00:00:00.000Z', + createdAt: '2026-08-28T00:00:00.000Z', + updatedAt: '2026-08-28T00:00:00.000Z', +} + +describe('Enterprise owner claim routes', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { + id: 'owner-1', + name: 'Owner', + email: 'owner@example.com', + emailVerified: false, + }, + }) + }) + + it('lets the invited account review the mailed claim before email verification', async () => { + mocks.getClaimDetails.mockResolvedValue({ + ...claim, + invoiceAmountUsd: 10_000, + billingInterval: 'year', + seats: 10, + invitations: 0, + workspacePreview: { workspacesToMove: [], createsDefaultWorkspace: true }, + acceptanceReview: { canAccept: true, reason: null, requiredSeats: 1 }, + }) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost/api/enterprise-owner-claims/claim-1?token=secure-token' + ), + { params: Promise.resolve({ id: 'claim-1' }) } + ) + + expect(response.status).toBe(200) + expect(mocks.getClaimDetails).toHaveBeenCalledWith({ + claimId: 'claim-1', + token: 'secure-token', + userId: 'owner-1', + userEmail: 'owner@example.com', + }) + }) + + it('lets the acceptance transaction verify an unverified invited account', async () => { + mocks.acceptClaim.mockResolvedValue({ + success: true, + claim, + redirectPath: '/workspace', + }) + + const response = await POST( + createMockRequest( + 'POST', + { + token: 'secure-token', + disclosedWorkspaceIds: [], + disclosedCreatesDefaultWorkspace: true, + }, + {}, + 'http://localhost/api/enterprise-owner-claims/claim-1/accept' + ), + { params: Promise.resolve({ id: 'claim-1' }) } + ) + + expect(response.status).toBe(200) + expect(mocks.acceptClaim).toHaveBeenCalledWith({ + claimId: 'claim-1', + token: 'secure-token', + userId: 'owner-1', + userEmail: 'owner@example.com', + userName: 'Owner', + disclosedWorkspaceIds: [], + disclosedCreatesDefaultWorkspace: true, + }) + }) +}) diff --git a/apps/sim/app/api/enterprise-owner-claims/[id]/route.ts b/apps/sim/app/api/enterprise-owner-claims/[id]/route.ts index 36f1c4be80e..2bb2d47b943 100644 --- a/apps/sim/app/api/enterprise-owner-claims/[id]/route.ts +++ b/apps/sim/app/api/enterprise-owner-claims/[id]/route.ts @@ -19,15 +19,6 @@ export const GET = withRouteHandler( if (!session?.user?.id || !session.user.email) { return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) } - if (!session.user.emailVerified) { - return NextResponse.json( - { - error: 'email-unverified', - message: 'Verify the invited email before reviewing Enterprise ownership.', - }, - { status: 403 } - ) - } const parsed = await parseRequest(getEnterpriseOwnerClaimContract, request, context) if (!parsed.success) return parsed.response try { diff --git a/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx b/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx index 5b807d39103..c5a0d690700 100644 --- a/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx +++ b/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx @@ -184,9 +184,7 @@ export default function EnterpriseOwnerClaim({ registrationDisabled }: Enterpris apiErrorMessage(detailsQuery.error) ?? (queryErrorCode === 'email-mismatch' ? 'This invitation was sent to a different email address.' - : queryErrorCode === 'email-unverified' - ? 'Verify the invited email, then return to this owner invitation.' - : 'This Enterprise invitation is invalid or unavailable.'), + : 'This Enterprise invitation is invalid or unavailable.'), } : null) if (error) { diff --git a/apps/sim/lib/billing/enterprise-owner-claim.test.ts b/apps/sim/lib/billing/enterprise-owner-claim.test.ts index 18506eeb91d..cd34c17b5ae 100644 --- a/apps/sim/lib/billing/enterprise-owner-claim.test.ts +++ b/apps/sim/lib/billing/enterprise-owner-claim.test.ts @@ -3,7 +3,12 @@ */ import { db } from '@sim/db' import { member, outboxEvent, user, workspace } from '@sim/db/schema' -import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -154,6 +159,7 @@ describe('Enterprise future-owner claims', () => { error: null, updatedAt: now.toISOString(), }) + dbChainMockFns.returning.mockResolvedValue([{ id: 'owner-1' }]) }) it('rejects the future-owner path when an account already exists', async () => { @@ -189,6 +195,7 @@ describe('Enterprise future-owner claims', () => { disclosedCreatesDefaultWorkspace: false, }) ).resolves.toEqual({ success: false, kind: 'disclosure-outdated' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(mocks.createOrganization).not.toHaveBeenCalled() expect(mocks.enqueue).not.toHaveBeenCalled() expect(mocks.process).not.toHaveBeenCalled() @@ -268,6 +275,11 @@ describe('Enterprise future-owner claims', () => { expect.anything(), expect.objectContaining({ ownerUserId: 'owner-1', name: 'Acme' }) ) + expect(dbChainMockFns.update).toHaveBeenCalledWith(user) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + emailVerified: true, + updatedAt: now, + }) expect(mocks.patchPayload).toHaveBeenCalledWith( expect.anything(), 'claim-1', @@ -288,6 +300,50 @@ describe('Enterprise future-owner claims', () => { ) }) + it('rejects acceptance when the canonical account email no longer matches the claim', async () => { + queueTableRows(outboxEvent, [claimRow()]) + queueTableRows(member, []) + queueTableRows(workspace, [{ id: 'workspace-1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + acceptEnterpriseOwnerClaim({ + claimId: 'claim-1', + token: 'secure-token', + userId: 'owner-1', + userEmail: 'owner@example.com', + userName: 'Owner', + disclosedWorkspaceIds: ['workspace-1'], + disclosedCreatesDefaultWorkspace: false, + }) + ).resolves.toEqual({ success: false, kind: 'email-mismatch' }) + + const updateConditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect( + updateConditions.some( + (condition) => + condition.type === 'eq' && condition.left === user.id && condition.right === 'owner-1' + ) + ).toBe(true) + const emailScope = updateConditions.find((condition) => condition.type === 'or') + const emailConditions = Array.isArray(emailScope?.conditions) ? emailScope.conditions : [] + expect( + emailConditions.some( + (condition) => + condition?.type === 'eq' && + condition.left === user.normalizedEmail && + condition.right === request.ownerEmail + ) + ).toBe(true) + expect( + emailConditions.filter( + (condition) => condition?.type === 'eq' && condition.right === request.ownerEmail + ) + ).toHaveLength(2) + expect(mocks.createOrganization).not.toHaveBeenCalled() + expect(mocks.enqueue).not.toHaveBeenCalled() + }) + it('activates through the canonical Enterprise issuance operation only after acceptance', async () => { const accepted = { acceptedAt: '2026-08-20T12:00:00.000Z', diff --git a/apps/sim/lib/billing/enterprise-owner-claim.ts b/apps/sim/lib/billing/enterprise-owner-claim.ts index 3634d71718a..b2dab33933f 100644 --- a/apps/sim/lib/billing/enterprise-owner-claim.ts +++ b/apps/sim/lib/billing/enterprise-owner-claim.ts @@ -964,6 +964,23 @@ export async function acceptEnterpriseOwnerClaim(params: { invitationEmails: payload.request.invitations.map((invitation) => invitation.email), }) + const [verifiedOwner] = await tx + .update(user) + .set({ emailVerified: true, updatedAt: new Date() }) + .where( + and( + eq(user.id, params.userId), + or( + eq(user.normalizedEmail, payload.request.ownerEmail), + eq(sql`lower(trim(${user.email}))`, payload.request.ownerEmail) + ) + ) + ) + .returning({ id: user.id }) + if (!verifiedOwner) { + return { success: false as const, kind: 'email-mismatch' as const } + } + if (createsDefaultWorkspace) { const defaultWorkspace = await createDefaultPersonalWorkspaceInTransaction(tx, { userId: params.userId,