Skip to content

Commit 54de852

Browse files
committed
address comments
1 parent fd1c053 commit 54de852

11 files changed

Lines changed: 107 additions & 95 deletions

File tree

apps/sim/app/api/organizations/[id]/invitations/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,7 @@ export const POST = withRouteHandler(
502502
let grantedAny = false
503503
for (const grant of memberInvite.grants) {
504504
try {
505-
await grantWorkspaceAccessDirectly({
505+
const grantResult = await grantWorkspaceAccessDirectly({
506506
userId: memberUserId,
507507
email: memberInvite.email,
508508
workspaceId: grant.workspaceId,
@@ -514,7 +514,8 @@ export const POST = withRouteHandler(
514514
actorEmail: session.user.email,
515515
request,
516516
})
517-
grantedAny = true
517+
518+
if (grantResult.outcome === 'added') grantedAny = true
518519
} catch (grantError) {
519520
logger.error('Failed to grant workspace access directly', {
520521
email: memberInvite.email,

apps/sim/app/api/workspaces/invitations/batch/route.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
6262

6363
const successful: string[] = []
6464
const added: string[] = []
65-
const upgraded: string[] = []
6665
const failed: BatchInvitationFailure[] = []
6766
const invitations: WorkspaceInvitationResult[] = []
6867
const seenEmails = new Set<string>()
@@ -86,8 +85,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
8685
request: req,
8786
})
8887
if (invitation.instantAdd) {
89-
added.push(invitation.email)
90-
if (invitation.outcome === 'upgraded') upgraded.push(invitation.email)
88+
// Only report an actual insertion; an `unchanged` outcome means the
89+
// user already had access (rare race) and is a silent no-op.
90+
if (invitation.outcome === 'added') added.push(invitation.email)
9191
} else {
9292
successful.push(invitation.email)
9393
}
@@ -110,7 +110,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
110110
success: failed.length === 0,
111111
successful,
112112
added,
113-
upgraded,
114113
failed,
115114
invitations,
116115
})

apps/sim/hooks/queries/invitations.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ type BatchSendInvitationsParams = ContractBodyInput<typeof batchWorkspaceInvitat
7272

7373
type BatchInvitationResult = Pick<BatchInvitationResultContract, 'successful' | 'failed'> & {
7474
added: string[]
75-
upgraded: string[]
7675
}
7776

7877
/**
@@ -99,7 +98,6 @@ export function useBatchSendWorkspaceInvitations() {
9998
return {
10099
successful: result.successful ?? [],
101100
added: result.added ?? [],
102-
upgraded: result.upgraded ?? [],
103101
failed: result.failed ?? [],
104102
}
105103
},

apps/sim/lib/api/contracts/invitations.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ export const batchInvitationResultSchema = z
5454
success: z.boolean(),
5555
successful: z.array(z.string()),
5656
added: z.array(z.string()).optional(),
57-
upgraded: z.array(z.string()).optional(),
5857
failed: z.array(z.object({ email: z.string(), error: z.string() })),
5958
invitations: z.array(z.record(z.string(), z.unknown())),
6059
})

apps/sim/lib/core/telemetry.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -563,14 +563,12 @@ export const PlatformEvents = {
563563
addedBy: string
564564
addedUserId: string
565565
role: string
566-
outcome: 'added' | 'upgraded'
567566
}) => {
568567
trackPlatformEvent('platform.workspace.member_added', {
569568
'workspace.id': attrs.workspaceId,
570569
'user.id': attrs.addedBy,
571570
'member.id': attrs.addedUserId,
572571
'member.role': attrs.role,
573-
'member.add_outcome': attrs.outcome,
574572
})
575573
},
576574

apps/sim/lib/invitations/direct-grant.test.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,6 @@ vi.mock('@/lib/credentials/environment', () => ({
4141
syncWorkspaceEnvCredentials: mockSyncWorkspaceEnvCredentials,
4242
}))
4343

44-
vi.mock('@/lib/invitations/core', () => ({
45-
PERMISSION_RANK: { read: 0, write: 1, admin: 2 },
46-
}))
47-
4844
vi.mock('@/lib/invitations/send', () => ({
4945
cancelPendingInvitation: mockCancelPendingInvitation,
5046
sendWorkspaceAddedEmail: mockSendWorkspaceAddedEmail,
@@ -95,6 +91,8 @@ describe('grantWorkspaceAccessDirectly', () => {
9591
vi.clearAllMocks()
9692
resetDbChainMock()
9793
mockSendWorkspaceAddedEmail.mockResolvedValue({ success: true })
94+
// Insert path reports the new row via `.returning()`.
95+
dbChainMockFns.returning.mockResolvedValue([{ id: 'perm-new' }])
9896
})
9997

10098
it('inserts a permission row when the user has no existing access', async () => {
@@ -107,25 +105,38 @@ describe('grantWorkspaceAccessDirectly', () => {
107105
expect.objectContaining({ action: 'member.added', resourceId: 'ws-1' })
108106
)
109107
expect(mockWorkspaceMemberAdded).toHaveBeenCalledWith(
110-
expect.objectContaining({ workspaceId: 'ws-1', outcome: 'added' })
108+
expect.objectContaining({ workspaceId: 'ws-1' })
111109
)
112110
expect(mockSendWorkspaceAddedEmail).toHaveBeenCalledWith(
113111
expect.objectContaining({ email: 'member@example.com', workspaceId: 'ws-1' })
114112
)
115113
})
116114

117-
it('upgrades an existing lower permission', async () => {
115+
it('reports unchanged (no audit/email) when a concurrent insert wins the race', async () => {
116+
dbChainMockFns.returning.mockResolvedValueOnce([])
117+
118+
const result = await grantWorkspaceAccessDirectly({ ...baseInput })
119+
120+
expect(result).toEqual({ outcome: 'unchanged', permission: 'write' })
121+
expect(dbChainMockFns.insert).toHaveBeenCalled()
122+
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
123+
expect(mockWorkspaceMemberAdded).not.toHaveBeenCalled()
124+
expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled()
125+
})
126+
127+
it('does not upgrade an existing lower permission (invites never modify access)', async () => {
118128
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'perm-1', permissionType: 'read' }])
119129

120130
const result = await grantWorkspaceAccessDirectly({ ...baseInput, permission: 'admin' })
121131

122-
expect(result).toEqual({ outcome: 'upgraded', from: 'read', to: 'admin' })
123-
expect(dbChainMockFns.update).toHaveBeenCalled()
132+
expect(result).toEqual({ outcome: 'unchanged', permission: 'read' })
133+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
124134
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
125-
expect(mockSendWorkspaceAddedEmail).toHaveBeenCalled()
135+
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
136+
expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled()
126137
})
127138

128-
it('no-ops when the user already has equal or higher access', async () => {
139+
it('no-ops when the user already has access', async () => {
129140
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'perm-1', permissionType: 'admin' }])
130141

131142
const result = await grantWorkspaceAccessDirectly({ ...baseInput, permission: 'write' })

apps/sim/lib/invitations/direct-grant.ts

Lines changed: 23 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import type { NextRequest } from 'next/server'
1414
import { getUserOrganization } from '@/lib/billing/organizations/membership'
1515
import { PlatformEvents } from '@/lib/core/telemetry'
1616
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
17-
import { PERMISSION_RANK, type PermissionLevel } from '@/lib/invitations/core'
1817
import { cancelPendingInvitation, sendWorkspaceAddedEmail } from '@/lib/invitations/send'
1918
import { captureServerEvent } from '@/lib/posthog/server'
2019
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
@@ -23,7 +22,6 @@ const logger = createLogger('InvitationDirectGrant')
2322

2423
export type DirectGrantOutcome =
2524
| { outcome: 'added'; permission: PermissionType }
26-
| { outcome: 'upgraded'; from: PermissionType; to: PermissionType }
2725
| { outcome: 'unchanged'; permission: PermissionType }
2826

2927
export interface GrantWorkspaceAccessDirectlyInput {
@@ -89,15 +87,14 @@ async function supersedePendingWorkspaceInvites(
8987
/**
9088
* Grants a user workspace access immediately, without an invitation or
9189
* acceptance step. Intended for users who already belong to the workspace's
92-
* organization. Idempotent: no-ops when the user already has equal or higher
93-
* access, upgrades when the new permission is higher.
90+
* organization and are not yet members of the workspace. Idempotent: when a
91+
* permission already exists it is left untouched (no-op) — invites never modify
92+
* or upgrade an existing member's permission.
9493
*/
9594
export async function grantWorkspaceAccessDirectly(
9695
input: GrantWorkspaceAccessDirectlyInput
9796
): Promise<DirectGrantOutcome> {
9897
const normalizedEmail = normalizeEmail(input.email)
99-
const newPermission = input.permission as PermissionLevel
100-
const newRank = PERMISSION_RANK[newPermission] ?? 0
10198

10299
const result = await db.transaction(async (tx): Promise<DirectGrantOutcome> => {
103100
const [existing] = await tx
@@ -112,33 +109,29 @@ export async function grantWorkspaceAccessDirectly(
112109
)
113110
.limit(1)
114111

115-
if (!existing) {
116-
await tx
117-
.insert(permissions)
118-
.values({
119-
id: generateId(),
120-
entityType: 'workspace',
121-
entityId: input.workspaceId,
122-
userId: input.userId,
123-
permissionType: newPermission,
124-
createdAt: new Date(),
125-
updatedAt: new Date(),
126-
})
127-
.onConflictDoNothing()
128-
return { outcome: 'added', permission: input.permission }
112+
if (existing) {
113+
return { outcome: 'unchanged', permission: existing.permissionType as PermissionType }
129114
}
130115

131-
const existingPermission = existing.permissionType as PermissionType
132-
const existingRank = PERMISSION_RANK[existingPermission as PermissionLevel] ?? 0
133-
if (newRank > existingRank) {
134-
await tx
135-
.update(permissions)
136-
.set({ permissionType: newPermission, updatedAt: new Date() })
137-
.where(eq(permissions.id, existing.id))
138-
return { outcome: 'upgraded', from: existingPermission, to: input.permission }
116+
const inserted = await tx
117+
.insert(permissions)
118+
.values({
119+
id: generateId(),
120+
entityType: 'workspace',
121+
entityId: input.workspaceId,
122+
userId: input.userId,
123+
permissionType: input.permission,
124+
createdAt: new Date(),
125+
updatedAt: new Date(),
126+
})
127+
.onConflictDoNothing()
128+
.returning({ id: permissions.id })
129+
130+
if (inserted.length === 0) {
131+
return { outcome: 'unchanged', permission: input.permission }
139132
}
140133

141-
return { outcome: 'unchanged', permission: existingPermission }
134+
return { outcome: 'added', permission: input.permission }
142135
})
143136

144137
if (result.outcome === 'unchanged') {
@@ -182,7 +175,6 @@ export async function grantWorkspaceAccessDirectly(
182175
addedBy: input.actorId,
183176
addedUserId: input.userId,
184177
role: input.permission,
185-
outcome: result.outcome,
186178
})
187179
} catch {
188180
/**
@@ -196,7 +188,6 @@ export async function grantWorkspaceAccessDirectly(
196188
{
197189
workspace_id: input.workspaceId,
198190
member_role: input.permission,
199-
outcome: result.outcome,
200191
},
201192
{
202193
groups: { workspace: input.workspaceId },
@@ -212,17 +203,13 @@ export async function grantWorkspaceAccessDirectly(
212203
resourceType: AuditResourceType.WORKSPACE,
213204
resourceId: input.workspaceId,
214205
resourceName: normalizedEmail,
215-
description:
216-
result.outcome === 'upgraded'
217-
? `Added existing organization member ${normalizedEmail} (upgraded to ${input.permission})`
218-
: `Added existing organization member ${normalizedEmail} as ${input.permission}`,
206+
description: `Added existing organization member ${normalizedEmail} as ${input.permission}`,
219207
metadata: {
220208
targetEmail: normalizedEmail,
221209
targetRole: input.permission,
222210
organizationId: input.organizationId,
223211
workspaceName: input.workspaceName,
224212
addedUserId: input.userId,
225-
outcome: result.outcome,
226213
},
227214
request: input.request,
228215
})

apps/sim/lib/invitations/send.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
2222
import { computeInvitationExpiry } from '@/lib/invitations/core'
2323
import { sendEmail } from '@/lib/messaging/email/mailer'
2424
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
25+
import { getBrandConfig } from '@/ee/whitelabeling'
2526

2627
const logger = createLogger('InvitationSend')
2728

@@ -206,10 +207,11 @@ export async function sendInvitationEmail(
206207
inviteUrl
207208
)
208209

210+
const brandName = getBrandConfig().name
209211
const subject =
210212
workspaceNames.length === 1
211-
? `You've been invited to join "${workspaceNames[0]}" on Sim`
212-
: `You've been invited to join ${workspaceNames.length} workspaces on Sim`
213+
? `You've been invited to join "${workspaceNames[0]}" on ${brandName}`
214+
: `You've been invited to join ${workspaceNames.length} workspaces on ${brandName}`
213215

214216
const result = await sendEmail({
215217
to: input.email,
@@ -306,7 +308,7 @@ export async function sendWorkspaceAddedEmail(
306308

307309
const result = await sendEmail({
308310
to: input.email,
309-
subject: `You've been added to "${input.workspaceName}" on Sim`,
311+
subject: getEmailSubject('workspace-added'),
310312
html: emailHtml,
311313
from: getFromEmailAddress(),
312314
emailType: 'transactional',

apps/sim/lib/invitations/workspace-invitations.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ describe('createWorkspaceInvitation', () => {
130130
})
131131

132132
it('directly grants access to an existing member of the workspace organization', async () => {
133-
queueWhereResponses([[{ id: 'user-2', email: 'member@example.com' }]])
133+
queueWhereResponses([[{ id: 'user-2', email: 'member@example.com' }], []])
134134
mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-1', role: 'member' })
135135

136136
const result = await createWorkspaceInvitation({
@@ -154,6 +154,26 @@ describe('createWorkspaceInvitation', () => {
154154
expect(mockSendInvitationEmail).not.toHaveBeenCalled()
155155
})
156156

157+
it('rejects an existing workspace member without upgrading their permission', async () => {
158+
queueWhereResponses([
159+
[{ id: 'user-2', email: 'member@example.com' }],
160+
[{ id: 'perm-1', permissionType: 'read' }],
161+
])
162+
mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-1', role: 'member' })
163+
164+
await expect(
165+
createWorkspaceInvitation({
166+
context: makeContext(),
167+
email: 'member@example.com',
168+
permission: 'admin',
169+
request,
170+
})
171+
).rejects.toThrow('already has access')
172+
173+
expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled()
174+
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
175+
})
176+
157177
it('creates an external pending invitation when the user belongs to a different org', async () => {
158178
queueWhereResponses([[{ id: 'user-3', email: 'ext@example.com' }], []])
159179
mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-2', role: 'member' })

0 commit comments

Comments
 (0)