Skip to content

Commit 230dc0a

Browse files
committed
code cleanup
1 parent b9b89f9 commit 230dc0a

28 files changed

Lines changed: 1311 additions & 569 deletions

File tree

apps/sim/app/api/invitations/[id]/resend/route.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,17 @@ import { and, eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
77
import { getSession } from '@/lib/auth'
8+
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
9+
import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers'
10+
import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
811
import { getInvitationById } from '@/lib/invitations/core'
9-
import { resendInvitationEmail, sendInvitationEmail } from '@/lib/invitations/send'
10-
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
12+
import {
13+
persistInvitationResend,
14+
prepareInvitationResend,
15+
sendInvitationEmail,
16+
} from '@/lib/invitations/send'
17+
import { getWorkspaceWithOwner, hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
18+
import { getWorkspaceInvitePolicy } from '@/lib/workspaces/policy'
1119

1220
const logger = createLogger('InvitationResendAPI')
1321

@@ -54,7 +62,48 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
5462
)
5563
}
5664

57-
const { token } = await resendInvitationEmail({ invitationId: id, rotateToken: true })
65+
for (const grant of inv.grants) {
66+
const workspaceDetails = await getWorkspaceWithOwner(grant.workspaceId)
67+
if (!workspaceDetails) {
68+
return NextResponse.json(
69+
{ error: 'Invitation references a workspace that no longer exists' },
70+
{ status: 409 }
71+
)
72+
}
73+
const policy = await getWorkspaceInvitePolicy(workspaceDetails)
74+
if (!policy.allowed) {
75+
return NextResponse.json(
76+
{
77+
error: policy.reason ?? 'Invites are no longer allowed on this workspace',
78+
upgradeRequired: policy.upgradeRequired,
79+
},
80+
{ status: 403 }
81+
)
82+
}
83+
}
84+
85+
if (inv.kind === 'organization' && inv.grants.length === 0 && inv.organizationId) {
86+
const orgSubscription = await getOrganizationSubscription(inv.organizationId)
87+
const orgOnTeamOrEnterprise =
88+
!!orgSubscription &&
89+
hasUsableSubscriptionStatus(orgSubscription.status) &&
90+
(isTeam(orgSubscription.plan) || isEnterprise(orgSubscription.plan))
91+
if (!orgOnTeamOrEnterprise) {
92+
return NextResponse.json(
93+
{
94+
error: 'Invites are no longer allowed on this organization',
95+
upgradeRequired: true,
96+
},
97+
{ status: 403 }
98+
)
99+
}
100+
}
101+
102+
const { tokenForEmail, nextToken, nextExpiresAt } = await prepareInvitationResend({
103+
invitationId: id,
104+
rotateToken: true,
105+
currentToken: inv.token,
106+
})
58107

59108
const [inviterRow] = await db
60109
.select({ name: user.name, email: user.email })
@@ -64,7 +113,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
64113

65114
const emailResult = await sendInvitationEmail({
66115
invitationId: inv.id,
67-
token,
116+
token: tokenForEmail,
68117
kind: inv.kind,
69118
email: inv.email,
70119
inviterName: inviterRow?.name || inviterRow?.email || 'A user',
@@ -83,6 +132,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
83132
)
84133
}
85134

135+
await persistInvitationResend({ invitationId: id, nextToken, nextExpiresAt })
136+
86137
recordAudit({
87138
workspaceId: inv.grants[0]?.workspaceId ?? null,
88139
actorId: session.user.id,

apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -193,22 +193,29 @@ export async function PUT(
193193
return NextResponse.json({ error: 'Cannot change owner role' }, { status: 400 })
194194
}
195195

196-
if (role === 'admin' && userMember[0].role !== 'owner') {
196+
if (role === 'owner' && userMember[0].role !== 'owner') {
197197
return NextResponse.json(
198-
{ error: 'Only owners can promote members to admin' },
198+
{ error: 'Only the current owner can transfer ownership' },
199199
{ status: 403 }
200200
)
201201
}
202202

203-
if (targetMember[0].role === 'admin' && userMember[0].role !== 'owner') {
204-
return NextResponse.json({ error: 'Only owners can change admin roles' }, { status: 403 })
205-
}
203+
const isOwnershipTransfer = role === 'owner'
206204

207-
const updatedMember = await db
208-
.update(member)
209-
.set({ role })
210-
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
211-
.returning()
205+
const updatedMember = await db.transaction(async (tx) => {
206+
if (isOwnershipTransfer) {
207+
await tx
208+
.update(member)
209+
.set({ role: 'admin' })
210+
.where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
211+
}
212+
213+
return tx
214+
.update(member)
215+
.set({ role })
216+
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
217+
.returning()
218+
})
212219

213220
if (updatedMember.length === 0) {
214221
return NextResponse.json({ error: 'Failed to update member role' }, { status: 500 })

apps/sim/app/api/organizations/[id]/members/route.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import {
1919
sendInvitationEmail,
2020
} from '@/lib/invitations/send'
2121
import { quickValidateEmail } from '@/lib/messaging/email/validation'
22+
import {
23+
InvitationsNotAllowedError,
24+
validateInvitationsAllowed,
25+
} from '@/ee/access-control/utils/permission-check'
2226

2327
const logger = createLogger('OrganizationMembersAPI')
2428

@@ -157,10 +161,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
157161
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
158162
}
159163

164+
await validateInvitationsAllowed(session.user.id)
165+
160166
const { id: organizationId } = await params
161167
const { email, role = 'member' } = await request.json()
162168

163-
// Validate input
164169
if (!email) {
165170
return NextResponse.json({ error: 'Email is required' }, { status: 400 })
166171
}
@@ -323,6 +328,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
323328
},
324329
})
325330
} catch (error) {
331+
if (error instanceof InvitationsNotAllowedError) {
332+
return NextResponse.json({ error: error.message }, { status: 403 })
333+
}
326334
logger.error('Failed to invite organization member', {
327335
organizationId: (await params).id,
328336
error,

apps/sim/app/api/organizations/[id]/roster/route.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import {
88
workspace,
99
} from '@sim/db/schema'
1010
import { createLogger } from '@sim/logger'
11-
import { and, eq, inArray } from 'drizzle-orm'
11+
import { and, eq, inArray, sql } from 'drizzle-orm'
1212
import { type NextRequest, NextResponse } from 'next/server'
1313
import { getSession } from '@/lib/auth'
14+
import { expireStalePendingInvitationsForOrganization } from '@/lib/invitations/core'
1415

1516
const logger = createLogger('OrganizationRosterAPI')
1617

@@ -42,6 +43,15 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
4243
)
4344
}
4445

46+
if (callerMembership.role !== 'owner' && callerMembership.role !== 'admin') {
47+
return NextResponse.json(
48+
{ error: 'Forbidden - Organization admin access required' },
49+
{ status: 403 }
50+
)
51+
}
52+
53+
await expireStalePendingInvitationsForOrganization(organizationId)
54+
4555
const orgWorkspaces = await db
4656
.select({ id: workspace.id, name: workspace.name })
4757
.from(workspace)
@@ -118,7 +128,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
118128
inviteeImage: user.image,
119129
})
120130
.from(invitation)
121-
.leftJoin(user, eq(user.email, invitation.email))
131+
.leftJoin(user, sql`lower(${user.email}) = lower(${invitation.email})`)
122132
.where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending')))
123133

124134
const pendingInvitationIds = pendingInvitationRows.map((row) => row.id)

apps/sim/app/api/workspaces/invitations/route.test.ts

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
77
const {
88
mockGetSession,
99
mockGetWorkspaceWithOwner,
10+
mockGetWorkspaceInvitePolicy,
1011
mockValidateInvitationsAllowed,
1112
mockValidateSeatAvailability,
1213
mockGetUserOrganization,
@@ -19,6 +20,7 @@ const {
1920
} = vi.hoisted(() => ({
2021
mockGetSession: vi.fn(),
2122
mockGetWorkspaceWithOwner: vi.fn(),
23+
mockGetWorkspaceInvitePolicy: vi.fn(),
2224
mockValidateInvitationsAllowed: vi.fn().mockResolvedValue(undefined),
2325
mockValidateSeatAvailability: vi.fn(),
2426
mockGetUserOrganization: vi.fn(),
@@ -66,6 +68,14 @@ vi.mock('drizzle-orm', () => ({
6668
eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })),
6769
inArray: vi.fn((field: unknown, values: unknown[]) => ({ type: 'inArray', field, values })),
6870
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
71+
sql: Object.assign(
72+
vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
73+
type: 'sql',
74+
strings,
75+
values,
76+
})),
77+
{ raw: vi.fn((value: unknown) => ({ type: 'sql.raw', value })) }
78+
),
6979
}))
7080

7181
vi.mock('@sim/logger', () => ({
@@ -81,10 +91,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
8191
}))
8292

8393
vi.mock('@/lib/workspaces/policy', () => ({
84-
canWorkspaceInviteMembers: (ws: { workspaceMode?: string | null }) =>
85-
ws.workspaceMode !== 'personal',
86-
getWorkspaceInviteDisabledReason: () =>
87-
'Member invites are only available for organization-owned or grandfathered shared workspaces.',
94+
getWorkspaceInvitePolicy: mockGetWorkspaceInvitePolicy,
8895
isOrganizationWorkspace: (ws: {
8996
workspaceMode?: string | null
9097
organizationId?: string | null
@@ -146,6 +153,13 @@ describe('POST /api/workspaces/invitations', () => {
146153
billedAccountUserId: 'user-1',
147154
})
148155
mockValidateInvitationsAllowed.mockResolvedValue(undefined)
156+
mockGetWorkspaceInvitePolicy.mockResolvedValue({
157+
allowed: true,
158+
reason: null,
159+
requiresSeat: false,
160+
organizationId: null,
161+
upgradeRequired: false,
162+
})
149163
mockValidateSeatAvailability.mockResolvedValue({
150164
canInvite: true,
151165
currentSeats: 1,
@@ -162,7 +176,7 @@ describe('POST /api/workspaces/invitations', () => {
162176
mockFindPendingGrantForWorkspaceEmail.mockResolvedValue(null)
163177
})
164178

165-
it('blocks invites for personal workspaces', async () => {
179+
it('blocks invites for personal workspaces with an upgrade prompt', async () => {
166180
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
167181
id: 'workspace-1',
168182
name: 'Personal Workspace',
@@ -171,6 +185,13 @@ describe('POST /api/workspaces/invitations', () => {
171185
workspaceMode: 'personal',
172186
billedAccountUserId: 'user-1',
173187
})
188+
mockGetWorkspaceInvitePolicy.mockResolvedValueOnce({
189+
allowed: false,
190+
reason: 'Upgrade to invite more members',
191+
requiresSeat: false,
192+
organizationId: null,
193+
upgradeRequired: true,
194+
})
174195
mockDbResults.value = [[{ permissionType: 'admin' }]]
175196

176197
const request = createMockRequest('POST', {
@@ -182,8 +203,41 @@ describe('POST /api/workspaces/invitations', () => {
182203
const response = await POST(request)
183204
const data = await response.json()
184205

185-
expect(response.status).toBe(400)
186-
expect(data.error).toContain('Member invites are only available')
206+
expect(response.status).toBe(403)
207+
expect(data.error).toBe('Upgrade to invite more members')
208+
expect(data.upgradeRequired).toBe(true)
209+
})
210+
211+
it('blocks invites for grandfathered workspaces without a team plan', async () => {
212+
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
213+
id: 'workspace-1',
214+
name: 'Grandfathered Workspace',
215+
ownerId: 'user-1',
216+
organizationId: null,
217+
workspaceMode: 'grandfathered_shared',
218+
billedAccountUserId: 'user-1',
219+
})
220+
mockGetWorkspaceInvitePolicy.mockResolvedValueOnce({
221+
allowed: false,
222+
reason: 'Upgrade to invite more members',
223+
requiresSeat: false,
224+
organizationId: null,
225+
upgradeRequired: true,
226+
})
227+
mockDbResults.value = [[{ permissionType: 'admin' }]]
228+
229+
const request = createMockRequest('POST', {
230+
workspaceId: 'workspace-1',
231+
email: 'new@example.com',
232+
permission: 'read',
233+
})
234+
235+
const response = await POST(request)
236+
const data = await response.json()
237+
238+
expect(response.status).toBe(403)
239+
expect(data.upgradeRequired).toBe(true)
240+
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
187241
})
188242

189243
it('rejects org-owned invites when the organization has no available seats', async () => {
@@ -195,6 +249,13 @@ describe('POST /api/workspaces/invitations', () => {
195249
workspaceMode: 'organization',
196250
billedAccountUserId: 'owner-1',
197251
})
252+
mockGetWorkspaceInvitePolicy.mockResolvedValueOnce({
253+
allowed: true,
254+
reason: null,
255+
requiresSeat: true,
256+
organizationId: 'org-1',
257+
upgradeRequired: false,
258+
})
198259
mockValidateSeatAvailability.mockResolvedValueOnce({
199260
canInvite: false,
200261
reason: 'No available seats. Currently using 5 of 5 seats.',
@@ -228,6 +289,13 @@ describe('POST /api/workspaces/invitations', () => {
228289
workspaceMode: 'organization',
229290
billedAccountUserId: 'owner-1',
230291
})
292+
mockGetWorkspaceInvitePolicy.mockResolvedValueOnce({
293+
allowed: true,
294+
reason: null,
295+
requiresSeat: true,
296+
organizationId: 'org-1',
297+
upgradeRequired: false,
298+
})
231299
mockGetUserOrganization.mockResolvedValueOnce({
232300
organizationId: 'org-2',
233301
role: 'member',

0 commit comments

Comments
 (0)