From cf65e126c6951c87d264c1792a05168550e35ac4 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 18 Jun 2026 15:00:19 -0700 Subject: [PATCH 1/4] feat(workspaces): auto-add without invite if part of organization --- .../[id]/invitations/route.test.ts | 57 ++-- .../organizations/[id]/invitations/route.ts | 199 +++++++------- .../api/workspaces/invitations/batch/route.ts | 11 +- .../organization-invite-modal.tsx | 19 +- .../organization-member-lists.tsx | 42 ++- .../components/invite-modal/invite-modal.tsx | 13 + .../components/emails/invitations/index.ts | 1 + .../invitations/workspace-added-email.tsx | 44 ++++ apps/sim/components/emails/render.ts | 15 ++ apps/sim/components/emails/subjects.ts | 3 + apps/sim/hooks/queries/invitations.ts | 17 +- apps/sim/hooks/queries/organization.ts | 9 + apps/sim/lib/api/contracts/invitations.ts | 2 + apps/sim/lib/api/contracts/organization.ts | 2 + apps/sim/lib/core/config/env-flags.ts | 2 +- apps/sim/lib/core/telemetry.ts | 20 ++ apps/sim/lib/invitations/core.ts | 4 +- apps/sim/lib/invitations/direct-grant.test.ts | 203 ++++++++++++++ apps/sim/lib/invitations/direct-grant.ts | 248 ++++++++++++++++++ apps/sim/lib/invitations/send.ts | 36 +++ .../invitations/workspace-invitations.test.ts | 211 +++++++++++++++ .../lib/invitations/workspace-invitations.ts | 50 +++- apps/sim/lib/posthog/events.ts | 6 + packages/audit/src/types.ts | 1 + packages/testing/src/mocks/audit.mock.ts | 1 + 25 files changed, 1082 insertions(+), 134 deletions(-) create mode 100644 apps/sim/components/emails/invitations/workspace-added-email.tsx create mode 100644 apps/sim/lib/invitations/direct-grant.test.ts create mode 100644 apps/sim/lib/invitations/direct-grant.ts create mode 100644 apps/sim/lib/invitations/workspace-invitations.test.ts 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..492b4f356f7 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,27 +231,23 @@ 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([]) }) @@ -281,14 +284,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 +313,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 +321,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..f8b44026edb 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,94 @@ 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 - } - - 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, + /** + * 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 grantedAny = false + for (const grant of memberInvite.grants) { + try { + await grantWorkspaceAccessDirectly({ + userId: memberUserId, + email: memberInvite.email, + workspaceId: grant.workspaceId, + workspaceName: workspaceNameById.get(grant.workspaceId) ?? 'a workspace', + permission: grant.permission, organizationId, - isBatch, - }, - request, - }) + actorId: session.user.id, + actorName: inviterName, + actorEmail: session.user.email, + request, + }) + grantedAny = true + } catch (grantError) { + logger.error('Failed to grant workspace access directly', { + email: memberInvite.email, + workspaceId: grant.workspaceId, + error: grantError, + }) + failedInvitations.push({ + email: memberInvite.email, + error: getErrorMessage(grantError, 'Failed to add member to workspace'), + }) + } } + if (grantedAny) directlyAdded.push(memberInvite.email) + } + + 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 sentOrgInvitations = sentInvitations.filter((inv) => inv.kind === 'organization') 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 +573,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 +602,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 +612,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..7f85f9ba02a 100644 --- a/apps/sim/app/api/workspaces/invitations/batch/route.ts +++ b/apps/sim/app/api/workspaces/invitations/batch/route.ts @@ -61,6 +61,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) const successful: string[] = [] + const added: string[] = [] + const upgraded: string[] = [] const failed: BatchInvitationFailure[] = [] const invitations: WorkspaceInvitationResult[] = [] const seenEmails = new Set() @@ -83,7 +85,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { permission: item.permission, request: req, }) - successful.push(invitation.email) + if (invitation.instantAdd) { + added.push(invitation.email) + if (invitation.outcome === 'upgraded') upgraded.push(invitation.email) + } else { + successful.push(invitation.email) + } invitations.push(invitation) } catch (error) { if (error instanceof WorkspaceInvitationError) { @@ -102,6 +109,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ success: failed.length === 0, successful, + added, + upgraded, 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..b75d898ec24 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,23 @@ 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 }) + : null + const addedCount = summary?.directlyAddedCount ?? 0 + const sentCount = summary?.invitationsSent ?? 0 + 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(' · ')) + } 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..c391a251d3a 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' @@ -105,6 +106,18 @@ export function InviteModal({ setErrorMessage(result.failed[0].error) return } + 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(' · ')) + } 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..ebe7bf7c3bd 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -70,11 +70,16 @@ type BatchSendInvitationsParams = ContractBodyInput +type BatchInvitationResult = Pick & { + added: string[] + upgraded: 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 +98,8 @@ export function useBatchSendWorkspaceInvitations() { return { successful: result.successful ?? [], + added: result.added ?? [], + upgraded: result.upgraded ?? [], failed: result.failed ?? [], } }, @@ -100,6 +107,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..3db5c789c34 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -435,6 +435,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..1eef9ef7eee 100644 --- a/apps/sim/lib/api/contracts/invitations.ts +++ b/apps/sim/lib/api/contracts/invitations.ts @@ -53,6 +53,8 @@ export const batchInvitationResultSchema = z .object({ success: z.boolean(), successful: z.array(z.string()), + added: z.array(z.string()).optional(), + upgraded: 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/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index e980a452429..81ac39aea01 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -29,7 +29,7 @@ try { } catch { // invalid URL — isHosted stays false } -export const isHosted = appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai') +export const isHosted = true /** * Is billing enforcement enabled diff --git a/apps/sim/lib/core/telemetry.ts b/apps/sim/lib/core/telemetry.ts index a77ab240990..c381f16e61b 100644 --- a/apps/sim/lib/core/telemetry.ts +++ b/apps/sim/lib/core/telemetry.ts @@ -554,6 +554,26 @@ 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 + outcome: 'added' | 'upgraded' + }) => { + trackPlatformEvent('platform.workspace.member_added', { + 'workspace.id': attrs.workspaceId, + 'user.id': attrs.addedBy, + 'member.id': attrs.addedUserId, + 'member.role': attrs.role, + 'member.add_outcome': attrs.outcome, + }) + }, + /** * 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..9add528859a --- /dev/null +++ b/apps/sim/lib/invitations/direct-grant.test.ts @@ -0,0 +1,203 @@ +/** + * @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/core', () => ({ + PERMISSION_RANK: { read: 0, write: 1, admin: 2 }, +})) + +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 }) + }) + + 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', outcome: 'added' }) + ) + expect(mockSendWorkspaceAddedEmail).toHaveBeenCalledWith( + expect.objectContaining({ email: 'member@example.com', workspaceId: 'ws-1' }) + ) + }) + + it('upgrades an existing lower permission', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'perm-1', permissionType: 'read' }]) + + const result = await grantWorkspaceAccessDirectly({ ...baseInput, permission: 'admin' }) + + expect(result).toEqual({ outcome: 'upgraded', from: 'read', to: 'admin' }) + expect(dbChainMockFns.update).toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(mockSendWorkspaceAddedEmail).toHaveBeenCalled() + }) + + it('no-ops when the user already has equal or higher 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..602f1f223ca --- /dev/null +++ b/apps/sim/lib/invitations/direct-grant.ts @@ -0,0 +1,248 @@ +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 { PERMISSION_RANK, type PermissionLevel } from '@/lib/invitations/core' +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: 'upgraded'; from: PermissionType; to: 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. Idempotent: no-ops when the user already has equal or higher + * access, upgrades when the new permission is higher. + */ +export async function grantWorkspaceAccessDirectly( + input: GrantWorkspaceAccessDirectlyInput +): Promise { + const normalizedEmail = normalizeEmail(input.email) + const newPermission = input.permission as PermissionLevel + const newRank = PERMISSION_RANK[newPermission] ?? 0 + + 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) { + await tx + .insert(permissions) + .values({ + id: generateId(), + entityType: 'workspace', + entityId: input.workspaceId, + userId: input.userId, + permissionType: newPermission, + createdAt: new Date(), + updatedAt: new Date(), + }) + .onConflictDoNothing() + return { outcome: 'added', permission: input.permission } + } + + const existingPermission = existing.permissionType as PermissionType + const existingRank = PERMISSION_RANK[existingPermission as PermissionLevel] ?? 0 + if (newRank > existingRank) { + await tx + .update(permissions) + .set({ permissionType: newPermission, updatedAt: new Date() }) + .where(eq(permissions.id, existing.id)) + return { outcome: 'upgraded', from: existingPermission, to: input.permission } + } + + return { outcome: 'unchanged', permission: existingPermission } + }) + + 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, + outcome: result.outcome, + }) + } catch { + /** + * Telemetry must not fail the grant. + */ + } + + captureServerEvent( + input.actorId, + 'workspace_member_added', + { + workspace_id: input.workspaceId, + member_role: input.permission, + outcome: result.outcome, + }, + { + 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: + result.outcome === 'upgraded' + ? `Added existing organization member ${normalizedEmail} (upgraded to ${input.permission})` + : `Added existing organization member ${normalizedEmail} as ${input.permission}`, + metadata: { + targetEmail: normalizedEmail, + targetRole: input.permission, + organizationId: input.organizationId, + workspaceName: input.workspaceName, + addedUserId: input.userId, + outcome: result.outcome, + }, + 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..9b662776af4 100644 --- a/apps/sim/lib/invitations/send.ts +++ b/apps/sim/lib/invitations/send.ts @@ -15,6 +15,7 @@ import { getEmailSubject, renderBatchInvitationEmail, renderInvitationEmail, + renderWorkspaceAddedEmail, renderWorkspaceInvitationEmail, } from '@/components/emails' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -281,6 +282,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: `You've been added to "${input.workspaceName}" on Sim`, + 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..f6d1fbae6ac --- /dev/null +++ b/apps/sim/lib/invitations/workspace-invitations.test.ts @@ -0,0 +1,211 @@ +/** + * @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('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..7bc06468a87 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,47 @@ export async function createWorkspaceInvitation({ .then((rows) => rows[0]) if (existingUser) { + const workspaceOrganizationId = context.workspaceDetails.organizationId + const existingMembership = workspaceOrganizationId + ? await getUserOrganization(existingUser.id) + : null + + /** + * Invitee already belongs to the workspace's organization: grant access + * directly with no invitation/acceptance step. Idempotent — upgrades a + * lower permission and no-ops when access already meets or exceeds the + * requested permission. + */ + 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, + } + } + const existingPermission = await db .select() .from(permissions) @@ -173,7 +222,6 @@ export async function createWorkspaceInvitation({ } if (context.invitePolicy.organizationId) { - const existingMembership = await getUserOrganization(existingUser.id) if ( existingMembership && existingMembership.organizationId !== context.invitePolicy.organizationId diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 02dae0d6b02..672d256feb6 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -91,6 +91,12 @@ export interface PostHogEventMap { membership_intent?: string } + workspace_member_added: { + workspace_id: string + member_role: string + outcome: 'added' | 'upgraded' + } + 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', From fd1c053cc82ce8949d7980765c80648734542d25 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 18 Jun 2026 15:04:04 -0700 Subject: [PATCH 2/4] reverse feature flag hardcoding --- apps/sim/lib/core/config/env-flags.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 81ac39aea01..e980a452429 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -29,7 +29,7 @@ try { } catch { // invalid URL — isHosted stays false } -export const isHosted = true +export const isHosted = appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai') /** * Is billing enforcement enabled From 54de8525c7bf061099c42d2f553c44cafa439060 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 18 Jun 2026 15:15:17 -0700 Subject: [PATCH 3/4] address comments --- .../organizations/[id]/invitations/route.ts | 5 +- .../api/workspaces/invitations/batch/route.ts | 7 +- apps/sim/hooks/queries/invitations.ts | 2 - apps/sim/lib/api/contracts/invitations.ts | 1 - apps/sim/lib/core/telemetry.ts | 2 - apps/sim/lib/invitations/direct-grant.test.ts | 31 ++++++--- apps/sim/lib/invitations/direct-grant.ts | 59 +++++++---------- apps/sim/lib/invitations/send.ts | 8 ++- .../invitations/workspace-invitations.test.ts | 22 ++++++- .../lib/invitations/workspace-invitations.ts | 64 +++++++++---------- apps/sim/lib/posthog/events.ts | 1 - 11 files changed, 107 insertions(+), 95 deletions(-) diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index f8b44026edb..0d49c62a152 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -502,7 +502,7 @@ export const POST = withRouteHandler( let grantedAny = false for (const grant of memberInvite.grants) { try { - await grantWorkspaceAccessDirectly({ + const grantResult = await grantWorkspaceAccessDirectly({ userId: memberUserId, email: memberInvite.email, workspaceId: grant.workspaceId, @@ -514,7 +514,8 @@ export const POST = withRouteHandler( actorEmail: session.user.email, request, }) - grantedAny = true + + if (grantResult.outcome === 'added') grantedAny = true } catch (grantError) { logger.error('Failed to grant workspace access directly', { email: memberInvite.email, diff --git a/apps/sim/app/api/workspaces/invitations/batch/route.ts b/apps/sim/app/api/workspaces/invitations/batch/route.ts index 7f85f9ba02a..391a1cb8952 100644 --- a/apps/sim/app/api/workspaces/invitations/batch/route.ts +++ b/apps/sim/app/api/workspaces/invitations/batch/route.ts @@ -62,7 +62,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const successful: string[] = [] const added: string[] = [] - const upgraded: string[] = [] const failed: BatchInvitationFailure[] = [] const invitations: WorkspaceInvitationResult[] = [] const seenEmails = new Set() @@ -86,8 +85,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { request: req, }) if (invitation.instantAdd) { - added.push(invitation.email) - if (invitation.outcome === 'upgraded') upgraded.push(invitation.email) + // 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) } @@ -110,7 +110,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { success: failed.length === 0, successful, added, - upgraded, failed, invitations, }) diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index ebe7bf7c3bd..17e0bfe3963 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -72,7 +72,6 @@ type BatchSendInvitationsParams = ContractBodyInput & { added: string[] - upgraded: string[] } /** @@ -99,7 +98,6 @@ export function useBatchSendWorkspaceInvitations() { return { successful: result.successful ?? [], added: result.added ?? [], - upgraded: result.upgraded ?? [], failed: result.failed ?? [], } }, diff --git a/apps/sim/lib/api/contracts/invitations.ts b/apps/sim/lib/api/contracts/invitations.ts index 1eef9ef7eee..b1fa8871523 100644 --- a/apps/sim/lib/api/contracts/invitations.ts +++ b/apps/sim/lib/api/contracts/invitations.ts @@ -54,7 +54,6 @@ export const batchInvitationResultSchema = z success: z.boolean(), successful: z.array(z.string()), added: z.array(z.string()).optional(), - upgraded: 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/core/telemetry.ts b/apps/sim/lib/core/telemetry.ts index c381f16e61b..1b67c865edb 100644 --- a/apps/sim/lib/core/telemetry.ts +++ b/apps/sim/lib/core/telemetry.ts @@ -563,14 +563,12 @@ export const PlatformEvents = { addedBy: string addedUserId: string role: string - outcome: 'added' | 'upgraded' }) => { trackPlatformEvent('platform.workspace.member_added', { 'workspace.id': attrs.workspaceId, 'user.id': attrs.addedBy, 'member.id': attrs.addedUserId, 'member.role': attrs.role, - 'member.add_outcome': attrs.outcome, }) }, diff --git a/apps/sim/lib/invitations/direct-grant.test.ts b/apps/sim/lib/invitations/direct-grant.test.ts index 9add528859a..9eba697ffff 100644 --- a/apps/sim/lib/invitations/direct-grant.test.ts +++ b/apps/sim/lib/invitations/direct-grant.test.ts @@ -41,10 +41,6 @@ vi.mock('@/lib/credentials/environment', () => ({ syncWorkspaceEnvCredentials: mockSyncWorkspaceEnvCredentials, })) -vi.mock('@/lib/invitations/core', () => ({ - PERMISSION_RANK: { read: 0, write: 1, admin: 2 }, -})) - vi.mock('@/lib/invitations/send', () => ({ cancelPendingInvitation: mockCancelPendingInvitation, sendWorkspaceAddedEmail: mockSendWorkspaceAddedEmail, @@ -95,6 +91,8 @@ describe('grantWorkspaceAccessDirectly', () => { 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 () => { @@ -107,25 +105,38 @@ describe('grantWorkspaceAccessDirectly', () => { expect.objectContaining({ action: 'member.added', resourceId: 'ws-1' }) ) expect(mockWorkspaceMemberAdded).toHaveBeenCalledWith( - expect.objectContaining({ workspaceId: 'ws-1', outcome: 'added' }) + expect.objectContaining({ workspaceId: 'ws-1' }) ) expect(mockSendWorkspaceAddedEmail).toHaveBeenCalledWith( expect.objectContaining({ email: 'member@example.com', workspaceId: 'ws-1' }) ) }) - it('upgrades an existing lower permission', async () => { + 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: 'upgraded', from: 'read', to: 'admin' }) - expect(dbChainMockFns.update).toHaveBeenCalled() + expect(result).toEqual({ outcome: 'unchanged', permission: 'read' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(dbChainMockFns.insert).not.toHaveBeenCalled() - expect(mockSendWorkspaceAddedEmail).toHaveBeenCalled() + expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled() + expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled() }) - it('no-ops when the user already has equal or higher access', async () => { + 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' }) diff --git a/apps/sim/lib/invitations/direct-grant.ts b/apps/sim/lib/invitations/direct-grant.ts index 602f1f223ca..93118f7034a 100644 --- a/apps/sim/lib/invitations/direct-grant.ts +++ b/apps/sim/lib/invitations/direct-grant.ts @@ -14,7 +14,6 @@ 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 { PERMISSION_RANK, type PermissionLevel } from '@/lib/invitations/core' import { cancelPendingInvitation, sendWorkspaceAddedEmail } from '@/lib/invitations/send' import { captureServerEvent } from '@/lib/posthog/server' import type { PermissionType } from '@/lib/workspaces/permissions/utils' @@ -23,7 +22,6 @@ const logger = createLogger('InvitationDirectGrant') export type DirectGrantOutcome = | { outcome: 'added'; permission: PermissionType } - | { outcome: 'upgraded'; from: PermissionType; to: PermissionType } | { outcome: 'unchanged'; permission: PermissionType } export interface GrantWorkspaceAccessDirectlyInput { @@ -89,15 +87,14 @@ async function supersedePendingWorkspaceInvites( /** * Grants a user workspace access immediately, without an invitation or * acceptance step. Intended for users who already belong to the workspace's - * organization. Idempotent: no-ops when the user already has equal or higher - * access, upgrades when the new permission is higher. + * 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 newPermission = input.permission as PermissionLevel - const newRank = PERMISSION_RANK[newPermission] ?? 0 const result = await db.transaction(async (tx): Promise => { const [existing] = await tx @@ -112,33 +109,29 @@ export async function grantWorkspaceAccessDirectly( ) .limit(1) - if (!existing) { - await tx - .insert(permissions) - .values({ - id: generateId(), - entityType: 'workspace', - entityId: input.workspaceId, - userId: input.userId, - permissionType: newPermission, - createdAt: new Date(), - updatedAt: new Date(), - }) - .onConflictDoNothing() - return { outcome: 'added', permission: input.permission } + if (existing) { + return { outcome: 'unchanged', permission: existing.permissionType as PermissionType } } - const existingPermission = existing.permissionType as PermissionType - const existingRank = PERMISSION_RANK[existingPermission as PermissionLevel] ?? 0 - if (newRank > existingRank) { - await tx - .update(permissions) - .set({ permissionType: newPermission, updatedAt: new Date() }) - .where(eq(permissions.id, existing.id)) - return { outcome: 'upgraded', from: existingPermission, to: input.permission } + 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: 'unchanged', permission: existingPermission } + return { outcome: 'added', permission: input.permission } }) if (result.outcome === 'unchanged') { @@ -182,7 +175,6 @@ export async function grantWorkspaceAccessDirectly( addedBy: input.actorId, addedUserId: input.userId, role: input.permission, - outcome: result.outcome, }) } catch { /** @@ -196,7 +188,6 @@ export async function grantWorkspaceAccessDirectly( { workspace_id: input.workspaceId, member_role: input.permission, - outcome: result.outcome, }, { groups: { workspace: input.workspaceId }, @@ -212,17 +203,13 @@ export async function grantWorkspaceAccessDirectly( resourceType: AuditResourceType.WORKSPACE, resourceId: input.workspaceId, resourceName: normalizedEmail, - description: - result.outcome === 'upgraded' - ? `Added existing organization member ${normalizedEmail} (upgraded to ${input.permission})` - : `Added existing organization member ${normalizedEmail} as ${input.permission}`, + description: `Added existing organization member ${normalizedEmail} as ${input.permission}`, metadata: { targetEmail: normalizedEmail, targetRole: input.permission, organizationId: input.organizationId, workspaceName: input.workspaceName, addedUserId: input.userId, - outcome: result.outcome, }, request: input.request, }) diff --git a/apps/sim/lib/invitations/send.ts b/apps/sim/lib/invitations/send.ts index 9b662776af4..6dc32b540a6 100644 --- a/apps/sim/lib/invitations/send.ts +++ b/apps/sim/lib/invitations/send.ts @@ -22,6 +22,7 @@ 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') @@ -206,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, @@ -306,7 +308,7 @@ export async function sendWorkspaceAddedEmail( const result = await sendEmail({ to: input.email, - subject: `You've been added to "${input.workspaceName}" on Sim`, + subject: getEmailSubject('workspace-added'), html: emailHtml, from: getFromEmailAddress(), emailType: 'transactional', diff --git a/apps/sim/lib/invitations/workspace-invitations.test.ts b/apps/sim/lib/invitations/workspace-invitations.test.ts index f6d1fbae6ac..a63fc27447a 100644 --- a/apps/sim/lib/invitations/workspace-invitations.test.ts +++ b/apps/sim/lib/invitations/workspace-invitations.test.ts @@ -130,7 +130,7 @@ describe('createWorkspaceInvitation', () => { }) it('directly grants access to an existing member of the workspace organization', async () => { - queueWhereResponses([[{ id: 'user-2', email: 'member@example.com' }]]) + queueWhereResponses([[{ id: 'user-2', email: 'member@example.com' }], []]) mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-1', role: 'member' }) const result = await createWorkspaceInvitation({ @@ -154,6 +154,26 @@ describe('createWorkspaceInvitation', () => { 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' }) diff --git a/apps/sim/lib/invitations/workspace-invitations.ts b/apps/sim/lib/invitations/workspace-invitations.ts index 7bc06468a87..63de5039649 100644 --- a/apps/sim/lib/invitations/workspace-invitations.ts +++ b/apps/sim/lib/invitations/workspace-invitations.ts @@ -165,11 +165,35 @@ export async function createWorkspaceInvitation({ ? await getUserOrganization(existingUser.id) : null + const existingPermission = await db + .select() + .from(permissions) + .where( + and( + eq(permissions.entityId, context.workspaceId), + eq(permissions.entityType, 'workspace'), + eq(permissions.userId, existingUser.id) + ) + ) + .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`, + status: 400, + email: normalizedEmail, + }) + } + /** - * Invitee already belongs to the workspace's organization: grant access - * directly with no invitation/acceptance step. Idempotent — upgrades a - * lower permission and no-ops when access already meets or exceeds the - * requested permission. + * 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 && @@ -201,37 +225,11 @@ export async function createWorkspaceInvitation({ } } - const existingPermission = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityId, context.workspaceId), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, existingUser.id) - ) - ) - .then((rows) => rows[0]) - - if (existingPermission) { - throw new WorkspaceInvitationError({ - message: `${normalizedEmail} already has access to this workspace`, - status: 400, - email: normalizedEmail, - }) - } - - if (context.invitePolicy.organizationId) { - if ( - existingMembership && - existingMembership.organizationId !== context.invitePolicy.organizationId - ) { + 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 672d256feb6..ed17ba7e2a8 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -94,7 +94,6 @@ export interface PostHogEventMap { workspace_member_added: { workspace_id: string member_role: string - outcome: 'added' | 'upgraded' } workspace_member_removed: { From 588701b0056066f09e061a3dba2856fc60c9163a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 18 Jun 2026 15:35:27 -0700 Subject: [PATCH 4/4] improve ux for org invite modal --- .../[id]/invitations/route.test.ts | 85 +++++++++++++++++++ .../organizations/[id]/invitations/route.ts | 17 ++-- .../organization-invite-modal.tsx | 21 ++++- .../components/invite-modal/invite-modal.tsx | 17 ++-- apps/sim/hooks/queries/organization.ts | 15 ++-- 5 files changed, 135 insertions(+), 20 deletions(-) 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 492b4f356f7..43f4ff6ce8b 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts @@ -251,6 +251,91 @@ describe('POST /api/organizations/[id]/invitations', () => { 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' }) diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index 0d49c62a152..afbd00e78ca 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -499,7 +499,8 @@ export const POST = withRouteHandler( const memberUserId = memberUserIdByEmail.get(memberInvite.email) if (!memberUserId) continue - let grantedAny = false + let addedAny = false + let lastGrantError: string | null = null for (const grant of memberInvite.grants) { try { const grantResult = await grantWorkspaceAccessDirectly({ @@ -515,20 +516,22 @@ export const POST = withRouteHandler( request, }) - if (grantResult.outcome === 'added') grantedAny = true + if (grantResult.outcome === 'added') addedAny = true } catch (grantError) { logger.error('Failed to grant workspace access directly', { email: memberInvite.email, workspaceId: grant.workspaceId, error: grantError, }) - failedInvitations.push({ - email: memberInvite.email, - error: getErrorMessage(grantError, 'Failed to add member to workspace'), - }) + lastGrantError = getErrorMessage(grantError, 'Failed to add member to workspace') } } - if (grantedAny) directlyAdded.push(memberInvite.email) + + if (addedAny) { + directlyAdded.push(memberInvite.email) + } else if (lastGrantError) { + failedInvitations.push({ email: memberInvite.email, error: lastGrantError }) + } } for (const inv of sentInvitations) { 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 b75d898ec24..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 @@ -116,10 +116,17 @@ export function OrganizationInviteModal({ onSuccess: (result) => { const summary = 'data' in result && result.data && typeof result.data === 'object' - ? (result.data as { invitationsSent?: number; directlyAddedCount?: number }) + ? (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`) @@ -130,6 +137,18 @@ export function OrganizationInviteModal({ 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]/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 c391a251d3a..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 @@ -101,11 +101,6 @@ export function InviteModal({ { workspaceId, organizationId, invitations }, { onSuccess: (result) => { - if (result.failed.length > 0) { - setEmails(result.failed.map((f) => f.email)) - setErrorMessage(result.failed[0].error) - return - } const parts: string[] = [] if (result.added.length > 0) { parts.push(`${result.added.length} member${result.added.length === 1 ? '' : 's'} added`) @@ -118,6 +113,18 @@ export function InviteModal({ 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.length === 1 + ? result.failed[0].error + : `${result.failed.length} invitations failed. ${result.failed[0].error}` + ) + return + } + setEmails([]) onOpenChange(false) }, diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index 3db5c789c34..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) })