improvement(organization): invite validation experience (#5008)

* improvement(organization): invite validation experience

* address comments
This commit is contained in:
Vikhyath Mondreti
2026-06-12 14:50:54 -07:00
committed by GitHub
parent 31e166f77f
commit 020baad9b3
13 changed files with 599 additions and 143 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ const emailTemplates = {
'workspace-invitation': () =>
renderWorkspaceInvitationEmail(
'John Smith',
'Engineering Team',
['Engineering Team'],
'https://sim.ai/workspace/invite/abc123'
),
@@ -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' })
@@ -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<string, Set<string>>()
for (const row of accessibleRows) {
const workspaceIds = accessibleByUserId.get(row.userId) ?? new Set<string>()
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<string, Set<string>>()
for (const row of pendingGrantRows) {
const email = row.email.toLowerCase()
const workspaceIds = pendingWorkspaceIdsByEmail.get(email) ?? new Set<string>()
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) {
@@ -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<string[]>([])
const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState<string[]>([])
@@ -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[]) => {
@@ -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}
/>
<TransferOwnershipDialog
@@ -620,6 +620,13 @@ function AuthSelector({
const [copySuccess, setCopySuccess] = useState(false)
const [invalidEmailItems, setInvalidEmailItems] = useState<TagItem[]>([])
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)
}
}
@@ -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 (
<EmailLayout
preview={`You've been invited to join the "${workspaceName}" workspace on ${brand.name}!`}
showUnsubscribe={false}
>
<EmailLayout preview={preview} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> invited you to join the <strong>{workspaceName}</strong>{' '}
workspace on {brand.name}.
<strong>{inviterName}</strong> invited you to join the{' '}
{workspaceNames.map((name, index) => (
<span key={`${name}-${index}`}>
{index > 0 &&
(index === workspaceNames.length - 1
? workspaceNames.length > 2
? ', and '
: ' and '
: ', ')}
<strong>{name}</strong>
</span>
))}{' '}
{isMultiple ? 'workspaces' : 'workspace'} on {brand.name}.
</Text>
<Link href={invitationLink} style={{ textDecoration: 'none' }}>
+2 -2
View File
@@ -203,13 +203,13 @@ export async function renderCreditPurchaseEmail(params: {
export async function renderWorkspaceInvitationEmail(
inviterName: string,
workspaceName: string,
workspaceNames: string[],
invitationLink: string
): Promise<string> {
return await render(
WorkspaceInvitationEmail({
inviterName,
workspaceName,
workspaceNames,
invitationLink,
})
)
@@ -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<HTMLButtonElement, ChipDropdownProps>(
[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<HTMLButtonElement, ChipDropdownProps>(
return (
<DropdownMenu
modal={false}
modal={insideModal}
{...(isMultiple
? {
open,
@@ -582,6 +582,21 @@ function ChipModalEmailsControl({
}: ChipModalEmailsFieldProps & { id: string; errorId: string }) {
const [items, setItems] = React.useState<TagItem[]>([])
/**
* 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<TagItem[]>(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 (
@@ -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 ? (
<DialogPrimitive.Title className='sr-only'>{srTitle}</DialogPrimitive.Title>
) : null}
{children}
<InsideModalContext.Provider value={true}>{children}</InsideModalContext.Provider>
</DialogPrimitive.Content>
</div>
</ModalPortal>
@@ -472,6 +484,7 @@ const ModalFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDi
ModalFooter.displayName = 'ModalFooter'
export {
InsideModalContext,
Modal,
ModalTrigger,
ModalContent,
@@ -334,26 +334,22 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
[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<HTMLInputElement>) => {
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(() => {
+15 -9
View File
@@ -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',