diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts index 32af9724073..c99e3ff9f6e 100644 --- a/apps/sim/app/api/emails/preview/route.ts +++ b/apps/sim/app/api/emails/preview/route.ts @@ -41,7 +41,7 @@ const emailTemplates = { 'workspace-invitation': () => renderWorkspaceInvitationEmail( 'John Smith', - 'Engineering Team', + ['Engineering Team'], 'https://sim.ai/workspace/invite/abc123' ), diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts index d9abf178947..c069e3c950e 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts @@ -79,6 +79,15 @@ vi.mock('@sim/db/schema', () => ({ organizationId: 'workspace.organizationId', workspaceMode: 'workspace.workspaceMode', }, + permissions: { + entityId: 'permissions.entityId', + entityType: 'permissions.entityType', + userId: 'permissions.userId', + }, + invitationWorkspaceGrant: { + invitationId: 'invitationWorkspaceGrant.invitationId', + workspaceId: 'invitationWorkspaceGrant.workspaceId', + }, })) vi.mock('drizzle-orm', () => ({ @@ -182,6 +191,175 @@ describe('POST /api/organizations/[id]/invitations', () => { expect(mockCancelPendingInvitation).not.toHaveBeenCalled() }) + it('sends a workspace invitation to an existing member for selected workspaces they lack', async () => { + mockGetSession.mockResolvedValue( + createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) + ) + mockDbState.selectResults = [ + [{ role: 'owner' }], + [{ name: 'Org One' }], + [{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }], + [{ id: 'ws-2', organizationId: 'org-1', workspaceMode: 'organization' }], + [{ userId: 'user-2', userEmail: 'member@example.com' }], + [], + [{ userId: 'user-2', workspaceId: 'ws-1' }], + [], + [{ name: 'Owner', email: 'owner@example.com' }], + ] + + const response = await POST( + createMockRequest( + 'POST', + { + emails: ['member@example.com'], + workspaceInvitations: [ + { workspaceId: 'ws-1', permission: 'write' }, + { workspaceId: 'ws-2', permission: 'write' }, + ], + }, + {}, + 'http://localhost/api/organizations/org-1/invitations?batch=true' + ), + { params: Promise.resolve({ id: 'org-1' }) } + ) + + expect(response.status).toBe(200) + expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(1) + expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'workspace', + email: 'member@example.com', + organizationId: 'org-1', + membershipIntent: 'internal', + grants: [{ workspaceId: 'ws-2', permission: 'write' }], + }) + ) + expect(mockSendInvitationEmail).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'workspace', + email: 'member@example.com', + grants: [{ workspaceId: 'ws-2', permission: 'write' }], + }) + ) + + const body = await response.json() + expect(body.data.invitationsSent).toBe(1) + expect(body.data.invitedEmails).toEqual(['member@example.com']) + expect(body.data.existingMembers).toEqual([]) + }) + + it('returns 400 when an existing member already has access to every selected workspace', async () => { + mockGetSession.mockResolvedValue( + createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) + ) + mockDbState.selectResults = [ + [{ role: 'owner' }], + [{ name: 'Org One' }], + [{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }], + [{ userId: 'user-2', userEmail: 'member@example.com' }], + [], + [{ userId: 'user-2', workspaceId: 'ws-1' }], + [], + ] + + const response = await POST( + createMockRequest( + 'POST', + { + emails: ['member@example.com'], + workspaceInvitations: [{ workspaceId: 'ws-1', permission: 'write' }], + }, + {}, + 'http://localhost/api/organizations/org-1/invitations?batch=true' + ), + { params: Promise.resolve({ id: 'org-1' }) } + ) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toContain('already has access') + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + }) + + it('invites new emails to the organization and existing members to workspaces in one batch', async () => { + mockGetSession.mockResolvedValue( + createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) + ) + mockDbState.selectResults = [ + [{ role: 'owner' }], + [{ name: 'Org One' }], + [{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }], + [{ userId: 'user-2', userEmail: 'member@example.com' }], + [], + [], + [], + [{ name: 'Owner', email: 'owner@example.com' }], + ] + + const response = await POST( + createMockRequest( + 'POST', + { + emails: ['new@example.com', 'member@example.com'], + workspaceInvitations: [{ workspaceId: 'ws-1', permission: 'read' }], + }, + {}, + 'http://localhost/api/organizations/org-1/invitations?batch=true' + ), + { params: Promise.resolve({ id: 'org-1' }) } + ) + + expect(response.status).toBe(200) + expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(2) + expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'organization', + email: 'new@example.com', + grants: [{ workspaceId: 'ws-1', permission: 'read' }], + }) + ) + expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'workspace', + email: 'member@example.com', + grants: [{ workspaceId: 'ws-1', permission: 'read' }], + }) + ) + + const body = await response.json() + expect(body.data.invitationsSent).toBe(2) + expect(body.data.invitedEmails).toEqual(['new@example.com', 'member@example.com']) + }) + + it('still rejects existing members on the non-batch organization invite path', async () => { + mockGetSession.mockResolvedValue( + createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) + ) + mockDbState.selectResults = [ + [{ role: 'owner' }], + [{ name: 'Org One' }], + [{ userId: 'user-2', userEmail: 'member@example.com' }], + [], + ] + + const response = await POST( + createMockRequest( + 'POST', + { emails: ['member@example.com'] }, + {}, + 'http://localhost/api/organizations/org-1/invitations' + ), + { params: Promise.resolve({ id: 'org-1' }) } + ) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe( + 'Failed to send invitation. User is already a part of the organization.' + ) + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + }) + it('rolls back the pending invitation when email delivery fails', async () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index 42f927cd918..de6bee05b77 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -1,8 +1,17 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { invitation, member, organization, user, workspace } from '@sim/db/schema' +import { + invitation, + invitationWorkspaceGrant, + member, + organization, + permissions, + user, + workspace, +} from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { inviteOrganizationMembersContract, @@ -188,6 +197,10 @@ export const POST = withRouteHandler( } for (const wsInvitation of workspaceInvitations) { + if (validGrants.some((grant) => grant.workspaceId === wsInvitation.workspaceId)) { + continue + } + const canInvite = await hasWorkspaceAdminAccess(session.user.id, wsInvitation.workspaceId) if (!canInvite) { return NextResponse.json( @@ -236,12 +249,15 @@ export const POST = withRouteHandler( } const existingMembers = await db - .select({ userEmail: user.email }) + .select({ userId: member.userId, userEmail: user.email }) .from(member) .innerJoin(user, eq(member.userId, user.id)) .where(eq(member.organizationId, organizationId)) - const existingEmails = existingMembers.map((m) => m.userEmail.toLowerCase()) - const newEmails = processedEmails.filter((email) => !existingEmails.includes(email)) + const memberUserIdByEmail = new Map( + existingMembers.map((m) => [m.userEmail.toLowerCase(), m.userId]) + ) + const newEmails = processedEmails.filter((email) => !memberUserIdByEmail.has(email)) + const memberEmails = processedEmails.filter((email) => memberUserIdByEmail.has(email)) const existingInvitations = await db .select({ email: invitation.email }) @@ -250,19 +266,106 @@ export const POST = withRouteHandler( const pendingEmails = existingInvitations.map((i) => i.email.toLowerCase()) const emailsToInvite = newEmails.filter((email) => !pendingEmails.includes(email)) - if (emailsToInvite.length === 0) { - const isSingleEmail = processedEmails.length === 1 - const existingMembersEmails = processedEmails.filter((email) => - existingEmails.includes(email) + /** + * Existing organization members are not re-invited to the organization, + * but in batch mode they still receive a workspace invitation covering + * the selected workspaces they don't already have access to (or a + * pending invitation for). The inviter's own email is always treated as + * covered. + */ + const memberWorkspaceInvites: Array<{ email: string; grants: WorkspaceGrantPayload[] }> = [] + const membersAlreadyCovered: string[] = [] + + if (isBatch) { + const inviterEmail = session.user.email?.toLowerCase() ?? null + const eligibleMemberEmails = memberEmails.filter((email) => email !== inviterEmail) + membersAlreadyCovered.push(...memberEmails.filter((email) => email === inviterEmail)) + + const grantWorkspaceIds = validGrants.map((grant) => grant.workspaceId) + const eligibleMemberUserIds = eligibleMemberEmails.map( + (email) => memberUserIdByEmail.get(email) as string ) + + const accessibleRows = + eligibleMemberUserIds.length > 0 + ? await db + .select({ userId: permissions.userId, workspaceId: permissions.entityId }) + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + inArray(permissions.userId, eligibleMemberUserIds), + inArray(permissions.entityId, grantWorkspaceIds) + ) + ) + : [] + const accessibleByUserId = new Map>() + for (const row of accessibleRows) { + const workspaceIds = accessibleByUserId.get(row.userId) ?? new Set() + workspaceIds.add(row.workspaceId) + accessibleByUserId.set(row.userId, workspaceIds) + } + + const pendingGrantRows = + eligibleMemberEmails.length > 0 + ? await db + .select({ + email: invitation.email, + workspaceId: invitationWorkspaceGrant.workspaceId, + }) + .from(invitationWorkspaceGrant) + .innerJoin(invitation, eq(invitation.id, invitationWorkspaceGrant.invitationId)) + .where( + and( + inArray(invitationWorkspaceGrant.workspaceId, grantWorkspaceIds), + inArray(invitation.email, eligibleMemberEmails), + eq(invitation.status, 'pending') + ) + ) + : [] + const pendingWorkspaceIdsByEmail = new Map>() + for (const row of pendingGrantRows) { + const email = row.email.toLowerCase() + const workspaceIds = pendingWorkspaceIdsByEmail.get(email) ?? new Set() + workspaceIds.add(row.workspaceId) + pendingWorkspaceIdsByEmail.set(email, workspaceIds) + } + + for (const email of eligibleMemberEmails) { + const memberUserId = memberUserIdByEmail.get(email) as string + const accessibleWorkspaceIds = accessibleByUserId.get(memberUserId) + const pendingWorkspaceIds = pendingWorkspaceIdsByEmail.get(email) + + const grantsNeeded = validGrants.filter( + (grant) => + !accessibleWorkspaceIds?.has(grant.workspaceId) && + !pendingWorkspaceIds?.has(grant.workspaceId) + ) + + if (grantsNeeded.length > 0) { + memberWorkspaceInvites.push({ email, grants: grantsNeeded }) + } else { + membersAlreadyCovered.push(email) + } + } + } else { + membersAlreadyCovered.push(...memberEmails) + } + + if (emailsToInvite.length === 0 && memberWorkspaceInvites.length === 0) { + const isSingleEmail = processedEmails.length === 1 const pendingInvitationEmails = processedEmails.filter((email) => pendingEmails.includes(email) ) if (isSingleEmail) { - if (existingMembersEmails.length > 0) { + if (membersAlreadyCovered.length > 0) { return NextResponse.json( - { error: 'Failed to send invitation. User is already a part of the organization.' }, + { + error: isBatch + ? 'Failed to send invitation. User already has access or a pending invitation to every selected workspace.' + : 'Failed to send invitation. User is already a part of the organization.', + }, { status: 400 } ) } @@ -279,9 +382,11 @@ export const POST = withRouteHandler( return NextResponse.json( { - error: 'All emails are already members or have pending invitations.', + error: isBatch + ? 'All emails are already members with access to the selected workspaces or have pending invitations.' + : 'All emails are already members or have pending invitations.', details: { - existingMembers: existingMembersEmails, + existingMembers: membersAlreadyCovered, pendingInvitations: pendingInvitationEmails, }, }, @@ -291,9 +396,10 @@ export const POST = withRouteHandler( const orgSubscription = await getOrganizationSubscription(organizationId) const enforceFixedSeats = !!orgSubscription && isEnterprise(orgSubscription.plan) - const seatValidation = enforceFixedSeats - ? await validateSeatAvailability(organizationId, emailsToInvite.length) - : null + const seatValidation = + enforceFixedSeats && emailsToInvite.length > 0 + ? await validateSeatAvailability(organizationId, emailsToInvite.length) + : null if (seatValidation && !seatValidation.canInvite) { return NextResponse.json( { @@ -316,88 +422,148 @@ export const POST = withRouteHandler( .limit(1) const inviterName = inviterRow?.name || inviterRow?.email || 'A user' - const sentInvitations: Array<{ id: string; email: string }> = [] + /** + * Organization invitations (new emails, all selected grants) and + * workspace invitations (existing members, only the grants they lack) + * share one create/send/rollback pipeline; they differ only in `kind`, + * grants, and audit treatment. + */ + const pendingSends = [ + ...emailsToInvite.map((email) => ({ + kind: 'organization' as const, + email, + grants: validGrants, + })), + ...memberWorkspaceInvites.map((memberInvite) => ({ + kind: 'workspace' as const, + email: memberInvite.email, + grants: memberInvite.grants, + })), + ] + + const sentInvitations: Array<{ + id: string + email: string + kind: 'organization' | 'workspace' + workspaceIds: string[] + }> = [] const failedInvitations: Array<{ email: string; error: string }> = [] - for (const email of emailsToInvite) { + for (const send of pendingSends) { + const sendRole = send.kind === 'organization' ? role : 'member' try { const { invitationId, token } = await createPendingInvitation({ - kind: 'organization', - email, + kind: send.kind, + email: send.email, inviterId: session.user.id, organizationId, - role, - grants: validGrants, + membershipIntent: 'internal', + role: sendRole, + grants: send.grants, }) const emailResult = await sendInvitationEmail({ invitationId, token, - kind: 'organization', - email, + kind: send.kind, + email: send.email, inviterName, organizationId, - organizationRole: role, - grants: validGrants, + organizationRole: sendRole, + grants: send.grants, }) if (!emailResult.success) { - logger.error('Failed to send organization invitation email', { - email, + logger.error('Failed to send invitation email', { + kind: send.kind, + email: send.email, error: emailResult.error, }) failedInvitations.push({ - email, + email: send.email, error: emailResult.error || 'Unknown email delivery error', }) await cancelPendingInvitation(invitationId) continue } - sentInvitations.push({ id: invitationId, email }) + sentInvitations.push({ + id: invitationId, + email: send.email, + kind: send.kind, + workspaceIds: send.grants.map((grant) => grant.workspaceId), + }) } catch (creationError) { - logger.error('Failed to create organization invitation', { email, error: creationError }) + logger.error('Failed to create invitation', { + kind: send.kind, + email: send.email, + error: creationError, + }) failedInvitations.push({ - email, - error: - creationError instanceof Error - ? creationError.message - : 'Failed to create invitation', + email: send.email, + error: getErrorMessage(creationError, 'Failed to create invitation'), }) } } for (const inv of sentInvitations) { - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.ORG_INVITATION_CREATED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: organizationEntry.name, - description: `Invited ${inv.email} to organization as ${role}`, - metadata: { - invitationId: inv.id, - targetEmail: inv.email, - targetRole: role, - isBatch, - workspaceGrantCount: validGrants.length, - enforcedFixedSeats: enforceFixedSeats, - plan: orgSubscription?.plan ?? null, - }, - request, - }) + if (inv.kind === 'organization') { + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORG_INVITATION_CREATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: organizationEntry.name, + description: `Invited ${inv.email} to organization as ${role}`, + metadata: { + invitationId: inv.id, + targetEmail: inv.email, + targetRole: role, + isBatch, + workspaceGrantCount: validGrants.length, + enforcedFixedSeats: enforceFixedSeats, + plan: orgSubscription?.plan ?? null, + }, + request, + }) + continue + } + + for (const workspaceId of inv.workspaceIds) { + recordAudit({ + workspaceId, + actorId: session.user.id, + action: AuditAction.MEMBER_INVITED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: inv.email, + description: `Invited existing organization member ${inv.email} to workspace`, + metadata: { + invitationId: inv.id, + targetEmail: inv.email, + organizationId, + isBatch, + }, + request, + }) + } } - const sentEmails = sentInvitations.map((inv) => inv.email) + const sentOrgInvitations = sentInvitations.filter((inv) => inv.kind === 'organization') + const totalInvitationsSent = sentInvitations.length const responseData = { - invitationsSent: sentInvitations.length, - invitedEmails: sentEmails, + invitationsSent: totalInvitationsSent, + invitedEmails: sentInvitations.map((inv) => inv.email), failedInvitations, - existingMembers: processedEmails.filter((email) => existingEmails.includes(email)), - pendingInvitations: processedEmails.filter((email) => pendingEmails.includes(email)), + existingMembers: membersAlreadyCovered, + pendingInvitations: processedEmails.filter( + (email) => pendingEmails.includes(email) && !memberUserIdByEmail.has(email) + ), invalidEmails: invitationEmails.filter( (email) => !quickValidateEmail(email.trim().toLowerCase()).isValid ), @@ -405,15 +571,15 @@ export const POST = withRouteHandler( ...(seatValidation ? { seatInfo: { - seatsUsed: seatValidation.currentSeats + sentInvitations.length, + seatsUsed: seatValidation.currentSeats + sentOrgInvitations.length, maxSeats: seatValidation.maxSeats, - availableSeats: seatValidation.availableSeats - sentInvitations.length, + availableSeats: seatValidation.availableSeats - sentOrgInvitations.length, }, } : {}), } - if (failedInvitations.length > 0 && sentInvitations.length === 0) { + if (failedInvitations.length > 0 && totalInvitationsSent === 0) { return NextResponse.json( { success: false, @@ -430,7 +596,7 @@ export const POST = withRouteHandler( { success: false, error: 'Some invitation emails failed to send.', - message: `${sentInvitations.length} invitation(s) sent, ${failedInvitations.length} failed`, + message: `${totalInvitationsSent} invitation(s) sent, ${failedInvitations.length} failed`, data: responseData, }, { status: 207 } @@ -439,7 +605,7 @@ export const POST = withRouteHandler( return NextResponse.json({ success: true, - message: `${sentInvitations.length} invitation(s) sent successfully`, + message: `${totalInvitationsSent} invitation(s) sent successfully`, data: responseData, }) } catch (error) { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-invite-modal/organization-invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-invite-modal/organization-invite-modal.tsx index 813d31c34c5..121be256edc 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-invite-modal/organization-invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-invite-modal/organization-invite-modal.tsx @@ -29,21 +29,31 @@ interface OrganizationInviteModalProps { organizationId: string /** Workspaces the inviter can grant access to. */ workspaces: Array<{ id: string; name: string }> - /** Emails that already belong to the organization (rejected as duplicates). */ - existingEmails?: string[] + /** Emails of external collaborators (rejected — they cannot join the organization). */ + externalEmails?: string[] + /** + * Non-member emails with a pending invitation (rejected as duplicates). + * Member emails are always allowed — they receive workspace invitations for + * the selected workspaces they aren't in yet, deduped per workspace by the + * server — so the parent excludes them from this list. + */ + pendingEmails?: string[] } /** * Organization-level invite modal: enter emails, pick one or more workspaces to * grant access to, choose a role applied to every selected workspace, and send - * through the organization invite path. + * through the organization invite path. Emails of existing organization + * members are accepted — the server sends them workspace-only invitations for + * the selected workspaces they don't already have access to. */ export function OrganizationInviteModal({ open, onOpenChange, organizationId, workspaces, - existingEmails = [], + externalEmails = [], + pendingEmails = [], }: OrganizationInviteModalProps) { const [emails, setEmails] = useState([]) const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState([]) @@ -59,9 +69,14 @@ export function OrganizationInviteModal({ [workspaces] ) - const existingEmailSet = useMemo( - () => new Set(existingEmails.map((email) => email.toLowerCase())), - [existingEmails] + const externalEmailSet = useMemo( + () => new Set(externalEmails.map((email) => email.toLowerCase())), + [externalEmails] + ) + + const pendingEmailSet = useMemo( + () => new Set(pendingEmails.map((email) => email.toLowerCase())), + [pendingEmails] ) const validateEmail = useCallback( @@ -69,12 +84,15 @@ export function OrganizationInviteModal({ if (session?.user?.email && session.user.email.toLowerCase() === email) { return 'You cannot invite yourself' } - if (existingEmailSet.has(email)) { - return `${email} is already in this organization` + if (externalEmailSet.has(email)) { + return `${email} belongs to another organization and can't be invited. Invite them to individual workspaces from the Teammates tab.` + } + if (pendingEmailSet.has(email)) { + return `${email} already has a pending invitation` } return null }, - [session?.user?.email, existingEmailSet] + [session?.user?.email, externalEmailSet, pendingEmailSet] ) const handleEmailsChange = useCallback((next: string[]) => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index bdddff104e2..7088804bc38 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -98,10 +98,29 @@ export function TeamManagement() { } : null - const existingEmails = useMemo(() => { - const memberEmails = (roster?.members ?? []).map((member) => member.email) - const pendingEmails = (roster?.pendingInvitations ?? []).map((invitation) => invitation.email) - return [...memberEmails, ...pendingEmails] + const externalEmails = useMemo( + () => + (roster?.members ?? []) + .filter((member) => member.role === 'external') + .map((member) => member.email), + [roster] + ) + + /** + * Pending invitations for emails that already belong to a member are + * excluded: members can always be re-invited to additional workspaces (the + * server dedupes per workspace), so only non-member pending emails are + * blocked in the invite modal. + */ + const pendingEmails = useMemo(() => { + const memberEmailSet = new Set( + (roster?.members ?? []) + .filter((member) => member.role !== 'external') + .map((member) => member.email.toLowerCase()) + ) + return (roster?.pendingInvitations ?? []) + .map((invitation) => invitation.email) + .filter((email) => !memberEmailSet.has(email.toLowerCase())) }, [roster]) useEffect(() => { @@ -344,7 +363,8 @@ export function TeamManagement() { onOpenChange={setInviteModalOpen} organizationId={displayOrganization.id} workspaces={roster?.workspaces ?? []} - existingEmails={existingEmails} + externalEmails={externalEmails} + pendingEmails={pendingEmails} /> ([]) + const emailsRef = useRef(emails) + const invalidEmailItemsRef = useRef(invalidEmailItems) + + useEffect(() => { + emailsRef.current = emails + }, [emails]) + useEffect(() => { if (!copySuccess) return const timer = setTimeout(() => setCopySuccess(false), 2000) @@ -645,20 +652,22 @@ function AuthSelector({ const isValid = validation.isValid || isDomainPattern if ( - emails.includes(normalized) || - invalidEmailItems.some((item) => item.value === normalized) + emailsRef.current.includes(normalized) || + invalidEmailItemsRef.current.some((item) => item.value === normalized) ) { return false } if (isValid) { setEmailError('') - onEmailsChange([...emails, normalized]) + emailsRef.current = [...emailsRef.current, normalized] + onEmailsChange(emailsRef.current) } else { - setInvalidEmailItems((prev) => [ - ...prev, + invalidEmailItemsRef.current = [ + ...invalidEmailItemsRef.current, { value: normalized, isValid, error: validation.reason ?? 'Invalid email format' }, - ]) + ] + setInvalidEmailItems(invalidEmailItemsRef.current) } return isValid @@ -674,9 +683,13 @@ function AuthSelector({ if (!itemToRemove) return if (itemToRemove.isValid) { - onEmailsChange(emails.filter((e) => e !== itemToRemove.value)) + emailsRef.current = emailsRef.current.filter((e) => e !== itemToRemove.value) + onEmailsChange(emailsRef.current) } else { - setInvalidEmailItems((prev) => prev.filter((item) => item.value !== itemToRemove.value)) + invalidEmailItemsRef.current = invalidEmailItemsRef.current.filter( + (item) => item.value !== itemToRemove.value + ) + setInvalidEmailItems(invalidEmailItemsRef.current) } } diff --git a/apps/sim/components/emails/invitations/workspace-invitation-email.tsx b/apps/sim/components/emails/invitations/workspace-invitation-email.tsx index ba3de149419..819388edfbc 100644 --- a/apps/sim/components/emails/invitations/workspace-invitation-email.tsx +++ b/apps/sim/components/emails/invitations/workspace-invitation-email.tsx @@ -4,27 +4,40 @@ import { EmailLayout } from '@/components/emails/components' import { getBrandConfig } from '@/ee/whitelabeling' interface WorkspaceInvitationEmailProps { - workspaceName?: string + /** Workspaces this invitation grants access to (one entry per workspace). */ + workspaceNames?: string[] inviterName?: string invitationLink?: string } export function WorkspaceInvitationEmail({ - workspaceName = 'Workspace', + workspaceNames = ['Workspace'], inviterName = 'Someone', invitationLink = '', }: WorkspaceInvitationEmailProps) { const brand = getBrandConfig() + const isMultiple = workspaceNames.length > 1 + const preview = isMultiple + ? `You've been invited to join ${workspaceNames.length} workspaces on ${brand.name}!` + : `You've been invited to join the "${workspaceNames[0]}" workspace on ${brand.name}!` return ( - + Hello, - {inviterName} invited you to join the {workspaceName}{' '} - workspace on {brand.name}. + {inviterName} invited you to join the{' '} + {workspaceNames.map((name, index) => ( + + {index > 0 && + (index === workspaceNames.length - 1 + ? workspaceNames.length > 2 + ? ', and ' + : ' and ' + : ', ')} + {name} + + ))}{' '} + {isMultiple ? 'workspaces' : 'workspace'} on {brand.name}. diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts index 16bfaf4af1e..bb601bcbcd9 100644 --- a/apps/sim/components/emails/render.ts +++ b/apps/sim/components/emails/render.ts @@ -203,13 +203,13 @@ export async function renderCreditPurchaseEmail(params: { export async function renderWorkspaceInvitationEmail( inviterName: string, - workspaceName: string, + workspaceNames: string[], invitationLink: string ): Promise { return await render( WorkspaceInvitationEmail({ inviterName, - workspaceName, + workspaceNames, invitationLink, }) ) diff --git a/apps/sim/components/emcn/components/chip-dropdown/chip-dropdown.tsx b/apps/sim/components/emcn/components/chip-dropdown/chip-dropdown.tsx index fe73ee4c779..116eec410f0 100644 --- a/apps/sim/components/emcn/components/chip-dropdown/chip-dropdown.tsx +++ b/apps/sim/components/emcn/components/chip-dropdown/chip-dropdown.tsx @@ -1,6 +1,13 @@ 'use client' -import { type ComponentType, forwardRef, type ReactNode, useMemo, useState } from 'react' +import { + type ComponentType, + forwardRef, + type ReactNode, + useContext, + useMemo, + useState, +} from 'react' import type { VariantProps } from 'class-variance-authority' import { chipVariants, TRIGGER_BORDER_CLASS } from '@/components/emcn/components/chip/chip' import { @@ -10,6 +17,7 @@ import { DropdownMenuSearchInput, DropdownMenuTrigger, } from '@/components/emcn/components/dropdown-menu/dropdown-menu' +import { InsideModalContext } from '@/components/emcn/components/modal/modal' import { Check, ChevronDown } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' @@ -168,6 +176,15 @@ const ChipDropdown = forwardRef( [isMultiple, props.value] ) + /** + * Inside a modal dialog the menu must be modal too: a non-modal menu + * portaled to `body` inherits the dialog's `pointer-events: none` body + * lock and outside-scroll lock (unclickable, unscrollable), and the + * dialog's still-active focus trap fights item focus. Outside dialogs we + * stay non-modal so filter chips don't lock page scroll while open. + */ + const insideModal = useContext(InsideModalContext) + const [open, setOpen] = useState(false) const [search, setSearch] = useState('') const searchable = isMultiple && props.searchable === true @@ -254,7 +271,7 @@ const ChipDropdown = forwardRef( return ( ([]) + /** + * Synchronous mirror of `items`. Pasting multiple values calls `handleAdd` + * once per value within a single event, before React re-renders — reading + * the `items` state there would make every call see the same stale array + * and each add overwrite the previous one (only the last pasted email + * survives). All reads and writes go through the ref so consecutive adds + * compose; `commitItems` keeps state and ref in lockstep. + */ + const itemsRef = React.useRef(items) + + const commitItems = React.useCallback((next: TagItem[]) => { + itemsRef.current = next + setItems(next) + }, []) + /** * Reconcile internal `items` with the consumer's `value` when the latter * changes externally (programmatic clear, partial-failure reseed, etc.). @@ -589,25 +604,25 @@ function ChipModalEmailsControl({ * `items` already match `value` and this is a no-op. */ React.useEffect(() => { - setItems((prev) => { - const prevValid = prev.filter((item) => item.isValid).map((item) => item.value) - if (prevValid.length === value.length && prevValid.every((v, idx) => v === value[idx])) { - return prev - } - return value.map((v) => ({ value: v, isValid: true })) - }) + const prevValid = itemsRef.current.filter((item) => item.isValid).map((item) => item.value) + if (prevValid.length === value.length && prevValid.every((v, idx) => v === value[idx])) { + return + } + itemsRef.current = value.map((v) => ({ value: v, isValid: true })) + setItems(itemsRef.current) }, [value]) const handleAdd = React.useCallback( (raw: string): boolean => { const email = raw.trim().toLowerCase() if (!email) return false - if (items.some((item) => item.value === email)) return false + const current = itemsRef.current + if (current.some((item) => item.value === email)) return false const formatCheck = quickValidateEmail(email) if (!formatCheck.isValid) { - setItems((prev) => [ - ...prev, + commitItems([ + ...current, { value: email, isValid: false, error: formatCheck.reason ?? 'Invalid email format' }, ]) return false @@ -615,28 +630,29 @@ function ChipModalEmailsControl({ const reason = validate?.(email) if (reason) { - setItems((prev) => [...prev, { value: email, isValid: false, error: reason }]) + commitItems([...current, { value: email, isValid: false, error: reason }]) return false } - const next = [...items, { value: email, isValid: true }] - setItems(next) + const next = [...current, { value: email, isValid: true }] + commitItems(next) onChange(next.filter((item) => item.isValid).map((item) => item.value)) return true }, - [items, validate, onChange] + [validate, onChange, commitItems] ) const handleRemove = React.useCallback( (_removed: string, index: number) => { - const wasValid = items[index]?.isValid ?? false - const next = items.filter((_, i) => i !== index) - setItems(next) + const current = itemsRef.current + const wasValid = current[index]?.isValid ?? false + const next = current.filter((_, i) => i !== index) + commitItems(next) if (wasValid) { onChange(next.filter((item) => item.isValid).map((item) => item.value)) } }, - [items, onChange] + [onChange, commitItems] ) return ( diff --git a/apps/sim/components/emcn/components/modal/modal.tsx b/apps/sim/components/emcn/components/modal/modal.tsx index e709b19f020..bfd08b6e47e 100644 --- a/apps/sim/components/emcn/components/modal/modal.tsx +++ b/apps/sim/components/emcn/components/modal/modal.tsx @@ -56,6 +56,18 @@ function hasOpenFloatingLayer() { return Boolean(document.querySelector('[data-radix-popper-content-wrapper] [data-state="open"]')) } +/** + * Whether the current subtree renders inside a `ModalContent`. + * + * Floating EMCN controls (e.g. `ChipDropdown`) read this to switch their + * Radix popper to modal behavior. A non-modal popper portaled to `body` + * underneath a modal dialog inherits the dialog's `pointer-events: none` + * body lock and its outside-scroll lock, leaving the popper unclickable and + * unscrollable; a modal popper pauses the dialog's focus trap and carries + * its own scroll allowance. + */ +const InsideModalContext = React.createContext(false) + /** * Root modal component. Manages open state. */ @@ -244,7 +256,7 @@ const ModalContent = React.forwardRef< {srTitle ? ( {srTitle} ) : null} - {children} + {children} @@ -472,6 +484,7 @@ const ModalFooter = React.forwardRef( [inputValue, triggerKeys, onAdd, items, onRemove] ) + /** + * Pasted values are committed through `onAdd` exactly like typing + Enter: + * consumers render rejected values as flagged invalid chips, so nothing is + * re-staged into the input afterwards — doing so would display the same + * value twice (the invalid chip plus the raw text in the typing buffer). + */ const handlePaste = React.useCallback( (e: React.ClipboardEvent) => { e.preventDefault() const pastedText = e.clipboardData.getData('text') const pastedValues = pastedText.split(/[\s,;]+/).filter(Boolean) - - let addedCount = 0 pastedValues.forEach((value) => { - if (onAdd(value.trim())) { - addedCount++ - } + onAdd(value.trim()) }) - - if (addedCount === 0 && pastedValues.length === 1) { - const newValue = inputValue + pastedValues[0] - setInputValue(newValue) - onInputChange?.(newValue) - } }, - [onAdd, inputValue, onInputChange] + [onAdd] ) const handleBlur = React.useCallback(() => { diff --git a/apps/sim/lib/invitations/send.ts b/apps/sim/lib/invitations/send.ts index 893e6c38631..6a355e45466 100644 --- a/apps/sim/lib/invitations/send.ts +++ b/apps/sim/lib/invitations/send.ts @@ -185,27 +185,33 @@ export async function sendInvitationEmail( const inviteUrl = `${getBaseUrl()}/invite/${input.invitationId}?token=${input.token}` if (input.kind === 'workspace') { - const primaryGrant = input.grants[0] - if (!primaryGrant) { + if (input.grants.length === 0) { return { success: false, error: 'Workspace invitation is missing a workspace grant' } } - const [workspaceRow] = await db - .select({ name: workspace.name }) + const grantWorkspaceIds = input.grants.map((grant) => grant.workspaceId) + const workspaceRows = await db + .select({ id: workspace.id, name: workspace.name }) .from(workspace) - .where(eq(workspace.id, primaryGrant.workspaceId)) - .limit(1) + .where(inArray(workspace.id, grantWorkspaceIds)) + const workspaceNames = grantWorkspaceIds.map( + (id) => workspaceRows.find((row) => row.id === id)?.name || 'a workspace' + ) - const workspaceName = workspaceRow?.name || 'a workspace' const emailHtml = await renderWorkspaceInvitationEmail( input.inviterName, - workspaceName, + workspaceNames, inviteUrl ) + const subject = + workspaceNames.length === 1 + ? `You've been invited to join "${workspaceNames[0]}" on Sim` + : `You've been invited to join ${workspaceNames.length} workspaces on Sim` + const result = await sendEmail({ to: input.email, - subject: `You've been invited to join "${workspaceName}" on Sim`, + subject, html: emailHtml, from: getFromEmailAddress(), emailType: 'transactional',