Skip to content

Commit de26d1d

Browse files
committed
feat(invites): explicit external members
1 parent 877dc9e commit de26d1d

29 files changed

Lines changed: 1289 additions & 244 deletions

File tree

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

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
33
import { invitation, invitationWorkspaceGrant } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
5+
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
56
import { normalizeEmail } from '@sim/utils/string'
67
import { and, eq } from 'drizzle-orm'
78
import { type NextRequest, NextResponse } from 'next/server'
@@ -14,7 +15,12 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
1415
import { getSession } from '@/lib/auth'
1516
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
1617
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
17-
import { cancelInvitation, getInvitationById } from '@/lib/invitations/core'
18+
import {
19+
cancelInvitation,
20+
getInvitationById,
21+
getInvitationJoinPreview,
22+
isInvitationExpired,
23+
} from '@/lib/invitations/core'
1824
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
1925

2026
const logger = createLogger('InvitationsAPI')
@@ -42,22 +48,41 @@ export const GET = withRouteHandler(
4248
const isInvitee = normalizeEmail(session.user.email || '') === normalizeEmail(inv.email)
4349
const tokenMatches = !!token && token === inv.token
4450

45-
let hasAdminView = false
46-
if (inv.organizationId) {
47-
hasAdminView = await isOrganizationOwnerOrAdmin(session.user.id, inv.organizationId)
48-
}
49-
if (!hasAdminView && inv.grants.length > 0) {
50-
const adminChecks = await Promise.all(
51-
inv.grants.map((grant) => hasWorkspaceAdminAccess(session.user.id, grant.workspaceId))
52-
)
53-
hasAdminView = adminChecks.some(Boolean)
51+
if (!isInvitee && !tokenMatches) {
52+
let hasAdminView = false
53+
if (inv.organizationId) {
54+
hasAdminView = await isOrganizationOwnerOrAdmin(session.user.id, inv.organizationId)
55+
}
56+
if (!hasAdminView && inv.grants.length > 0) {
57+
const adminChecks = await Promise.all(
58+
inv.grants.map((grant) => hasWorkspaceAdminAccess(session.user.id, grant.workspaceId))
59+
)
60+
hasAdminView = adminChecks.some(Boolean)
61+
}
62+
if (!hasAdminView) {
63+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
64+
}
5465
}
5566

56-
if (!isInvitee && !tokenMatches && !hasAdminView) {
57-
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
67+
/**
68+
* Disclosure-only: a preview failure must never block viewing or
69+
* accepting the invitation itself. Expired-but-still-pending rows get
70+
* no preview — acceptance deterministically rejects them.
71+
*/
72+
let joinPreview = null
73+
if (isInvitee && inv.status === 'pending' && !isInvitationExpired(inv)) {
74+
try {
75+
joinPreview = await getInvitationJoinPreview(session.user.id, inv)
76+
} catch (previewError) {
77+
logger.warn('Failed to compute invitation join preview', {
78+
invitationId: id,
79+
error: previewError,
80+
})
81+
}
5882
}
5983

6084
return NextResponse.json({
85+
joinPreview,
6186
invitation: {
6287
id: inv.id,
6388
kind: inv.kind,
@@ -128,6 +153,20 @@ export const PATCH = withRouteHandler(
128153
{ status: 403 }
129154
)
130155
}
156+
/**
157+
* A member-role invite without workspace grants would leave the
158+
* invitee workspace-less after accepting (admins derive access to
159+
* every organization workspace; members do not).
160+
*/
161+
if (!isOrgAdminRole(role) && inv.grants.length === 0) {
162+
return NextResponse.json(
163+
{
164+
error:
165+
'Member invitations must include at least one workspace. Keep the admin role or send a new invitation with workspace access.',
166+
},
167+
{ status: 400 }
168+
)
169+
}
131170
}
132171

133172
const grantsToApply = grants ?? []

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

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ describe('POST /api/organizations/[id]/invitations', () => {
112112
const response = await POST(
113113
createMockRequest(
114114
'POST',
115-
{ emails: ['invitee@example.com'] },
115+
{ emails: ['invitee@example.com'], role: 'admin' },
116116
{},
117117
'http://localhost/api/organizations/org-1/invitations'
118118
),
@@ -125,7 +125,7 @@ describe('POST /api/organizations/[id]/invitations', () => {
125125
kind: 'organization',
126126
email: 'invitee@example.com',
127127
organizationId: 'org-1',
128-
role: 'member',
128+
role: 'admin',
129129
grants: [],
130130
})
131131
)
@@ -135,6 +135,28 @@ describe('POST /api/organizations/[id]/invitations', () => {
135135
expect(mockCancelPendingInvitation).not.toHaveBeenCalled()
136136
})
137137

138+
it('rejects grant-less member-role invitations on the non-batch path', async () => {
139+
mockGetSession.mockResolvedValue(
140+
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
141+
)
142+
queueOwnerAndOrg()
143+
144+
const response = await POST(
145+
createMockRequest(
146+
'POST',
147+
{ emails: ['invitee@example.com'] },
148+
{},
149+
'http://localhost/api/organizations/org-1/invitations'
150+
),
151+
{ params: Promise.resolve({ id: 'org-1' }) }
152+
)
153+
154+
expect(response.status).toBe(400)
155+
const body = await response.json()
156+
expect(body.error).toContain('Member invitations must include at least one workspace')
157+
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
158+
})
159+
138160
it('adds an existing member directly to selected workspaces they lack (no invitation/email)', async () => {
139161
mockGetSession.mockResolvedValue(
140162
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
@@ -356,7 +378,7 @@ describe('POST /api/organizations/[id]/invitations', () => {
356378
const response = await POST(
357379
createMockRequest(
358380
'POST',
359-
{ emails: ['member@example.com'] },
381+
{ emails: ['member@example.com'], role: 'admin' },
360382
{},
361383
'http://localhost/api/organizations/org-1/invitations'
362384
),
@@ -385,7 +407,7 @@ describe('POST /api/organizations/[id]/invitations', () => {
385407
const response = await POST(
386408
createMockRequest(
387409
'POST',
388-
{ emails: ['invitee@example.com'] },
410+
{ emails: ['invitee@example.com'], role: 'admin' },
389411
{},
390412
'http://localhost/api/organizations/org-1/invitations'
391413
),

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,31 @@ export const POST = withRouteHandler(
155155
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
156156
}
157157

158+
/**
159+
* Member-role invites must carry workspace access so the invitee has a
160+
* workspace to land in after accepting — a member with no workspace
161+
* grants (and no admin-derived access) would hit a workspace-less dead
162+
* end. Admin invites are exempt: admins derive access to every
163+
* organization workspace. Both checks run before the validate-only path
164+
* so validation never approves a request the send would reject; the
165+
* domain-layer backstop lives in createPendingInvitation.
166+
*/
167+
if (!isBatch && !isOrgAdminRole(role)) {
168+
return NextResponse.json(
169+
{
170+
error:
171+
'Member invitations must include at least one workspace. Send batch=true with workspaceInvitations so the invitee has a workspace to land in.',
172+
},
173+
{ status: 400 }
174+
)
175+
}
176+
if (isBatch && (!Array.isArray(workspaceInvitations) || workspaceInvitations.length === 0)) {
177+
return NextResponse.json(
178+
{ error: 'Select at least one organization workspace for this invitation.' },
179+
{ status: 400 }
180+
)
181+
}
182+
158183
if (validateOnly) {
159184
const validationResult = await validateBulkInvitations(organizationId, invitationEmails)
160185
return NextResponse.json({
@@ -192,14 +217,7 @@ export const POST = withRouteHandler(
192217

193218
const validGrants: WorkspaceGrantPayload[] = []
194219
const workspaceNameById = new Map<string, string>()
195-
if (isBatch) {
196-
if (!Array.isArray(workspaceInvitations) || workspaceInvitations.length === 0) {
197-
return NextResponse.json(
198-
{ error: 'Select at least one organization workspace for this invitation.' },
199-
{ status: 400 }
200-
)
201-
}
202-
220+
if (isBatch && Array.isArray(workspaceInvitations)) {
203221
for (const wsInvitation of workspaceInvitations) {
204222
if (validGrants.some((grant) => grant.workspaceId === wsInvitation.workspaceId)) {
205223
continue

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ export const POST = withRouteHandler(
210210

211211
await validateInvitationsAllowed(session.user.id, { organizationId })
212212

213-
const { email, role = 'member' } = parsed.data.body
213+
const { email, role } = parsed.data.body
214214

215215
// Validate and normalize email
216216
const normalizedEmail = normalizeEmail(email)
@@ -240,6 +240,23 @@ export const POST = withRouteHandler(
240240
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
241241
}
242242

243+
/**
244+
* Member-role invites must carry workspace access so the invitee has a
245+
* workspace to land in after accepting — a member with no workspace
246+
* grants (and no admin-derived access) would hit a workspace-less dead
247+
* end. Admin invites are exempt: admins derive access to every
248+
* organization workspace.
249+
*/
250+
if (!isOrgAdminRole(role)) {
251+
return NextResponse.json(
252+
{
253+
error:
254+
'Member invitations must include at least one workspace. Use the invitations endpoint with workspaceInvitations so the invitee has a workspace to land in.',
255+
},
256+
{ status: 400 }
257+
)
258+
}
259+
243260
// Check seat availability
244261
const seatValidation = await validateSeatAvailability(organizationId, 1)
245262
if (!seatValidation.canInvite) {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { getMemberRemovalImpactContract } from '@/lib/api/contracts/organization'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { getSession } from '@/lib/auth'
6+
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
7+
import { getOrganizationTransferCredentialDependencies } from '@/lib/billing/organizations/membership'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
10+
const logger = createLogger('OrganizationRemovalImpactAPI')
11+
12+
/**
13+
* Identity-bound credentials the target user owns in this organization's
14+
* workspaces — the set that stops working when their workspace access is
15+
* revoked. Readable by org admins (removing someone) and by the user
16+
* themself (leaving).
17+
*/
18+
export const GET = withRouteHandler(
19+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
20+
const session = await getSession()
21+
if (!session?.user?.id) {
22+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
23+
}
24+
25+
const parsed = await parseRequest(getMemberRemovalImpactContract, request, context)
26+
if (!parsed.success) return parsed.response
27+
28+
const { id: organizationId } = parsed.data.params
29+
const { userId: targetUserId } = parsed.data.query
30+
31+
try {
32+
const isSelf = targetUserId === session.user.id
33+
if (!isSelf && !(await isOrganizationOwnerOrAdmin(session.user.id, organizationId))) {
34+
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
35+
}
36+
37+
const credentials = await getOrganizationTransferCredentialDependencies(
38+
targetUserId,
39+
organizationId
40+
)
41+
42+
return NextResponse.json({ credentials })
43+
} catch (error) {
44+
logger.error('Failed to compute member removal impact', {
45+
organizationId,
46+
targetUserId,
47+
error,
48+
})
49+
return NextResponse.json({ error: 'Failed to compute removal impact' }, { status: 500 })
50+
}
51+
}
52+
)

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization'
4242
import { addUserToOrganization } from '@/lib/billing/organizations/membership'
4343
import { isBillingEnabled } from '@/lib/core/config/env-flags'
4444
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
45+
import { attachOwnedWorkspacesToOrganization } from '@/lib/workspaces/organization-workspaces'
4546
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
4647
import {
4748
adminInvalidJsonResponse,
@@ -264,6 +265,33 @@ export const POST = withRouteHandler(
264265
return badRequestResponse(result.error || 'Failed to add member')
265266
}
266267

268+
/**
269+
* A new member's owned personal workspaces follow them into the org
270+
* (collaborators stay external). Best-effort: membership is already
271+
* committed, and an attach failure leaves the pre-join status quo.
272+
*/
273+
try {
274+
const attach = await attachOwnedWorkspacesToOrganization({
275+
ownerUserId: userId,
276+
organizationId,
277+
externalMemberPolicy: 'external-all',
278+
includeArchived: true,
279+
})
280+
if (attach.attachedWorkspaceIds.length > 0) {
281+
logger.info('Attached new member workspaces to organization', {
282+
userId,
283+
organizationId,
284+
attachedWorkspaceCount: attach.attachedWorkspaceIds.length,
285+
})
286+
}
287+
} catch (attachError) {
288+
logger.error('Failed to attach new member workspaces to organization', {
289+
userId,
290+
organizationId,
291+
error: attachError,
292+
})
293+
}
294+
267295
const data: AdminMember = {
268296
id: result.memberId!,
269297
userId,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
8282
context,
8383
email: item.email,
8484
permission: item.permission,
85+
membershipIntent: body.membershipIntent,
8586
request: req,
8687
})
8788
if (invitation.instantAdd) {

0 commit comments

Comments
 (0)