Skip to content

Commit ea71c3c

Browse files
icecrasher321claude
andcommitted
fix(invitations): restore the invite admin gate, hedge an unknown outcome
Consolidating the modals dropped a permission check. The old workspace-header modal derived `canInviteMembers` from `userPermissions.canAdmin` internally and disabled its field and button; the shared modal takes `canInvite` as a prop that defaults to true, and no call site passed it. Non-admins therefore saw a fully enabled invite form and only learned otherwise when the server refused the send. All three entry points now supply it: workspace admin for the header, the existing `canManage` for the workspace Teammates page, and organization owner/admin for organization settings. When the join preview cannot be computed the outcome is unknown, and the callers send no disclosure token — so acceptance runs without the consent guards. The membership notice nevertheless asserted a seat-taking join from the sent intent, which acceptance may resolve to external, already-a-member, or a failure. It is conditional now ("If you're added to X as a member, that uses one of their seats"), so the consequence is still disclosed without being claimed as settled. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1d19581 commit ea71c3c

5 files changed

Lines changed: 40 additions & 6 deletions

File tree

apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ export function TeamManagement({
349349
open={inviteModalOpen}
350350
onOpenChange={setInviteModalOpen}
351351
organizationId={displayOrganization.id}
352+
canInvite={adminOrOwner}
352353
/>
353354
)}
354355

apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ export function Teammates() {
320320
workspaceName={activeWorkspace?.name ?? 'Workspace'}
321321
inviteDisabledReason={inviteDisabledReason}
322322
organizationId={activeWorkspace?.organizationId ?? null}
323+
canInvite={canManage}
323324
/>
324325
)}
325326
</>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { MoreHorizontal, Search } from 'lucide-react'
2525
import { useActiveOrganization } from '@/lib/auth/auth-client'
2626
import { isBillingEnabled } from '@/lib/core/config/env-flags'
2727
import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal'
28+
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
2829
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
2930
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
3031
import {
@@ -244,6 +245,13 @@ function WorkspaceHeaderImpl({
244245
const createWorkspaceDisabledReason =
245246
workspaceCreationPolicy?.canCreate === false ? workspaceCreationPolicy.reason : null
246247
const { isInvitationsDisabled: isInvitationsDisabledByConfig } = usePermissionConfig()
248+
/**
249+
* Only workspace admins can invite. The modal takes this as a prop, so each
250+
* entry point supplies it — the pre-consolidation modal derived it internally,
251+
* and omitting it here left the form fully enabled for non-admins until the
252+
* server refused the send.
253+
*/
254+
const { userPermissions } = useWorkspacePermissionsContext()
247255
const inviteDisabledReason = activeWorkspaceFull?.inviteDisabledReason ?? null
248256
const isInvitationsDisabled = isInvitationsDisabledByConfig || inviteDisabledReason !== null
249257
const createWorkspaceTarget: CreateWorkspaceTarget =
@@ -871,6 +879,7 @@ function WorkspaceHeaderImpl({
871879
workspaceName={activeWorkspace?.name || 'Workspace'}
872880
inviteDisabledReason={inviteDisabledReason}
873881
organizationId={activeWorkspaceFull?.organizationId ?? null}
882+
canInvite={userPermissions.canAdmin}
874883
/>
875884
<ViewInvitationsModal open={isViewInvitationsOpen} onOpenChange={setIsViewInvitationsOpen} />
876885
<DeleteModal

apps/sim/lib/invitations/disclosure-copy.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ describe('buildMembershipNotice', () => {
7878
expect(notice).not.toContain('uses one of their seats')
7979
})
8080

81-
it('falls back to the sent intent when no preview could be computed', () => {
81+
/**
82+
* With no preview the outcome is unknown and acceptance runs without the consent
83+
* guards, so the copy must not assert a settled outcome.
84+
*/
85+
it('hedges when no preview could be computed', () => {
8286
expect(
8387
buildMembershipNotice({
8488
joinPreview: null,
@@ -87,7 +91,17 @@ describe('buildMembershipNotice', () => {
8791
organizationLabel: 'Acme',
8892
isOrganizationScoped: true,
8993
})
90-
).toContain('uses one of their seats')
94+
).toContain("If you're added")
95+
96+
expect(
97+
buildMembershipNotice({
98+
joinPreview: null,
99+
membershipIntent: 'internal',
100+
isOrganizationAdminRole: false,
101+
organizationLabel: 'Acme',
102+
isOrganizationScoped: true,
103+
})
104+
).not.toContain("You'll join Acme as")
91105

92106
expect(
93107
buildMembershipNotice({

apps/sim/lib/invitations/disclosure-copy.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,20 @@ export function buildMembershipNotice({
7575
if (!isOrganizationScoped || !membershipIntent) return ''
7676

7777
/**
78-
* No preview: fall back to the invitation's sent intent, which is the only
79-
* signal available.
78+
* No preview means the outcome is genuinely unknown, and the callers also send
79+
* no disclosure token, so acceptance runs without the consent guards. Asserting
80+
* a seat-taking join here would be a promise nothing verifies — acceptance may
81+
* resolve to external, already-a-member, or a failure. The copy is conditional
82+
* instead: the consequence is still disclosed, without claiming it as settled.
8083
*/
81-
const outcome =
82-
joinPreview?.outcome ?? (membershipIntent === 'external' ? 'external' : 'will-join')
84+
if (!joinPreview) {
85+
if (membershipIntent === 'external') {
86+
return ` You'll join as an external collaborator: you get access to the invited workspaces only, and you don't take one of their seats.`
87+
}
88+
return ` If you're added to ${organizationLabel} as a member, that uses one of their seats.`
89+
}
90+
91+
const outcome = joinPreview.outcome
8392

8493
switch (outcome) {
8594
/**

0 commit comments

Comments
 (0)