Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/sim/app/api/invitations/[id]/resend/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export const POST = withRouteHandler(
targetEmail: inv.email,
targetRole: inv.role,
kind: inv.kind,
membershipIntent: inv.membershipIntent,
},
request,
})
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/app/api/invitations/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const GET = withRouteHandler(
email: inv.email,
organizationId: inv.organizationId,
organizationName: inv.organizationName,
membershipIntent: inv.membershipIntent,
role: inv.role,
status: inv.status,
expiresAt: inv.expiresAt,
Expand Down Expand Up @@ -121,6 +122,12 @@ export const PATCH = withRouteHandler(
const { role, grants } = parsed.data

if (role !== undefined) {
if (inv.membershipIntent === 'external') {
return NextResponse.json(
{ error: 'Role updates are not valid on external workspace invitations' },
{ status: 400 }
)
}
if (!inv.organizationId) {
return NextResponse.json(
{ error: 'Role updates are only valid on organization-scoped invitations' },
Expand Down Expand Up @@ -187,6 +194,7 @@ export const PATCH = withRouteHandler(
invitationId: id,
targetEmail: inv.email,
kind: inv.kind,
membershipIntent: inv.membershipIntent,
roleUpdate: role ?? null,
grantUpdates: grantsToApply,
},
Expand Down
12 changes: 10 additions & 2 deletions apps/sim/app/api/organizations/[id]/roster/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
workspace,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, sql } from 'drizzle-orm'
import { and, eq, inArray, ne, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
Expand Down Expand Up @@ -124,14 +124,21 @@ export const GET = withRouteHandler(
email: invitation.email,
role: invitation.role,
kind: invitation.kind,
membershipIntent: invitation.membershipIntent,
createdAt: invitation.createdAt,
expiresAt: invitation.expiresAt,
inviteeName: user.name,
inviteeImage: user.image,
})
.from(invitation)
.leftJoin(user, sql`lower(${user.email}) = lower(${invitation.email})`)
.where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending')))
.where(
and(
eq(invitation.organizationId, organizationId),
eq(invitation.status, 'pending'),
ne(invitation.membershipIntent, 'external')
)
)
Comment thread
icecrasher321 marked this conversation as resolved.
Outdated

const pendingInvitationIds = pendingInvitationRows.map((row) => row.id)
const pendingGrants =
Expand Down Expand Up @@ -162,6 +169,7 @@ export const GET = withRouteHandler(
email: row.email,
role: row.role,
kind: row.kind,
membershipIntent: row.membershipIntent,
createdAt: row.createdAt,
expiresAt: row.expiresAt,
inviteeName: row.inviteeName,
Expand Down
11 changes: 9 additions & 2 deletions apps/sim/app/api/organizations/[id]/seats/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { invitation, member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq, inArray } from 'drizzle-orm'
import { and, count, eq, gt, inArray, ne } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
Expand Down Expand Up @@ -116,7 +116,14 @@ export const PUT = withRouteHandler(
const [pendingCountRow] = await db
.select({ count: count() })
.from(invitation)
.where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending')))
.where(
and(
eq(invitation.organizationId, organizationId),
eq(invitation.status, 'pending'),
ne(invitation.membershipIntent, 'external'),
gt(invitation.expiresAt, new Date())
)
)

const memberCount = memberCountRow?.count ?? 0
const pendingCount = pendingCountRow?.count ?? 0
Expand Down
18 changes: 14 additions & 4 deletions apps/sim/app/api/workspaces/invitations/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ describe('POST /api/workspaces/invitations', () => {
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
})

it('rejects org-owned invites for users already in another organization', async () => {
it('creates an external workspace invitation for users already in another organization', async () => {
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
id: 'workspace-1',
name: 'Org Workspace',
Expand Down Expand Up @@ -288,9 +288,19 @@ describe('POST /api/workspaces/invitations', () => {
const response = await POST(request)
const data = await response.json()

expect(response.status).toBe(409)
expect(data.error).toContain('already a member of another organization')
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.invitation.membershipIntent).toBe('external')
expect(mockValidateSeatAvailability).not.toHaveBeenCalled()
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'workspace',
email: 'new@example.com',
organizationId: 'org-1',
membershipIntent: 'external',
grants: [{ workspaceId: 'workspace-1', permission: 'read' }],
})
)
})

it('creates a unified workspace invitation for a grandfathered workspace', async () => {
Expand Down
31 changes: 17 additions & 14 deletions apps/sim/app/api/workspaces/invitations/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { permissions, type permissionTypeEnum, user, workspace } from '@sim/db/schema'
import {
type InvitationMembershipIntent,
permissions,
type permissionTypeEnum,
user,
workspace,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
Expand Down Expand Up @@ -123,6 +129,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
)
}

let membershipIntent: InvitationMembershipIntent = 'internal'

const existingUser = await db
.select()
.from(user)
Expand Down Expand Up @@ -152,23 +160,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
)
}

if (invitePolicy.requiresSeat && invitePolicy.organizationId) {
if (invitePolicy.organizationId) {
const existingMembership = await getUserOrganization(existingUser.id)
if (
existingMembership &&
existingMembership.organizationId !== invitePolicy.organizationId
) {
return NextResponse.json(
{
error:
'This user is already a member of another organization. They must leave it before joining this workspace.',
email: normalizedEmail,
},
{ status: 409 }
)
}

if (!existingMembership) {
Comment thread
icecrasher321 marked this conversation as resolved.
membershipIntent = 'external'
} else if (invitePolicy.requiresSeat && !existingMembership) {
const seatValidation = await validateSeatAvailability(invitePolicy.organizationId, 1)
if (!seatValidation.canInvite) {
return NextResponse.json(
Expand Down Expand Up @@ -213,6 +212,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
email: normalizedEmail,
inviterId: session.user.id,
organizationId: workspaceDetails.organizationId,
membershipIntent,
role: 'member',
grants: [
{
Expand All @@ -228,6 +228,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
invitedBy: session.user.id,
inviteeEmail: normalizedEmail,
role: permission,
membershipIntent,
})
} catch {
// telemetry must not fail the operation
Expand All @@ -236,7 +237,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
captureServerEvent(
session.user.id,
'workspace_member_invited',
{ workspace_id: workspaceId, invitee_role: permission },
{ workspace_id: workspaceId, invitee_role: permission, membership_intent: membershipIntent },
{
groups: { workspace: workspaceId },
setOnce: { first_invitation_sent_at: new Date().toISOString() },
Expand Down Expand Up @@ -275,6 +276,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
metadata: {
targetEmail: normalizedEmail,
targetRole: permission,
membershipIntent,
workspaceName: workspaceDetails.name,
invitationId,
},
Expand All @@ -288,6 +290,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
workspaceId,
email: normalizedEmail,
permission,
membershipIntent,
expiresAt: undefined,
},
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export const PermissionsTable = ({
permissionType:
changes.permissionType !== undefined ? changes.permissionType : permissionType,
isCurrentUser: user.email === session?.user?.email,
isExternal: user.isExternal,
}
}) || [],
[workspacePermissions?.users, existingUserPermissionChanges, session?.user?.email]
Expand Down Expand Up @@ -212,6 +213,11 @@ export const PermissionsTable = ({
)}
</Badge>
)}
{user.isExternal && (
<Badge variant='default' className='text-caption'>
External
</Badge>
)}
{hasChanges && (
<Badge variant='default' className='text-caption'>
Modified
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ export interface UserPermissions {
permissionType: PermissionType
isCurrentUser?: boolean
isPendingInvitation?: boolean
isExternal?: boolean
invitationId?: string
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { useParams } from 'next/navigation'
import {
Button,
type FileInputOptions,
Expand Down Expand Up @@ -47,7 +47,6 @@ export function InviteModal({
inviteDisabledReason = null,
organizationId = null,
}: InviteModalProps) {
const router = useRouter()
const formRef = useRef<HTMLFormElement>(null)
const [emailItems, setEmailItems] = useState<TagItem[]>([])
const [userPermissions, setUserPermissions] = useState<UserPermissions[]>([])
Expand Down Expand Up @@ -103,9 +102,9 @@ export function InviteModal({
const isOutOfSeats = exceedsSeatCapacity || isAtSeatCapacity
const seatLimitReason = hasSeatData
? availableSeats === 0
? `No available seats. Using ${usedSeats} of ${totalSeats}.`
? `Internal invites may fail: using ${usedSeats} of ${totalSeats} seats. External workspace invites do not require seats.`
: exceedsSeatCapacity
? `Only ${availableSeats} seat${availableSeats === 1 ? '' : 's'} available.`
? `Only ${availableSeats} internal seat${availableSeats === 1 ? '' : 's'} available. External workspace invites do not require seats.`
: null
: null

Expand Down Expand Up @@ -421,23 +420,12 @@ export function InviteModal({
[workspaceId, userPerms.canAdmin, resendCooldowns, resendingInvitationIds, resendInvitation]
)

const handleUpgradeRedirect = useCallback(() => {
if (!workspaceId) return
onOpenChange(false)
router.push(`/workspace/${workspaceId}/settings/subscription`)
}, [onOpenChange, router, workspaceId])

const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault()

setErrorMessage(null)

if (isOutOfSeats) {
handleUpgradeRedirect()
return
}

if (!canInviteMembers || validEmails.length === 0 || !workspaceId) {
return
}
Expand Down Expand Up @@ -472,15 +460,7 @@ export function InviteModal({
}
)
},
[
canInviteMembers,
isOutOfSeats,
handleUpgradeRedirect,
validEmails,
workspaceId,
userPermissions,
batchSendInvitations,
]
[canInviteMembers, validEmails, workspaceId, userPermissions, batchSendInvitations]
)

const resetState = useCallback(() => {
Expand All @@ -504,6 +484,7 @@ export function InviteModal({
email: inv.email,
permissionType: inv.permissionType,
isPendingInvitation: true,
isExternal: inv.isExternal,
invitationId: inv.invitationId,
})),
[pendingInvitations]
Expand Down Expand Up @@ -641,10 +622,6 @@ export function InviteModal({
type='button'
variant='primary'
onClick={() => {
if (isOutOfSeats) {
handleUpgradeRedirect()
return
}
formRef.current?.requestSubmit()
}}
disabled={
Expand All @@ -653,7 +630,7 @@ export function InviteModal({
isSubmitting ||
isSaving ||
!workspaceId ||
(!isOutOfSeats && !hasNewInvites)
!hasNewInvites
}
className='ml-auto'
>
Expand All @@ -663,9 +640,7 @@ export function InviteModal({
? 'Admin Access Required'
: isSubmitting
? 'Inviting...'
: isOutOfSeats
? 'Upgrade to invite'
: 'Invite'}
: 'Invite'}
</Button>
</ModalFooter>
</form>
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/hooks/queries/invitations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface PendingInvitationRow {
workspaceId: string
email: string
permission: 'admin' | 'write' | 'read'
membershipIntent?: 'internal' | 'external'
status: string
createdAt: string
}
Expand All @@ -25,6 +26,7 @@ export interface WorkspaceInvitation {
email: string
permissionType: 'admin' | 'write' | 'read'
isPendingInvitation: boolean
isExternal: boolean
invitationId?: string
}

Expand All @@ -49,6 +51,7 @@ async function fetchPendingInvitations(
email: inv.email,
permissionType: inv.permission,
isPendingInvitation: true,
isExternal: inv.membershipIntent === 'external',
invitationId: inv.id,
})) || []
)
Expand Down
1 change: 1 addition & 0 deletions apps/sim/hooks/queries/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export type RosterPendingInvitation = {
email: string
role: string
kind: 'organization' | 'workspace'
membershipIntent?: 'internal' | 'external'
createdAt: string
expiresAt: string
inviteeName: string | null
Expand Down
Loading
Loading