Skip to content

Commit eadf30a

Browse files
fix(enterprise): verify invited owner on acceptance (#7221)
* fix(enterprise): verify invited owner on acceptance * test(enterprise): pin owner email update scope
1 parent 60cb6f5 commit eadf30a

6 files changed

Lines changed: 190 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: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
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 {
7+
dbChainMockFns,
8+
flattenMockConditions,
9+
queueTableRows,
10+
resetDbChainMock,
11+
} from '@sim/testing'
712
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
813

914
const mocks = vi.hoisted(() => ({
@@ -154,6 +159,7 @@ describe('Enterprise future-owner claims', () => {
154159
error: null,
155160
updatedAt: now.toISOString(),
156161
})
162+
dbChainMockFns.returning.mockResolvedValue([{ id: 'owner-1' }])
157163
})
158164

159165
it('rejects the future-owner path when an account already exists', async () => {
@@ -189,6 +195,7 @@ describe('Enterprise future-owner claims', () => {
189195
disclosedCreatesDefaultWorkspace: false,
190196
})
191197
).resolves.toEqual({ success: false, kind: 'disclosure-outdated' })
198+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
192199
expect(mocks.createOrganization).not.toHaveBeenCalled()
193200
expect(mocks.enqueue).not.toHaveBeenCalled()
194201
expect(mocks.process).not.toHaveBeenCalled()
@@ -268,6 +275,11 @@ describe('Enterprise future-owner claims', () => {
268275
expect.anything(),
269276
expect.objectContaining({ ownerUserId: 'owner-1', name: 'Acme' })
270277
)
278+
expect(dbChainMockFns.update).toHaveBeenCalledWith(user)
279+
expect(dbChainMockFns.set).toHaveBeenCalledWith({
280+
emailVerified: true,
281+
updatedAt: now,
282+
})
271283
expect(mocks.patchPayload).toHaveBeenCalledWith(
272284
expect.anything(),
273285
'claim-1',
@@ -288,6 +300,50 @@ describe('Enterprise future-owner claims', () => {
288300
)
289301
})
290302

303+
it('rejects acceptance when the canonical account email no longer matches the claim', async () => {
304+
queueTableRows(outboxEvent, [claimRow()])
305+
queueTableRows(member, [])
306+
queueTableRows(workspace, [{ id: 'workspace-1' }])
307+
dbChainMockFns.returning.mockResolvedValueOnce([])
308+
309+
await expect(
310+
acceptEnterpriseOwnerClaim({
311+
claimId: 'claim-1',
312+
token: 'secure-token',
313+
userId: 'owner-1',
314+
userEmail: 'owner@example.com',
315+
userName: 'Owner',
316+
disclosedWorkspaceIds: ['workspace-1'],
317+
disclosedCreatesDefaultWorkspace: false,
318+
})
319+
).resolves.toEqual({ success: false, kind: 'email-mismatch' })
320+
321+
const updateConditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0])
322+
expect(
323+
updateConditions.some(
324+
(condition) =>
325+
condition.type === 'eq' && condition.left === user.id && condition.right === 'owner-1'
326+
)
327+
).toBe(true)
328+
const emailScope = updateConditions.find((condition) => condition.type === 'or')
329+
const emailConditions = Array.isArray(emailScope?.conditions) ? emailScope.conditions : []
330+
expect(
331+
emailConditions.some(
332+
(condition) =>
333+
condition?.type === 'eq' &&
334+
condition.left === user.normalizedEmail &&
335+
condition.right === request.ownerEmail
336+
)
337+
).toBe(true)
338+
expect(
339+
emailConditions.filter(
340+
(condition) => condition?.type === 'eq' && condition.right === request.ownerEmail
341+
)
342+
).toHaveLength(2)
343+
expect(mocks.createOrganization).not.toHaveBeenCalled()
344+
expect(mocks.enqueue).not.toHaveBeenCalled()
345+
})
346+
291347
it('activates through the canonical Enterprise issuance operation only after acceptance', async () => {
292348
const accepted = {
293349
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)