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 c069e3c950e..43f4ff6ce8b 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts @@ -12,6 +12,7 @@ const { mockCreatePendingInvitation, mockSendInvitationEmail, mockCancelPendingInvitation, + mockGrantWorkspaceAccessDirectly, } = vi.hoisted(() => ({ mockDbState: { selectResults: [] as any[], @@ -22,6 +23,7 @@ const { mockCreatePendingInvitation: vi.fn(), mockSendInvitationEmail: vi.fn(), mockCancelPendingInvitation: vi.fn(), + mockGrantWorkspaceAccessDirectly: vi.fn(), })) function createSelectChain() { @@ -115,6 +117,10 @@ vi.mock('@/lib/invitations/send', () => ({ cancelPendingInvitation: mockCancelPendingInvitation, })) +vi.mock('@/lib/invitations/direct-grant', () => ({ + grantWorkspaceAccessDirectly: mockGrantWorkspaceAccessDirectly, +})) + vi.mock('@/lib/messaging/email/validation', () => ({ quickValidateEmail: vi.fn((email: string) => ({ isValid: email.includes('@') })), })) @@ -151,6 +157,7 @@ describe('POST /api/organizations/[id]/invitations', () => { expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }) mockSendInvitationEmail.mockResolvedValue({ success: true }) + mockGrantWorkspaceAccessDirectly.mockResolvedValue({ outcome: 'added', permission: 'write' }) }) it('creates a unified invitation and sends a single email', async () => { @@ -191,15 +198,15 @@ 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 () => { + it('adds an existing member directly to selected workspaces they lack (no invitation/email)', 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' }], + [{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }], + [{ id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }], [{ userId: 'user-2', userEmail: 'member@example.com' }], [], [{ userId: 'user-2', workspaceId: 'ws-1' }], @@ -224,30 +231,111 @@ describe('POST /api/organizations/[id]/invitations', () => { ) expect(response.status).toBe(200) - expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(1) - expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledTimes(1) + expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledWith( expect.objectContaining({ - kind: 'workspace', + userId: 'user-2', email: 'member@example.com', + workspaceId: 'ws-2', + permission: 'write', 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.invitationsSent).toBe(0) + expect(body.data.directlyAdded).toEqual(['member@example.com']) + expect(body.data.directlyAddedCount).toBe(1) expect(body.data.existingMembers).toEqual([]) }) + it('reports a partially-failed member only as added, never in both buckets', async () => { + mockGetSession.mockResolvedValue( + createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) + ) + // First grant succeeds, second throws (e.g. transient DB error). + mockGrantWorkspaceAccessDirectly + .mockResolvedValueOnce({ outcome: 'added', permission: 'write' }) + .mockRejectedValueOnce(new Error('db blip')) + mockDbState.selectResults = [ + [{ role: 'owner' }], + [{ name: 'Org One' }], + [{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }], + [{ id: 'ws-2', name: 'Workspace 2', 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: ['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(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledTimes(2) + const body = await response.json() + expect(body.data.directlyAdded).toEqual(['member@example.com']) + expect(body.data.failedInvitations).toEqual([]) + }) + + it('returns 207 with both successes and failures when one member is added and another fails', async () => { + mockGetSession.mockResolvedValue( + createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) + ) + mockGrantWorkspaceAccessDirectly + .mockResolvedValueOnce({ outcome: 'added', permission: 'write' }) + .mockRejectedValueOnce(new Error('db blip')) + mockDbState.selectResults = [ + [{ role: 'owner' }], + [{ name: 'Org One' }], + [{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }], + [ + { userId: 'user-a', userEmail: 'a@example.com' }, + { userId: 'user-b', userEmail: 'b@example.com' }, + ], + [], + [], + [], + [{ name: 'Owner', email: 'owner@example.com' }], + ] + + const response = await POST( + createMockRequest( + 'POST', + { + emails: ['a@example.com', 'b@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(207) + const body = await response.json() + expect(body.success).toBe(false) + expect(body.data.directlyAdded).toEqual(['a@example.com']) + expect(body.data.directlyAddedCount).toBe(1) + expect(body.data.failedInvitations).toEqual([{ email: 'b@example.com', error: 'db blip' }]) + }) + 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' }) @@ -281,14 +369,14 @@ describe('POST /api/organizations/[id]/invitations', () => { expect(mockCreatePendingInvitation).not.toHaveBeenCalled() }) - it('invites new emails to the organization and existing members to workspaces in one batch', async () => { + it('invites new emails to the organization and adds 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' }], + [{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }], [{ userId: 'user-2', userEmail: 'member@example.com' }], [], [], @@ -310,7 +398,7 @@ describe('POST /api/organizations/[id]/invitations', () => { ) expect(response.status).toBe(200) - expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(2) + expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(1) expect(mockCreatePendingInvitation).toHaveBeenCalledWith( expect.objectContaining({ kind: 'organization', @@ -318,17 +406,21 @@ describe('POST /api/organizations/[id]/invitations', () => { grants: [{ workspaceId: 'ws-1', permission: 'read' }], }) ) - expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledTimes(1) + expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledWith( expect.objectContaining({ - kind: 'workspace', + userId: 'user-2', email: 'member@example.com', - grants: [{ workspaceId: 'ws-1', permission: 'read' }], + 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']) + expect(body.data.invitationsSent).toBe(1) + expect(body.data.invitedEmails).toEqual(['new@example.com']) + expect(body.data.directlyAdded).toEqual(['member@example.com']) + expect(body.data.directlyAddedCount).toBe(1) }) it('still rejects existing members on the non-batch organization invite path', async () => { diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index de6bee05b77..afbd00e78ca 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -26,6 +26,7 @@ import { validateSeatAvailability, } from '@/lib/billing/validation/seat-management' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { grantWorkspaceAccessDirectly } from '@/lib/invitations/direct-grant' import { cancelPendingInvitation, createPendingInvitation, @@ -188,6 +189,7 @@ export const POST = withRouteHandler( } const validGrants: WorkspaceGrantPayload[] = [] + const workspaceNameById = new Map() if (isBatch) { if (!Array.isArray(workspaceInvitations) || workspaceInvitations.length === 0) { return NextResponse.json( @@ -214,6 +216,7 @@ export const POST = withRouteHandler( const [workspaceEntry] = await db .select({ id: workspace.id, + name: workspace.name, organizationId: workspace.organizationId, workspaceMode: workspace.workspaceMode, }) @@ -241,6 +244,7 @@ export const POST = withRouteHandler( await validateInvitationsAllowed(session.user.id, wsInvitation.workspaceId) + workspaceNameById.set(workspaceEntry.id, workspaceEntry.name) validGrants.push({ workspaceId: wsInvitation.workspaceId, permission: wsInvitation.permission, @@ -422,65 +426,45 @@ export const POST = withRouteHandler( .limit(1) const inviterName = inviterRow?.name || inviterRow?.email || 'A user' + const failedInvitations: Array<{ email: string; error: 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. + * Brand-new emails receive an organization invitation (with all selected + * workspace grants) that still requires acceptance — accepting is what + * joins them to the org and consumes a seat. */ - 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 }> = [] + const sentInvitations: Array<{ id: string; email: string; workspaceIds: string[] }> = [] - for (const send of pendingSends) { - const sendRole = send.kind === 'organization' ? role : 'member' + for (const email of emailsToInvite) { try { const { invitationId, token } = await createPendingInvitation({ - kind: send.kind, - email: send.email, + kind: 'organization', + email, inviterId: session.user.id, organizationId, membershipIntent: 'internal', - role: sendRole, - grants: send.grants, + role, + grants: validGrants, }) const emailResult = await sendInvitationEmail({ invitationId, token, - kind: send.kind, - email: send.email, + kind: 'organization', + email, inviterName, organizationId, - organizationRole: sendRole, - grants: send.grants, + organizationRole: role, + grants: validGrants, }) if (!emailResult.success) { logger.error('Failed to send invitation email', { - kind: send.kind, - email: send.email, + email, error: emailResult.error, }) failedInvitations.push({ - email: send.email, + email, error: emailResult.error || 'Unknown email delivery error', }) await cancelPendingInvitation(invitationId) @@ -489,76 +473,98 @@ export const POST = withRouteHandler( sentInvitations.push({ id: invitationId, - email: send.email, - kind: send.kind, - workspaceIds: send.grants.map((grant) => grant.workspaceId), + email, + workspaceIds: validGrants.map((grant) => grant.workspaceId), }) } catch (creationError) { logger.error('Failed to create invitation', { - kind: send.kind, - email: send.email, + email, error: creationError, }) failedInvitations.push({ - email: send.email, + email, error: getErrorMessage(creationError, 'Failed to create invitation'), }) } } - for (const inv of sentInvitations) { - 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 + /** + * Existing organization members are granted workspace access directly — + * no invitation, no acceptance step. They are already in the org, so no + * seat is consumed. The grant is idempotent and upgrades lower access. + */ + const directlyAdded: string[] = [] + + for (const memberInvite of memberWorkspaceInvites) { + const memberUserId = memberUserIdByEmail.get(memberInvite.email) + if (!memberUserId) continue + + let addedAny = false + let lastGrantError: string | null = null + for (const grant of memberInvite.grants) { + try { + const grantResult = await grantWorkspaceAccessDirectly({ + userId: memberUserId, + email: memberInvite.email, + workspaceId: grant.workspaceId, + workspaceName: workspaceNameById.get(grant.workspaceId) ?? 'a workspace', + permission: grant.permission, + organizationId, + actorId: session.user.id, + actorName: inviterName, + actorEmail: session.user.email, + request, + }) + + if (grantResult.outcome === 'added') addedAny = true + } catch (grantError) { + logger.error('Failed to grant workspace access directly', { + email: memberInvite.email, + workspaceId: grant.workspaceId, + error: grantError, + }) + lastGrantError = getErrorMessage(grantError, 'Failed to add member to workspace') + } } - 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, - }) + if (addedAny) { + directlyAdded.push(memberInvite.email) + } else if (lastGrantError) { + failedInvitations.push({ email: memberInvite.email, error: lastGrantError }) } } - const sentOrgInvitations = sentInvitations.filter((inv) => inv.kind === 'organization') + 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, + }) + } + const totalInvitationsSent = sentInvitations.length + const totalSucceeded = totalInvitationsSent + directlyAdded.length const responseData = { invitationsSent: totalInvitationsSent, invitedEmails: sentInvitations.map((inv) => inv.email), + directlyAdded, + directlyAddedCount: directlyAdded.length, failedInvitations, existingMembers: membersAlreadyCovered, pendingInvitations: processedEmails.filter( @@ -571,20 +577,25 @@ export const POST = withRouteHandler( ...(seatValidation ? { seatInfo: { - seatsUsed: seatValidation.currentSeats + sentOrgInvitations.length, + seatsUsed: seatValidation.currentSeats + totalInvitationsSent, maxSeats: seatValidation.maxSeats, - availableSeats: seatValidation.availableSeats - sentOrgInvitations.length, + availableSeats: seatValidation.availableSeats - totalInvitationsSent, }, } : {}), } - if (failedInvitations.length > 0 && totalInvitationsSent === 0) { + const summaryParts: string[] = [] + if (totalInvitationsSent > 0) summaryParts.push(`${totalInvitationsSent} invitation(s) sent`) + if (directlyAdded.length > 0) summaryParts.push(`${directlyAdded.length} member(s) added`) + const summary = summaryParts.join(', ') + + if (failedInvitations.length > 0 && totalSucceeded === 0) { return NextResponse.json( { success: false, - error: 'Failed to send invitation emails.', - message: 'No invitation emails could be delivered.', + error: 'Failed to send invitations.', + message: 'No invitations could be delivered.', data: responseData, }, { status: 502 } @@ -595,8 +606,8 @@ export const POST = withRouteHandler( return NextResponse.json( { success: false, - error: 'Some invitation emails failed to send.', - message: `${totalInvitationsSent} invitation(s) sent, ${failedInvitations.length} failed`, + error: 'Some invitations failed.', + message: `${summary}, ${failedInvitations.length} failed`, data: responseData, }, { status: 207 } @@ -605,7 +616,7 @@ export const POST = withRouteHandler( return NextResponse.json({ success: true, - message: `${totalInvitationsSent} invitation(s) sent successfully`, + message: `${summary || 'No changes'} successfully`, data: responseData, }) } catch (error) { diff --git a/apps/sim/app/api/workspaces/invitations/batch/route.ts b/apps/sim/app/api/workspaces/invitations/batch/route.ts index 02dc504458a..391a1cb8952 100644 --- a/apps/sim/app/api/workspaces/invitations/batch/route.ts +++ b/apps/sim/app/api/workspaces/invitations/batch/route.ts @@ -61,6 +61,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) const successful: string[] = [] + const added: string[] = [] const failed: BatchInvitationFailure[] = [] const invitations: WorkspaceInvitationResult[] = [] const seenEmails = new Set() @@ -83,7 +84,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { permission: item.permission, request: req, }) - successful.push(invitation.email) + if (invitation.instantAdd) { + // Only report an actual insertion; an `unchanged` outcome means the + // user already had access (rare race) and is a silent no-op. + if (invitation.outcome === 'added') added.push(invitation.email) + } else { + successful.push(invitation.email) + } invitations.push(invitation) } catch (error) { if (error instanceof WorkspaceInvitationError) { @@ -102,6 +109,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ success: failed.length === 0, successful, + added, failed, invitations, }) 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 121be256edc..50cd2d2d5b8 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 @@ -10,6 +10,7 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, + toast, } from '@/components/emcn' import { useSession } from '@/lib/auth/auth-client' import type { PermissionType } from '@/lib/workspaces/permissions/utils' @@ -112,7 +113,42 @@ export function OrganizationInviteModal({ inviteMember.mutate( { emails, orgId: organizationId, workspaceInvitations }, { - onSuccess: () => { + onSuccess: (result) => { + const summary = + 'data' in result && result.data && typeof result.data === 'object' + ? (result.data as { + invitationsSent?: number + directlyAddedCount?: number + failedInvitations?: Array<{ email: string; error: string }> + }) + : null + const addedCount = summary?.directlyAddedCount ?? 0 + const sentCount = summary?.invitationsSent ?? 0 + const failed = summary?.failedInvitations ?? [] + + // Surface partial successes even when some addresses fail. + const parts: string[] = [] + if (addedCount > 0) { + parts.push(`${addedCount} member${addedCount === 1 ? '' : 's'} added`) + } + if (sentCount > 0) { + parts.push(`${sentCount} invite${sentCount === 1 ? '' : 's'} sent`) + } + if (parts.length > 0) { + toast.success(parts.join(' · ')) + } + + if (failed.length > 0) { + // Keep only the failed addresses (workspaces stay selected) for retry. + setEmails(failed.map((entry) => entry.email)) + setErrorMessage( + failed.length === 1 + ? failed[0].error + : `${failed.length} invitations failed. ${failed[0].error}` + ) + return + } + setEmails([]) setSelectedWorkspaceIds([]) onOpenChange(false) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx index bb07a332c48..144fa39c69c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { ChipDropdown, ChipInput, @@ -12,6 +13,7 @@ import { DropdownMenuTrigger, MoreHorizontal, Search, + toast, } from '@/components/emcn' import type { OrgRole, PermissionType } from '@/components/permissions' import type { @@ -29,7 +31,10 @@ import { ManageCreditsModal, type ManageCreditsTarget, } from '@/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal' -import { useUpdateWorkspacePermissions } from '@/hooks/queries/invitations' +import { + useRemoveWorkspaceMember, + useUpdateWorkspacePermissions, +} from '@/hooks/queries/invitations' import { useCancelInvitation, useResendInvitation, @@ -93,6 +98,7 @@ export function OrganizationMemberLists({ const updateMemberRole = useUpdateOrganizationMemberRole() const updateInvitation = useUpdateInvitation() const updatePermissions = useUpdateWorkspacePermissions() + const removeWorkspaceMember = useRemoveWorkspaceMember() const cancelInvitation = useCancelInvitation() const resendInvitation = useResendInvitation() @@ -300,8 +306,15 @@ export function OrganizationMemberLists({ access: RosterWorkspaceAccess ) => { const rowUserIsOrgAdmin = member.role === 'owner' || member.role === 'admin' - const wouldDemoteSelf = member.userId === currentUserId && access.permission === 'admin' + const isSelf = member.userId === currentUserId + const wouldDemoteSelf = isSelf && access.permission === 'admin' const disabled = rowUserIsOrgAdmin || wouldDemoteSelf || updatePermissions.isPending + /** + * Org owners/admins keep implicit admin access on org workspaces, so + * deleting their explicit permission row wouldn't actually revoke access. + * Only regular/external members can be removed from a single workspace. + */ + const canRemoveFromWorkspace = !rowUserIsOrgAdmin && !isSelf return ( } menu={buildActionsMenu( - copyToClipboard(member.email)}> - Copy email - + <> + copyToClipboard(member.email)}> + Copy email + + {canRemoveFromWorkspace && ( + + removeWorkspaceMember + .mutateAsync({ userId: member.userId, workspaceId, organizationId }) + .catch((error) => { + logger.error('Failed to remove workspace member', { error }) + toast.error("Couldn't remove member", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) + }) + } + > + Remove from workspace + + )} + )} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx index 600d7776a1f..5780f79532f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx @@ -9,6 +9,7 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, + toast, } from '@/components/emcn' import { useSession } from '@/lib/auth/auth-client' import { isEnterprise } from '@/lib/billing/plan-helpers' @@ -100,11 +101,30 @@ export function InviteModal({ { workspaceId, organizationId, invitations }, { onSuccess: (result) => { + const parts: string[] = [] + if (result.added.length > 0) { + parts.push(`${result.added.length} member${result.added.length === 1 ? '' : 's'} added`) + } + if (result.successful.length > 0) { + parts.push( + `${result.successful.length} invite${result.successful.length === 1 ? '' : 's'} sent` + ) + } + if (parts.length > 0) { + toast.success(parts.join(' · ')) + } + if (result.failed.length > 0) { + // Keep the failed addresses in the field with the error for retry. setEmails(result.failed.map((f) => f.email)) - setErrorMessage(result.failed[0].error) + setErrorMessage( + result.failed.length === 1 + ? result.failed[0].error + : `${result.failed.length} invitations failed. ${result.failed[0].error}` + ) return } + setEmails([]) onOpenChange(false) }, diff --git a/apps/sim/components/emails/invitations/index.ts b/apps/sim/components/emails/invitations/index.ts index 6fa64cdc31c..383332020ac 100644 --- a/apps/sim/components/emails/invitations/index.ts +++ b/apps/sim/components/emails/invitations/index.ts @@ -1,4 +1,5 @@ export { BatchInvitationEmail } from './batch-invitation-email' export { InvitationEmail } from './invitation-email' export { PollingGroupInvitationEmail } from './polling-group-invitation-email' +export { WorkspaceAddedEmail } from './workspace-added-email' export { WorkspaceInvitationEmail } from './workspace-invitation-email' diff --git a/apps/sim/components/emails/invitations/workspace-added-email.tsx b/apps/sim/components/emails/invitations/workspace-added-email.tsx new file mode 100644 index 00000000000..3e291f48048 --- /dev/null +++ b/apps/sim/components/emails/invitations/workspace-added-email.tsx @@ -0,0 +1,44 @@ +import { Link, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' +import { getBrandConfig } from '@/ee/whitelabeling' + +interface WorkspaceAddedEmailProps { + /** Name of the workspace the recipient was added to. */ + workspaceName?: string + /** Name of the person who added the recipient. */ + inviterName?: string + /** Direct link to the workspace (no acceptance required). */ + workspaceLink?: string +} + +export function WorkspaceAddedEmail({ + workspaceName = 'Workspace', + inviterName = 'Someone', + workspaceLink = '', +}: WorkspaceAddedEmailProps) { + const brand = getBrandConfig() + const preview = `You've been added to the "${workspaceName}" workspace on ${brand.name}` + + return ( + + Hello, + + {inviterName} added you to the {workspaceName} workspace + on {brand.name}. + + + + Open workspace + + +
+ + + If this was unexpected, contact a workspace admin. + + + ) +} + +export default WorkspaceAddedEmail diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts index bb601bcbcd9..92318b61069 100644 --- a/apps/sim/components/emails/render.ts +++ b/apps/sim/components/emails/render.ts @@ -20,6 +20,7 @@ import { BatchInvitationEmail, InvitationEmail, PollingGroupInvitationEmail, + WorkspaceAddedEmail, WorkspaceInvitationEmail, } from '@/components/emails/invitations' import { HelpConfirmationEmail } from '@/components/emails/support' @@ -215,6 +216,20 @@ export async function renderWorkspaceInvitationEmail( ) } +export async function renderWorkspaceAddedEmail( + inviterName: string, + workspaceName: string, + workspaceLink: string +): Promise { + return await render( + WorkspaceAddedEmail({ + inviterName, + workspaceName, + workspaceLink, + }) + ) +} + export async function renderPollingGroupInvitationEmail(params: { inviterName: string organizationName: string diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts index a1ddbd3ed3b..e9630289500 100644 --- a/apps/sim/components/emails/subjects.ts +++ b/apps/sim/components/emails/subjects.ts @@ -10,6 +10,7 @@ export type EmailSubjectType = | 'existing-account' | 'invitation' | 'batch-invitation' + | 'workspace-added' | 'polling-group-invitation' | 'help-confirmation' | 'enterprise-subscription' @@ -48,6 +49,8 @@ export function getEmailSubject(type: EmailSubjectType): string { return `You've been invited to join a team on ${brandName}` case 'batch-invitation': return `You've been invited to join a team and workspaces on ${brandName}` + case 'workspace-added': + return `You've been added to a workspace on ${brandName}` case 'polling-group-invitation': return `You've been invited to join an email polling group on ${brandName}` case 'help-confirmation': diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index d9b35a434d1..17e0bfe3963 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -70,11 +70,15 @@ type BatchSendInvitationsParams = ContractBodyInput +type BatchInvitationResult = Pick & { + added: string[] +} /** * Sends workspace invitations through the server-side batch endpoint. - * Returns results for each invitation indicating success or failure. + * Returns results for each invitation indicating success or failure. Existing + * organization members are added directly (no acceptance) and reported in + * `added`; everyone else receives a pending invitation in `successful`. */ export function useBatchSendWorkspaceInvitations() { const queryClient = useQueryClient() @@ -93,6 +97,7 @@ export function useBatchSendWorkspaceInvitations() { return { successful: result.successful ?? [], + added: result.added ?? [], failed: result.failed ?? [], } }, @@ -100,6 +105,12 @@ export function useBatchSendWorkspaceInvitations() { queryClient.invalidateQueries({ queryKey: invitationKeys.list(variables.workspaceId), }) + queryClient.invalidateQueries({ + queryKey: workspaceKeys.permissions(variables.workspaceId), + }) + queryClient.invalidateQueries({ + queryKey: workspaceKeys.members(variables.workspaceId), + }) if (variables.organizationId) { queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.organizationId), diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index ac02eeacccb..1b38e66e42c 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -414,7 +414,14 @@ export function useInviteMember() { return useMutation({ mutationFn: async ({ emails, workspaceInvitations, orgId }: InviteMemberParams) => { - const result = await requestJson(inviteOrganizationMembersContract, { + /** + * Partial batches return HTTP 207 with `success: false` and a `data` + * payload (some invited/added, some failed). `requestJson` only throws on + * >= 400 (e.g. the total-failure 502 / validation 400 paths), so partials + * resolve here and the caller reports successes + per-email failures from + * `data` instead of surfacing a single generic error. + */ + return requestJson(inviteOrganizationMembersContract, { params: { id: orgId }, query: { batch: true }, body: { @@ -422,12 +429,6 @@ export function useInviteMember() { workspaceInvitations, }, }) - - if (result.success === false) { - throw new Error(result.error || result.message || 'Failed to invite teammate') - } - - return result }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) @@ -435,6 +436,15 @@ export function useInviteMember() { queryClient.invalidateQueries({ queryKey: organizationKeys.memberUsage(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) + // Existing members may have been added directly to selected workspaces. + for (const grant of variables.workspaceInvitations ?? []) { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.permissions(grant.workspaceId), + }) + queryClient.invalidateQueries({ + queryKey: workspaceKeys.members(grant.workspaceId), + }) + } }, }) } diff --git a/apps/sim/lib/api/contracts/invitations.ts b/apps/sim/lib/api/contracts/invitations.ts index 5631bdc7418..b1fa8871523 100644 --- a/apps/sim/lib/api/contracts/invitations.ts +++ b/apps/sim/lib/api/contracts/invitations.ts @@ -53,6 +53,7 @@ export const batchInvitationResultSchema = z .object({ success: z.boolean(), successful: z.array(z.string()), + added: z.array(z.string()).optional(), failed: z.array(z.object({ email: z.string(), error: z.string() })), invitations: z.array(z.record(z.string(), z.unknown())), }) diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts index 28606fcc722..eaa4c3dbf75 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -310,6 +310,8 @@ export const inviteOrganizationMembersContract = defineRouteContract({ .object({ invitationsSent: z.number(), invitedEmails: z.array(z.string()), + directlyAdded: z.array(z.string()).optional(), + directlyAddedCount: z.number().optional(), failedInvitations: z.array(z.object({ email: z.string(), error: z.string() })), existingMembers: z.array(z.string()), pendingInvitations: z.array(z.string()), diff --git a/apps/sim/lib/core/telemetry.ts b/apps/sim/lib/core/telemetry.ts index a77ab240990..1b67c865edb 100644 --- a/apps/sim/lib/core/telemetry.ts +++ b/apps/sim/lib/core/telemetry.ts @@ -554,6 +554,24 @@ export const PlatformEvents = { }) }, + /** + * Track member added directly to a workspace (no acceptance step) because + * they were already a member of the workspace's organization. + */ + workspaceMemberAdded: (attrs: { + workspaceId: string + addedBy: string + addedUserId: string + role: string + }) => { + trackPlatformEvent('platform.workspace.member_added', { + 'workspace.id': attrs.workspaceId, + 'user.id': attrs.addedBy, + 'member.id': attrs.addedUserId, + 'member.role': attrs.role, + }) + }, + /** * Track member joined workspace */ diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index fb2fa173886..c8152bad844 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -33,8 +33,8 @@ import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InvitationCore') -const PERMISSION_RANK = { read: 0, write: 1, admin: 2 } as const -type PermissionLevel = keyof typeof PERMISSION_RANK +export const PERMISSION_RANK = { read: 0, write: 1, admin: 2 } as const +export type PermissionLevel = keyof typeof PERMISSION_RANK export const INVITATION_EXPIRY_DAYS = 7 diff --git a/apps/sim/lib/invitations/direct-grant.test.ts b/apps/sim/lib/invitations/direct-grant.test.ts new file mode 100644 index 00000000000..9eba697ffff --- /dev/null +++ b/apps/sim/lib/invitations/direct-grant.test.ts @@ -0,0 +1,214 @@ +/** + * @vitest-environment node + */ +import { + auditMock, + auditMockFns, + dbChainMock, + dbChainMockFns, + resetDbChainMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetUserOrganization, + mockSyncWorkspaceEnvCredentials, + mockCancelPendingInvitation, + mockSendWorkspaceAddedEmail, + mockCaptureServerEvent, + mockWorkspaceMemberAdded, +} = vi.hoisted(() => ({ + mockGetUserOrganization: vi.fn(), + mockSyncWorkspaceEnvCredentials: vi.fn(), + mockCancelPendingInvitation: vi.fn(), + mockSendWorkspaceAddedEmail: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockWorkspaceMemberAdded: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) +vi.mock('@sim/audit', () => auditMock) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + getUserOrganization: mockGetUserOrganization, +})) + +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { workspaceMemberAdded: mockWorkspaceMemberAdded }, +})) + +vi.mock('@/lib/credentials/environment', () => ({ + syncWorkspaceEnvCredentials: mockSyncWorkspaceEnvCredentials, +})) + +vi.mock('@/lib/invitations/send', () => ({ + cancelPendingInvitation: mockCancelPendingInvitation, + sendWorkspaceAddedEmail: mockSendWorkspaceAddedEmail, +})) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: mockCaptureServerEvent, +})) + +import { grantWorkspaceAccessDirectly, isSameOrgMember } from '@/lib/invitations/direct-grant' + +/** + * Drives `db.select().from().where()` results in call order. Both an awaited + * `where()` and a chained `.limit()` resolve to the same per-call value. + */ +function queueWhereResponses(responses: unknown[][]) { + const queue = [...responses] + dbChainMockFns.where.mockImplementation(() => { + const result = queue.shift() ?? [] + const thenable = Promise.resolve(result) as Promise & { + limit: ReturnType + orderBy: ReturnType + returning: ReturnType + groupBy: ReturnType + } + thenable.limit = vi.fn(() => Promise.resolve(result)) + thenable.orderBy = vi.fn(() => Promise.resolve(result)) + thenable.returning = vi.fn(() => Promise.resolve(result)) + thenable.groupBy = vi.fn(() => Promise.resolve(result)) + return thenable as ReturnType + }) +} + +const baseInput = { + userId: 'user-2', + email: 'Member@Example.com', + workspaceId: 'ws-1', + workspaceName: 'Workspace 1', + permission: 'write' as const, + organizationId: 'org-1', + actorId: 'user-1', + actorName: 'Owner', + actorEmail: 'owner@example.com', +} + +describe('grantWorkspaceAccessDirectly', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockSendWorkspaceAddedEmail.mockResolvedValue({ success: true }) + // Insert path reports the new row via `.returning()`. + dbChainMockFns.returning.mockResolvedValue([{ id: 'perm-new' }]) + }) + + it('inserts a permission row when the user has no existing access', async () => { + const result = await grantWorkspaceAccessDirectly({ ...baseInput }) + + expect(result).toEqual({ outcome: 'added', permission: 'write' }) + expect(dbChainMockFns.insert).toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'member.added', resourceId: 'ws-1' }) + ) + expect(mockWorkspaceMemberAdded).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: 'ws-1' }) + ) + expect(mockSendWorkspaceAddedEmail).toHaveBeenCalledWith( + expect.objectContaining({ email: 'member@example.com', workspaceId: 'ws-1' }) + ) + }) + + it('reports unchanged (no audit/email) when a concurrent insert wins the race', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const result = await grantWorkspaceAccessDirectly({ ...baseInput }) + + expect(result).toEqual({ outcome: 'unchanged', permission: 'write' }) + expect(dbChainMockFns.insert).toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + expect(mockWorkspaceMemberAdded).not.toHaveBeenCalled() + expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled() + }) + + it('does not upgrade an existing lower permission (invites never modify access)', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'perm-1', permissionType: 'read' }]) + + const result = await grantWorkspaceAccessDirectly({ ...baseInput, permission: 'admin' }) + + expect(result).toEqual({ outcome: 'unchanged', permission: 'read' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled() + }) + + it('no-ops when the user already has access', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'perm-1', permissionType: 'admin' }]) + + const result = await grantWorkspaceAccessDirectly({ ...baseInput, permission: 'write' }) + + expect(result).toEqual({ outcome: 'unchanged', permission: 'admin' }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + expect(mockWorkspaceMemberAdded).not.toHaveBeenCalled() + expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled() + }) + + it('skips the email when notify is false', async () => { + const result = await grantWorkspaceAccessDirectly({ ...baseInput, notify: false }) + + expect(result.outcome).toBe('added') + expect(auditMockFns.mockRecordAudit).toHaveBeenCalled() + expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled() + }) + + it('syncs workspace env credentials when env variables exist', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([]) // existing permission lookup + .mockResolvedValueOnce([{ variables: { API_KEY: 'x', BASE_URL: 'y' } }]) // env lookup + + await grantWorkspaceAccessDirectly({ ...baseInput }) + + expect(mockSyncWorkspaceEnvCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'ws-1', + actingUserId: 'user-2', + envKeys: ['API_KEY', 'BASE_URL'], + }) + ) + }) + + it('supersedes lingering pending workspace invitations for the same email', async () => { + queueWhereResponses([ + [], // existing permission lookup (transaction) + [{ invitationId: 'old-inv' }], // supersede lookup + [], // env lookup + ]) + + await grantWorkspaceAccessDirectly({ ...baseInput }) + + expect(mockCancelPendingInvitation).toHaveBeenCalledWith('old-inv') + }) +}) + +describe('isSameOrgMember', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns false when the workspace has no organization', async () => { + expect(await isSameOrgMember('user-2', null)).toBe(false) + expect(mockGetUserOrganization).not.toHaveBeenCalled() + }) + + it('returns false when the user belongs to no organization', async () => { + mockGetUserOrganization.mockResolvedValueOnce(null) + expect(await isSameOrgMember('user-2', 'org-1')).toBe(false) + }) + + it('returns true when the user belongs to the workspace organization', async () => { + mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-1', role: 'member' }) + expect(await isSameOrgMember('user-2', 'org-1')).toBe(true) + }) + + it('returns false when the user belongs to a different organization', async () => { + mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-2', role: 'member' }) + expect(await isSameOrgMember('user-2', 'org-1')).toBe(false) + }) +}) diff --git a/apps/sim/lib/invitations/direct-grant.ts b/apps/sim/lib/invitations/direct-grant.ts new file mode 100644 index 00000000000..93118f7034a --- /dev/null +++ b/apps/sim/lib/invitations/direct-grant.ts @@ -0,0 +1,235 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { + invitation, + invitationWorkspaceGrant, + permissions, + workspaceEnvironment, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { normalizeEmail } from '@sim/utils/string' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { getUserOrganization } from '@/lib/billing/organizations/membership' +import { PlatformEvents } from '@/lib/core/telemetry' +import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' +import { cancelPendingInvitation, sendWorkspaceAddedEmail } from '@/lib/invitations/send' +import { captureServerEvent } from '@/lib/posthog/server' +import type { PermissionType } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('InvitationDirectGrant') + +export type DirectGrantOutcome = + | { outcome: 'added'; permission: PermissionType } + | { outcome: 'unchanged'; permission: PermissionType } + +export interface GrantWorkspaceAccessDirectlyInput { + /** Registered user receiving access. */ + userId: string + /** Invitee email (used for notification + audit; normalized internally). */ + email: string + workspaceId: string + workspaceName: string + permission: PermissionType + /** Organization that owns the workspace. */ + organizationId: string + actorId: string + actorName: string + actorEmail?: string | null + request?: NextRequest + /** Send the lightweight "you've been added" email. Defaults to true. */ + notify?: boolean +} + +/** + * Returns whether the given user is already a member of the workspace's + * organization. Only same-org members are eligible for direct (no-acceptance) + * workspace access. + */ +export async function isSameOrgMember( + userId: string, + workspaceOrganizationId: string | null +): Promise { + if (!workspaceOrganizationId) return false + const membership = await getUserOrganization(userId) + return !!membership && membership.organizationId === workspaceOrganizationId +} + +/** + * Cancels any pending single-workspace invitations that grant exactly this + * workspace to this email. Multi-workspace organization invitations are left + * untouched — their remaining grants stay valid and the accept flow upserts + * permissions idempotently. + */ +async function supersedePendingWorkspaceInvites( + workspaceId: string, + normalizedEmail: string +): Promise { + const rows = await db + .select({ invitationId: invitation.id }) + .from(invitation) + .innerJoin(invitationWorkspaceGrant, eq(invitationWorkspaceGrant.invitationId, invitation.id)) + .where( + and( + eq(invitation.kind, 'workspace'), + eq(invitation.email, normalizedEmail), + eq(invitation.status, 'pending'), + eq(invitationWorkspaceGrant.workspaceId, workspaceId) + ) + ) + + for (const row of rows) { + await cancelPendingInvitation(row.invitationId) + } +} + +/** + * Grants a user workspace access immediately, without an invitation or + * acceptance step. Intended for users who already belong to the workspace's + * organization and are not yet members of the workspace. Idempotent: when a + * permission already exists it is left untouched (no-op) — invites never modify + * or upgrade an existing member's permission. + */ +export async function grantWorkspaceAccessDirectly( + input: GrantWorkspaceAccessDirectlyInput +): Promise { + const normalizedEmail = normalizeEmail(input.email) + + const result = await db.transaction(async (tx): Promise => { + const [existing] = await tx + .select({ id: permissions.id, permissionType: permissions.permissionType }) + .from(permissions) + .where( + and( + eq(permissions.entityId, input.workspaceId), + eq(permissions.entityType, 'workspace'), + eq(permissions.userId, input.userId) + ) + ) + .limit(1) + + if (existing) { + return { outcome: 'unchanged', permission: existing.permissionType as PermissionType } + } + + const inserted = await tx + .insert(permissions) + .values({ + id: generateId(), + entityType: 'workspace', + entityId: input.workspaceId, + userId: input.userId, + permissionType: input.permission, + createdAt: new Date(), + updatedAt: new Date(), + }) + .onConflictDoNothing() + .returning({ id: permissions.id }) + + if (inserted.length === 0) { + return { outcome: 'unchanged', permission: input.permission } + } + + return { outcome: 'added', permission: input.permission } + }) + + if (result.outcome === 'unchanged') { + return result + } + + try { + await supersedePendingWorkspaceInvites(input.workspaceId, normalizedEmail) + } catch (error) { + logger.error('Failed to supersede pending workspace invitations after direct grant', { + workspaceId: input.workspaceId, + error, + }) + } + + try { + const [wsEnvRow] = await db + .select({ variables: workspaceEnvironment.variables }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, input.workspaceId)) + .limit(1) + const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record) || {}) + if (wsEnvKeys.length > 0) { + await syncWorkspaceEnvCredentials({ + workspaceId: input.workspaceId, + envKeys: wsEnvKeys, + actingUserId: input.userId, + }) + } + } catch (error) { + logger.error('Failed to sync workspace env credentials after direct grant', { + workspaceId: input.workspaceId, + userId: input.userId, + error, + }) + } + + try { + PlatformEvents.workspaceMemberAdded({ + workspaceId: input.workspaceId, + addedBy: input.actorId, + addedUserId: input.userId, + role: input.permission, + }) + } catch { + /** + * Telemetry must not fail the grant. + */ + } + + captureServerEvent( + input.actorId, + 'workspace_member_added', + { + workspace_id: input.workspaceId, + member_role: input.permission, + }, + { + groups: { workspace: input.workspaceId }, + } + ) + + recordAudit({ + workspaceId: input.workspaceId, + actorId: input.actorId, + actorName: input.actorName, + actorEmail: input.actorEmail, + action: AuditAction.MEMBER_ADDED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: input.workspaceId, + resourceName: normalizedEmail, + description: `Added existing organization member ${normalizedEmail} as ${input.permission}`, + metadata: { + targetEmail: normalizedEmail, + targetRole: input.permission, + organizationId: input.organizationId, + workspaceName: input.workspaceName, + addedUserId: input.userId, + }, + request: input.request, + }) + + if (input.notify ?? true) { + try { + await sendWorkspaceAddedEmail({ + email: normalizedEmail, + inviterName: input.actorName, + workspaceId: input.workspaceId, + workspaceName: input.workspaceName, + }) + } catch (error) { + logger.error('Failed to send workspace added email', { + workspaceId: input.workspaceId, + email: normalizedEmail, + error, + }) + } + } + + return result +} diff --git a/apps/sim/lib/invitations/send.ts b/apps/sim/lib/invitations/send.ts index 23b7f8566c6..6dc32b540a6 100644 --- a/apps/sim/lib/invitations/send.ts +++ b/apps/sim/lib/invitations/send.ts @@ -15,12 +15,14 @@ import { getEmailSubject, renderBatchInvitationEmail, renderInvitationEmail, + renderWorkspaceAddedEmail, renderWorkspaceInvitationEmail, } from '@/components/emails' import { getBaseUrl } from '@/lib/core/utils/urls' import { computeInvitationExpiry } from '@/lib/invitations/core' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' +import { getBrandConfig } from '@/ee/whitelabeling' const logger = createLogger('InvitationSend') @@ -205,10 +207,11 @@ export async function sendInvitationEmail( inviteUrl ) + const brandName = getBrandConfig().name 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` + ? `You've been invited to join "${workspaceNames[0]}" on ${brandName}` + : `You've been invited to join ${workspaceNames.length} workspaces on ${brandName}` const result = await sendEmail({ to: input.email, @@ -281,6 +284,41 @@ export async function sendInvitationEmail( return { success: true } } +export interface SendWorkspaceAddedEmailInput { + email: string + inviterName: string + workspaceId: string + workspaceName: string +} + +/** + * Lightweight notification sent when an existing organization member is added + * directly to a workspace. Unlike an invitation email, this links straight to + * the workspace and has no acceptance step. + */ +export async function sendWorkspaceAddedEmail( + input: SendWorkspaceAddedEmailInput +): Promise { + const workspaceLink = `${getBaseUrl()}/workspace/${input.workspaceId}/home` + const emailHtml = await renderWorkspaceAddedEmail( + input.inviterName, + input.workspaceName, + workspaceLink + ) + + const result = await sendEmail({ + to: input.email, + subject: getEmailSubject('workspace-added'), + html: emailHtml, + from: getFromEmailAddress(), + emailType: 'transactional', + }) + if (!result.success) { + return { success: false, error: result.message } + } + return { success: true } +} + export async function prepareInvitationResend(params: { invitationId: string rotateToken?: boolean diff --git a/apps/sim/lib/invitations/workspace-invitations.test.ts b/apps/sim/lib/invitations/workspace-invitations.test.ts new file mode 100644 index 00000000000..a63fc27447a --- /dev/null +++ b/apps/sim/lib/invitations/workspace-invitations.test.ts @@ -0,0 +1,231 @@ +/** + * @vitest-environment node + */ +import { + auditMock, + createMockRequest, + dbChainMock, + dbChainMockFns, + resetDbChainMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetUserOrganization, + mockValidateSeatAvailability, + mockGrantWorkspaceAccessDirectly, + mockCreatePendingInvitation, + mockSendInvitationEmail, + mockCancelPendingInvitation, + mockFindPendingGrantForWorkspaceEmail, + mockWorkspaceMemberInvited, + mockCaptureServerEvent, +} = vi.hoisted(() => ({ + mockGetUserOrganization: vi.fn(), + mockValidateSeatAvailability: vi.fn(), + mockGrantWorkspaceAccessDirectly: vi.fn(), + mockCreatePendingInvitation: vi.fn(), + mockSendInvitationEmail: vi.fn(), + mockCancelPendingInvitation: vi.fn(), + mockFindPendingGrantForWorkspaceEmail: vi.fn(), + mockWorkspaceMemberInvited: vi.fn(), + mockCaptureServerEvent: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) +vi.mock('@sim/audit', () => auditMock) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + getUserOrganization: mockGetUserOrganization, +})) + +vi.mock('@/lib/billing/validation/seat-management', () => ({ + validateSeatAvailability: mockValidateSeatAvailability, +})) + +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { workspaceMemberInvited: mockWorkspaceMemberInvited }, +})) + +vi.mock('@/lib/invitations/direct-grant', () => ({ + grantWorkspaceAccessDirectly: mockGrantWorkspaceAccessDirectly, +})) + +vi.mock('@/lib/invitations/send', () => ({ + createPendingInvitation: mockCreatePendingInvitation, + sendInvitationEmail: mockSendInvitationEmail, + cancelPendingInvitation: mockCancelPendingInvitation, + findPendingGrantForWorkspaceEmail: mockFindPendingGrantForWorkspaceEmail, +})) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: mockCaptureServerEvent, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: vi.fn(), +})) + +vi.mock('@/lib/workspaces/policy', () => ({ + getWorkspaceInvitePolicy: vi.fn(), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + validateInvitationsAllowed: vi.fn(), +})) + +import { createWorkspaceInvitation } from '@/lib/invitations/workspace-invitations' + +function queueWhereResponses(responses: unknown[][]) { + const queue = [...responses] + dbChainMockFns.where.mockImplementation(() => { + const result = queue.shift() ?? [] + const thenable = Promise.resolve(result) as Promise & { + limit: ReturnType + } + thenable.limit = vi.fn(() => Promise.resolve(result)) + return thenable as ReturnType + }) +} + +function makeContext() { + return { + workspaceId: 'ws-1', + inviterId: 'user-1', + inviterName: 'Owner', + inviterEmail: 'owner@example.com', + workspaceDetails: { + id: 'ws-1', + name: 'Workspace 1', + ownerId: 'user-1', + organizationId: 'org-1', + billedAccountUserId: 'user-1', + }, + invitePolicy: { + allowed: true, + reason: null, + requiresSeat: false, + organizationId: 'org-1', + upgradeRequired: false, + }, + // The function only reads the fields above at runtime. + } as Parameters[0]['context'] +} + +const request = createMockRequest( + 'POST', + {}, + {}, + 'http://localhost/api/workspaces/invitations/batch' +) + +describe('createWorkspaceInvitation', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGrantWorkspaceAccessDirectly.mockResolvedValue({ outcome: 'added', permission: 'write' }) + mockCreatePendingInvitation.mockResolvedValue({ invitationId: 'inv-1', token: 'tok-1' }) + mockSendInvitationEmail.mockResolvedValue({ success: true }) + mockFindPendingGrantForWorkspaceEmail.mockResolvedValue(null) + }) + + it('directly grants access to an existing member of the workspace organization', async () => { + queueWhereResponses([[{ id: 'user-2', email: 'member@example.com' }], []]) + mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-1', role: 'member' }) + + const result = await createWorkspaceInvitation({ + context: makeContext(), + email: 'member@example.com', + permission: 'write', + request, + }) + + expect(result.instantAdd).toBe(true) + expect(result.outcome).toBe('added') + expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-2', + workspaceId: 'ws-1', + permission: 'write', + organizationId: 'org-1', + }) + ) + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + }) + + it('rejects an existing workspace member without upgrading their permission', async () => { + queueWhereResponses([ + [{ id: 'user-2', email: 'member@example.com' }], + [{ id: 'perm-1', permissionType: 'read' }], + ]) + mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-1', role: 'member' }) + + await expect( + createWorkspaceInvitation({ + context: makeContext(), + email: 'member@example.com', + permission: 'admin', + request, + }) + ).rejects.toThrow('already has access') + + expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled() + expect(mockCreatePendingInvitation).not.toHaveBeenCalled() + }) + + it('creates an external pending invitation when the user belongs to a different org', async () => { + queueWhereResponses([[{ id: 'user-3', email: 'ext@example.com' }], []]) + mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-2', role: 'member' }) + + const result = await createWorkspaceInvitation({ + context: makeContext(), + email: 'ext@example.com', + permission: 'read', + request, + }) + + expect(result.instantAdd).toBeFalsy() + expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled() + expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'workspace', membershipIntent: 'external' }) + ) + expect(mockSendInvitationEmail).toHaveBeenCalled() + }) + + it('creates an internal pending invitation when the registered user has no org', async () => { + queueWhereResponses([[{ id: 'user-4', email: 'noorg@example.com' }], []]) + mockGetUserOrganization.mockResolvedValueOnce(null) + + const result = await createWorkspaceInvitation({ + context: makeContext(), + email: 'noorg@example.com', + permission: 'write', + request, + }) + + expect(result.instantAdd).toBeFalsy() + expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled() + expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'workspace', membershipIntent: 'internal' }) + ) + }) + + it('creates a pending invitation for a brand-new email', async () => { + queueWhereResponses([[]]) + + const result = await createWorkspaceInvitation({ + context: makeContext(), + email: 'new@example.com', + permission: 'read', + request, + }) + + expect(result.instantAdd).toBeFalsy() + expect(mockGetUserOrganization).not.toHaveBeenCalled() + expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled() + expect(mockCreatePendingInvitation).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'workspace', membershipIntent: 'internal' }) + ) + }) +}) diff --git a/apps/sim/lib/invitations/workspace-invitations.ts b/apps/sim/lib/invitations/workspace-invitations.ts index c29cbc12731..63de5039649 100644 --- a/apps/sim/lib/invitations/workspace-invitations.ts +++ b/apps/sim/lib/invitations/workspace-invitations.ts @@ -7,6 +7,10 @@ import type { NextRequest } from 'next/server' import { getUserOrganization } from '@/lib/billing/organizations/membership' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' import { PlatformEvents } from '@/lib/core/telemetry' +import { + type DirectGrantOutcome, + grantWorkspaceAccessDirectly, +} from '@/lib/invitations/direct-grant' import { cancelPendingInvitation, createPendingInvitation, @@ -38,6 +42,10 @@ export interface WorkspaceInvitationResult { permission: PermissionType membershipIntent: InvitationMembershipIntent expiresAt: Date | undefined + /** True when the user was granted access directly (no pending invitation). */ + instantAdd?: boolean + /** Direct-grant outcome when `instantAdd` is true. */ + outcome?: DirectGrantOutcome['outcome'] } export class WorkspaceInvitationError extends Error { @@ -152,6 +160,11 @@ export async function createWorkspaceInvitation({ .then((rows) => rows[0]) if (existingUser) { + const workspaceOrganizationId = context.workspaceDetails.organizationId + const existingMembership = workspaceOrganizationId + ? await getUserOrganization(existingUser.id) + : null + const existingPermission = await db .select() .from(permissions) @@ -164,6 +177,11 @@ export async function createWorkspaceInvitation({ ) .then((rows) => rows[0]) + /** + * Already a workspace member: reject. Invites never change an existing + * member's permission — role changes go through the members list, not the + * invite flow. (The client also blocks re-inviting current teammates.) + */ if (existingPermission) { throw new WorkspaceInvitationError({ message: `${normalizedEmail} already has access to this workspace`, @@ -172,18 +190,46 @@ export async function createWorkspaceInvitation({ }) } - if (context.invitePolicy.organizationId) { - const existingMembership = await getUserOrganization(existingUser.id) - if ( - existingMembership && - existingMembership.organizationId !== context.invitePolicy.organizationId - ) { + /** + * Invitee already belongs to the workspace's organization (and is not yet a + * member of this workspace): grant access directly, with no invitation or + * acceptance step. + */ + if ( + workspaceOrganizationId && + existingMembership && + existingMembership.organizationId === workspaceOrganizationId + ) { + const directGrant = await grantWorkspaceAccessDirectly({ + userId: existingUser.id, + email: normalizedEmail, + workspaceId: context.workspaceId, + workspaceName: context.workspaceDetails.name, + permission: invitationPermission, + organizationId: workspaceOrganizationId, + actorId: context.inviterId, + actorName: context.inviterName, + actorEmail: context.inviterEmail, + request, + }) + + return { + id: existingUser.id, + workspaceId: context.workspaceId, + email: normalizedEmail, + permission: invitationPermission, + membershipIntent: 'internal', + expiresAt: undefined, + instantAdd: true, + outcome: directGrant.outcome, + } + } + + if (workspaceOrganizationId) { + if (existingMembership && existingMembership.organizationId !== workspaceOrganizationId) { membershipIntent = 'external' } else if (context.invitePolicy.requiresSeat && !existingMembership) { - const seatValidation = await validateSeatAvailability( - context.invitePolicy.organizationId, - 1 - ) + const seatValidation = await validateSeatAvailability(workspaceOrganizationId, 1) if (!seatValidation.canInvite) { throw new WorkspaceInvitationError({ message: seatValidation.reason || 'No available seats for this organization.', diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 02dae0d6b02..ed17ba7e2a8 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -91,6 +91,11 @@ export interface PostHogEventMap { membership_intent?: string } + workspace_member_added: { + workspace_id: string + member_role: string + } + workspace_member_removed: { workspace_id: string is_self_removal: boolean diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index 2757f2988f0..7eccc1f757f 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -99,6 +99,7 @@ export const AuditAction = { // Members MEMBER_INVITED: 'member.invited', + MEMBER_ADDED: 'member.added', MEMBER_REMOVED: 'member.removed', MEMBER_ROLE_CHANGED: 'member.role_changed', diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 30c2a71bb2d..83e6d90d710 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -99,6 +99,7 @@ export const auditMock = { MCP_SERVER_UPDATED: 'mcp_server.updated', MCP_SERVER_REMOVED: 'mcp_server.removed', MEMBER_INVITED: 'member.invited', + MEMBER_ADDED: 'member.added', MEMBER_REMOVED: 'member.removed', MEMBER_ROLE_CHANGED: 'member.role_changed', OAUTH_DISCONNECTED: 'oauth.disconnected',