Skip to content

Commit ab44394

Browse files
committed
fix(enterprise): verify invited owner on acceptance
1 parent 60cb6f5 commit ab44394

6 files changed

Lines changed: 163 additions & 22 deletions

File tree

apps/sim/app/api/enterprise-owner-claims/[id]/accept/route.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,6 @@ export const POST = withRouteHandler(
1414
if (!session?.user?.id || !session.user.email) {
1515
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
1616
}
17-
if (!session.user.emailVerified) {
18-
return NextResponse.json(
19-
{
20-
error: 'email-unverified',
21-
message: 'Verify the invited email before accepting Enterprise ownership.',
22-
},
23-
{ status: 403 }
24-
)
25-
}
2617
const parsed = await parseRequest(acceptEnterpriseOwnerClaimContract, request, context)
2718
if (!parsed.success) return parsed.response
2819
const result = await acceptEnterpriseOwnerClaim({
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { authMockFns, createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({
8+
acceptClaim: vi.fn(),
9+
getClaimDetails: vi.fn(),
10+
}))
11+
12+
vi.mock('@/lib/billing/enterprise-owner-claim', () => ({
13+
acceptEnterpriseOwnerClaim: mocks.acceptClaim,
14+
getEnterpriseOwnerClaimDetails: mocks.getClaimDetails,
15+
EnterpriseOwnerClaimEmailMismatchError: class EnterpriseOwnerClaimEmailMismatchError extends Error {},
16+
EnterpriseOwnerClaimWorkspaceLimitError: class EnterpriseOwnerClaimWorkspaceLimitError extends Error {},
17+
}))
18+
19+
vi.mock('@/lib/billing/enterprise-provisioning', () => ({
20+
EnterpriseProvisioningError: class EnterpriseProvisioningError extends Error {},
21+
}))
22+
23+
import { POST } from '@/app/api/enterprise-owner-claims/[id]/accept/route'
24+
import { GET } from '@/app/api/enterprise-owner-claims/[id]/route'
25+
26+
const claim = {
27+
id: 'claim-1',
28+
ownerEmail: 'owner@example.com',
29+
organizationName: 'Acme',
30+
organizationId: null,
31+
provisioningOperationId: null,
32+
stage: 'owner_acceptance' as const,
33+
status: 'awaiting_owner' as const,
34+
error: null,
35+
expiresAt: '2026-09-04T00:00:00.000Z',
36+
createdAt: '2026-08-28T00:00:00.000Z',
37+
updatedAt: '2026-08-28T00:00:00.000Z',
38+
}
39+
40+
describe('Enterprise owner claim routes', () => {
41+
beforeEach(() => {
42+
vi.clearAllMocks()
43+
authMockFns.mockGetSession.mockResolvedValue({
44+
user: {
45+
id: 'owner-1',
46+
name: 'Owner',
47+
email: 'owner@example.com',
48+
emailVerified: false,
49+
},
50+
})
51+
})
52+
53+
it('lets the invited account review the mailed claim before email verification', async () => {
54+
mocks.getClaimDetails.mockResolvedValue({
55+
...claim,
56+
invoiceAmountUsd: 10_000,
57+
billingInterval: 'year',
58+
seats: 10,
59+
invitations: 0,
60+
workspacePreview: { workspacesToMove: [], createsDefaultWorkspace: true },
61+
acceptanceReview: { canAccept: true, reason: null, requiredSeats: 1 },
62+
})
63+
64+
const response = await GET(
65+
createMockRequest(
66+
'GET',
67+
undefined,
68+
{},
69+
'http://localhost/api/enterprise-owner-claims/claim-1?token=secure-token'
70+
),
71+
{ params: Promise.resolve({ id: 'claim-1' }) }
72+
)
73+
74+
expect(response.status).toBe(200)
75+
expect(mocks.getClaimDetails).toHaveBeenCalledWith({
76+
claimId: 'claim-1',
77+
token: 'secure-token',
78+
userId: 'owner-1',
79+
userEmail: 'owner@example.com',
80+
})
81+
})
82+
83+
it('lets the acceptance transaction verify an unverified invited account', async () => {
84+
mocks.acceptClaim.mockResolvedValue({
85+
success: true,
86+
claim,
87+
redirectPath: '/workspace',
88+
})
89+
90+
const response = await POST(
91+
createMockRequest(
92+
'POST',
93+
{
94+
token: 'secure-token',
95+
disclosedWorkspaceIds: [],
96+
disclosedCreatesDefaultWorkspace: true,
97+
},
98+
{},
99+
'http://localhost/api/enterprise-owner-claims/claim-1/accept'
100+
),
101+
{ params: Promise.resolve({ id: 'claim-1' }) }
102+
)
103+
104+
expect(response.status).toBe(200)
105+
expect(mocks.acceptClaim).toHaveBeenCalledWith({
106+
claimId: 'claim-1',
107+
token: 'secure-token',
108+
userId: 'owner-1',
109+
userEmail: 'owner@example.com',
110+
userName: 'Owner',
111+
disclosedWorkspaceIds: [],
112+
disclosedCreatesDefaultWorkspace: true,
113+
})
114+
})
115+
})

apps/sim/app/api/enterprise-owner-claims/[id]/route.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,6 @@ export const GET = withRouteHandler(
1919
if (!session?.user?.id || !session.user.email) {
2020
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
2121
}
22-
if (!session.user.emailVerified) {
23-
return NextResponse.json(
24-
{
25-
error: 'email-unverified',
26-
message: 'Verify the invited email before reviewing Enterprise ownership.',
27-
},
28-
{ status: 403 }
29-
)
30-
}
3122
const parsed = await parseRequest(getEnterpriseOwnerClaimContract, request, context)
3223
if (!parsed.success) return parsed.response
3324
try {

apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,7 @@ export default function EnterpriseOwnerClaim({ registrationDisabled }: Enterpris
184184
apiErrorMessage(detailsQuery.error) ??
185185
(queryErrorCode === 'email-mismatch'
186186
? 'This invitation was sent to a different email address.'
187-
: queryErrorCode === 'email-unverified'
188-
? 'Verify the invited email, then return to this owner invitation.'
189-
: 'This Enterprise invitation is invalid or unavailable.'),
187+
: 'This Enterprise invitation is invalid or unavailable.'),
190188
}
191189
: null)
192190
if (error) {

apps/sim/lib/billing/enterprise-owner-claim.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
import { db } from '@sim/db'
55
import { member, outboxEvent, user, workspace } from '@sim/db/schema'
6-
import { queueTableRows, resetDbChainMock } from '@sim/testing'
6+
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
77
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
88

99
const mocks = vi.hoisted(() => ({
@@ -154,6 +154,7 @@ describe('Enterprise future-owner claims', () => {
154154
error: null,
155155
updatedAt: now.toISOString(),
156156
})
157+
dbChainMockFns.returning.mockResolvedValue([{ id: 'owner-1' }])
157158
})
158159

159160
it('rejects the future-owner path when an account already exists', async () => {
@@ -189,6 +190,7 @@ describe('Enterprise future-owner claims', () => {
189190
disclosedCreatesDefaultWorkspace: false,
190191
})
191192
).resolves.toEqual({ success: false, kind: 'disclosure-outdated' })
193+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
192194
expect(mocks.createOrganization).not.toHaveBeenCalled()
193195
expect(mocks.enqueue).not.toHaveBeenCalled()
194196
expect(mocks.process).not.toHaveBeenCalled()
@@ -268,6 +270,11 @@ describe('Enterprise future-owner claims', () => {
268270
expect.anything(),
269271
expect.objectContaining({ ownerUserId: 'owner-1', name: 'Acme' })
270272
)
273+
expect(dbChainMockFns.update).toHaveBeenCalledWith(user)
274+
expect(dbChainMockFns.set).toHaveBeenCalledWith({
275+
emailVerified: true,
276+
updatedAt: now,
277+
})
271278
expect(mocks.patchPayload).toHaveBeenCalledWith(
272279
expect.anything(),
273280
'claim-1',
@@ -288,6 +295,28 @@ describe('Enterprise future-owner claims', () => {
288295
)
289296
})
290297

298+
it('rejects acceptance when the canonical account email no longer matches the claim', async () => {
299+
queueTableRows(outboxEvent, [claimRow()])
300+
queueTableRows(member, [])
301+
queueTableRows(workspace, [{ id: 'workspace-1' }])
302+
dbChainMockFns.returning.mockResolvedValueOnce([])
303+
304+
await expect(
305+
acceptEnterpriseOwnerClaim({
306+
claimId: 'claim-1',
307+
token: 'secure-token',
308+
userId: 'owner-1',
309+
userEmail: 'owner@example.com',
310+
userName: 'Owner',
311+
disclosedWorkspaceIds: ['workspace-1'],
312+
disclosedCreatesDefaultWorkspace: false,
313+
})
314+
).resolves.toEqual({ success: false, kind: 'email-mismatch' })
315+
316+
expect(mocks.createOrganization).not.toHaveBeenCalled()
317+
expect(mocks.enqueue).not.toHaveBeenCalled()
318+
})
319+
291320
it('activates through the canonical Enterprise issuance operation only after acceptance', async () => {
292321
const accepted = {
293322
acceptedAt: '2026-08-20T12:00:00.000Z',

apps/sim/lib/billing/enterprise-owner-claim.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -964,6 +964,23 @@ export async function acceptEnterpriseOwnerClaim(params: {
964964
invitationEmails: payload.request.invitations.map((invitation) => invitation.email),
965965
})
966966

967+
const [verifiedOwner] = await tx
968+
.update(user)
969+
.set({ emailVerified: true, updatedAt: new Date() })
970+
.where(
971+
and(
972+
eq(user.id, params.userId),
973+
or(
974+
eq(user.normalizedEmail, payload.request.ownerEmail),
975+
eq(sql<string>`lower(trim(${user.email}))`, payload.request.ownerEmail)
976+
)
977+
)
978+
)
979+
.returning({ id: user.id })
980+
if (!verifiedOwner) {
981+
return { success: false as const, kind: 'email-mismatch' as const }
982+
}
983+
967984
if (createsDefaultWorkspace) {
968985
const defaultWorkspace = await createDefaultPersonalWorkspaceInTransaction(tx, {
969986
userId: params.userId,

0 commit comments

Comments
 (0)