Skip to content

Commit 1ea6c7f

Browse files
authored
improvement(chat): let Chat manage sim links, bulk-add permission group members, and preview member removal (#9600)
1 parent fa10efb commit 1ea6c7f

12 files changed

Lines changed: 275 additions & 65 deletions

File tree

Lines changed: 21 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,25 @@
1-
import { createLogger } from '@sim/logger'
2-
import { type NextRequest, NextResponse } from 'next/server'
31
import { getMemberRemovalImpactContract } from '@/lib/api/contracts/organization'
4-
import { parseRequest } from '@/lib/api/server'
5-
import { getSession } from '@/lib/auth'
6-
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
7-
import { getOrganizationTransferCredentialDependencies } from '@/lib/billing/organizations/membership'
8-
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9-
10-
const logger = createLogger('OrganizationRemovalImpactAPI')
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalOrganizationErrorPolicy } from '@/lib/api/server/routes/organizations'
8+
import { previewOrganizationMemberRemoval } from '@/lib/organizations/application/members'
119

1210
/**
13-
* Identity-bound credentials the target user owns in this organization's
14-
* workspaces — the set that stops working when their workspace access is
15-
* revoked. Readable by org admins (removing someone) and by the user
16-
* themself (leaving).
11+
* Identity-bound credentials the target user owns in this organization's workspaces. Readable by
12+
* org admins (removing someone) and by the user themself (leaving).
1713
*/
18-
export const GET = withRouteHandler(
19-
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
20-
const session = await getSession()
21-
if (!session?.user?.id) {
22-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
23-
}
24-
25-
const parsed = await parseRequest(getMemberRemovalImpactContract, request, context)
26-
if (!parsed.success) return parsed.response
27-
28-
const { id: organizationId } = parsed.data.params
29-
const { userId: targetUserId } = parsed.data.query
30-
31-
try {
32-
const isSelf = targetUserId === session.user.id
33-
if (!isSelf && !(await isOrganizationOwnerOrAdmin(session.user.id, organizationId))) {
34-
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
35-
}
36-
37-
const credentials = await getOrganizationTransferCredentialDependencies(
38-
targetUserId,
39-
organizationId
40-
)
41-
42-
return NextResponse.json({ credentials })
43-
} catch (error) {
44-
logger.error('Failed to compute member removal impact', {
45-
organizationId,
46-
targetUserId,
47-
error,
48-
})
49-
return NextResponse.json({ error: 'Failed to compute removal impact' }, { status: 500 })
50-
}
51-
}
52-
)
14+
export const GET = defineInternalJsonRoute({
15+
contract: getMemberRemovalImpactContract,
16+
auth: internalSessionAuth,
17+
operation: previewOrganizationMemberRemoval.operation,
18+
rateLimit: internalRateLimits.none({
19+
reason: 'Preserve existing organization member administration behavior.',
20+
}),
21+
errorPolicy: internalOrganizationErrorPolicy,
22+
mapInput: ({ params, query }) => ({ organizationId: params.id, userId: query.userId }),
23+
useCase: previewOrganizationMemberRemoval,
24+
present: ({ credentials }) => ({ credentials }),
25+
})

‎apps/sim/lib/api/contracts/sim-links.ts‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,21 +35,23 @@ export type SimLinkBody = z.input<typeof simLinkFieldsSchema>
3535

3636
const linkResponse = z.object({ link: simLinkSchema })
3737

38+
export const listSimLinksQuerySchema = z
39+
.object({
40+
search: z.string().max(200).optional(),
41+
cursor: z.string().min(1).max(128).optional(),
42+
sort: z.enum(SIM_LINK_SORTS).optional(),
43+
dir: z.enum(SORT_DIRECTIONS).optional(),
44+
})
45+
.refine((query) => !(query.sort === 'popular' && query.cursor), {
46+
path: ['cursor'],
47+
message: 'The most-used view is a single page',
48+
})
49+
3850
export const listSimLinksContract = defineRouteContract({
3951
method: 'GET',
4052
path: '/api/organizations/[id]/sim-links',
4153
params,
42-
query: z
43-
.object({
44-
search: z.string().max(200).optional(),
45-
cursor: z.string().min(1).max(128).optional(),
46-
sort: z.enum(SIM_LINK_SORTS).optional(),
47-
dir: z.enum(SORT_DIRECTIONS).optional(),
48-
})
49-
.refine((query) => !(query.sort === 'popular' && query.cursor), {
50-
path: ['cursor'],
51-
message: 'The most-used view is a single page',
52-
}),
54+
query: listSimLinksQuerySchema,
5355
response: {
5456
mode: 'json',
5557
schema: z.object({ links: z.array(simLinkSchema), nextCursor: z.string().nullable() }),

‎apps/sim/lib/mothership/tools/server/settings-operations.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
createPermissionGroupBodySchema,
1515
updatePermissionGroupBodySchema,
1616
} from '@/lib/api/contracts/permission-groups'
17+
import { v2BulkAddPermissionGroupMembersBodySchema } from '@/lib/api/contracts/v2/permission-groups'
1718
import { updateWorkspacePermissionsBodySchema } from '@/lib/api/contracts/workspaces'
1819
import { getOrganizationUsageBreakdown } from '@/lib/billing/application/organization-usage/get-organization-usage-breakdown'
1920
import { getOrganizationUsageSummary } from '@/lib/billing/application/organization-usage/get-organization-usage-summary'
@@ -77,12 +78,15 @@ import {
7778
settingsWorkspaceId,
7879
} from '@/lib/mothership/tools/server/settings-operation'
7980
import { organizationDomainSettingsActions } from '@/lib/mothership/tools/server/settings-security'
81+
import { simLinkSettingsActions } from '@/lib/mothership/tools/server/settings-sim-links'
8082
import {
83+
previewOrganizationMemberRemoval,
8184
removeOrganizationMember,
8285
updateOrganizationMember,
8386
} from '@/lib/organizations/application/members'
8487
import {
8588
addPermissionGroupMember,
89+
bulkAddPermissionGroupMembers,
8690
createPermissionGroup,
8791
deletePermissionGroup,
8892
getPermissionGroup,
@@ -370,6 +374,22 @@ export const settingsOperations: Record<string, Record<string, SettingsOperation
370374
).member,
371375
})
372376
),
377+
bulk_add_members: settingsOperation(
378+
'write',
379+
z.strictObject({
380+
groupId: z.string().min(1),
381+
userIds: v2BulkAddPermissionGroupMembersBodySchema.shape.userIds,
382+
addAllOrganizationMembers:
383+
v2BulkAddPermissionGroupMembersBodySchema.shape.addAllOrganizationMembers,
384+
}),
385+
async (context, input) => {
386+
const { added, skipped } = await bulkAddPermissionGroupMembers.execute({
387+
principal: context.principal,
388+
input: { ...input, organizationId: settingsOrganizationId(context) },
389+
})
390+
return { added, skipped }
391+
}
392+
),
373393
remove_member: settingsOperation(
374394
'write',
375395
z.strictObject({ groupId: z.string().min(1), memberId: z.string().min(1) }),
@@ -412,6 +432,15 @@ export const settingsOperations: Record<string, Record<string, SettingsOperation
412432
input: { ...input, organizationId: settingsOrganizationId(context) },
413433
})
414434
),
435+
removal_impact: settingsOperation(
436+
'read',
437+
z.strictObject({ userId: z.string().min(1).max(200) }),
438+
(context, input) =>
439+
previewOrganizationMemberRemoval.execute({
440+
principal: context.principal,
441+
input: { ...input, organizationId: settingsOrganizationId(context) },
442+
})
443+
),
415444
remove: settingsOperation(
416445
'write',
417446
z.strictObject({ userId: z.string().min(1) }),
@@ -443,6 +472,7 @@ export const settingsOperations: Record<string, Record<string, SettingsOperation
443472
})
444473
),
445474
},
475+
'organization/sim-links': simLinkSettingsActions,
446476
'organization/usage': {
447477
summary: settingsOperation(
448478
'read',
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { z } from 'zod'
2+
import { listSimLinksQuerySchema } from '@/lib/api/contracts/sim-links'
3+
import { presentSimLink } from '@/lib/api/server/sim-link-presenters'
4+
import type { SettingsContext } from '@/lib/mothership/application/settings-context'
5+
import {
6+
settingsOperation,
7+
settingsOrganizationId,
8+
} from '@/lib/mothership/tools/server/settings-operation'
9+
import {
10+
createSimLink,
11+
deleteSimLink,
12+
getSimLinkView,
13+
listSimLinks,
14+
updateSimLink,
15+
} from '@/lib/sim-links/application/use-cases'
16+
import { simLinkFieldsSchema } from '@/lib/sim-links/validation'
17+
18+
const linkId = z.string().min(1).max(255)
19+
20+
/** The first page of links this member may browse, or the page `query` selects. */
21+
export async function readSettingsSimLinks(
22+
context: SettingsContext,
23+
query: z.output<typeof listSimLinksQuerySchema> = {}
24+
) {
25+
const page = await listSimLinks.execute({
26+
principal: context.principal,
27+
input: { ...query, organizationId: settingsOrganizationId(context) },
28+
})
29+
return { links: page.links.map(presentSimLink), nextCursor: page.nextCursor }
30+
}
31+
32+
/** Per-link edit, unlist and restrict rules, audit and the rollout flag stay in the use cases. */
33+
export const simLinkSettingsActions = {
34+
list: settingsOperation('read', listSimLinksQuerySchema, readSettingsSimLinks),
35+
get: settingsOperation(
36+
'read',
37+
z.union([
38+
z.strictObject({ linkId }),
39+
z.strictObject({
40+
name: z
41+
.string()
42+
.trim()
43+
.min(1)
44+
.max(255)
45+
.describe('The name as typed, with or without the sim/ prefix'),
46+
}),
47+
]),
48+
async (context, input) =>
49+
presentSimLink(
50+
await getSimLinkView.execute({
51+
principal: context.principal,
52+
input: {
53+
organizationId: settingsOrganizationId(context),
54+
...('linkId' in input
55+
? { linkId: input.linkId }
56+
: { name: input.name.replace(/^sim\//i, '') }),
57+
},
58+
})
59+
)
60+
),
61+
create: settingsOperation('write', simLinkFieldsSchema, async (context, fields) =>
62+
presentSimLink(
63+
await createSimLink.execute({
64+
principal: context.principal,
65+
input: { ...fields, organizationId: settingsOrganizationId(context) },
66+
})
67+
)
68+
),
69+
update: settingsOperation(
70+
'write',
71+
z.strictObject({
72+
linkId,
73+
fields: simLinkFieldsSchema.describe(
74+
'Every field of the link as it should be saved; read it with get first'
75+
),
76+
}),
77+
async (context, input) =>
78+
presentSimLink(
79+
await updateSimLink.execute({
80+
principal: context.principal,
81+
input: {
82+
...input.fields,
83+
linkId: input.linkId,
84+
organizationId: settingsOrganizationId(context),
85+
},
86+
})
87+
)
88+
),
89+
delete: settingsOperation('write', z.strictObject({ linkId }), async (context, input) => ({
90+
deleted: (
91+
await deleteSimLink.execute({
92+
principal: context.principal,
93+
input: { ...input, organizationId: settingsOrganizationId(context) },
94+
})
95+
).id,
96+
})),
97+
}

‎apps/sim/lib/mothership/tools/server/settings.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
} from '@/lib/mothership/tools/server/settings-operation'
4747
import { settingsOperations } from '@/lib/mothership/tools/server/settings-operations'
4848
import { readSettingsDomains } from '@/lib/mothership/tools/server/settings-security'
49+
import { readSettingsSimLinks } from '@/lib/mothership/tools/server/settings-sim-links'
4950
import { organizationRoutes } from '@/lib/navigation/paths'
5051
import {
5152
getOrganizationDataRetention,
@@ -228,6 +229,7 @@ const adapters: Record<string, SettingAdapter> = {
228229
}),
229230
},
230231
'organization/members': { get: readSettingsRoster },
232+
'organization/sim-links': { get: (context) => readSettingsSimLinks(context) },
231233
'organization/whitelabeling': {
232234
schema: updateOrganizationWhitelabelBodySchema.strict(),
233235
get: async (context) =>

‎apps/sim/lib/organizations/application/member-removal.test.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ vi.mock('@/lib/posthog/server', () => posthogServerMock)
3131
vi.mock('@sim/audit', () => auditMock)
3232
vi.mock('@/ee/scim/lib/managed-membership', () => ({ assertMembershipNotScimManaged: vi.fn() }))
3333

34-
import { removeOrganizationMember } from '@/lib/organizations/application/members'
34+
import {
35+
previewOrganizationMemberRemoval,
36+
removeOrganizationMember,
37+
} from '@/lib/organizations/application/members'
3538
import { DELETE } from '@/app/api/organizations/[id]/members/[memberId]/route'
3639

3740
const mocks = {
@@ -40,6 +43,7 @@ const mocks = {
4043
remove: organizationMembershipMockFns.mockRemoveUserFromOrganization,
4144
external: organizationMembershipMockFns.mockRemoveExternalUserFromOrganizationWorkspaces,
4245
audit: auditMockFns.mockRecordAudit,
46+
impact: organizationMembershipMockFns.mockGetOrganizationTransferCredentialDependencies,
4347
}
4448

4549
const principal = {
@@ -147,3 +151,24 @@ describe('organization member removal', () => {
147151
expect(mocks.audit).not.toHaveBeenCalled()
148152
})
149153
})
154+
describe('organization member removal preview', () => {
155+
const credential = { id: 'cred', displayName: 'GitHub', type: 'oauth', workspaceId: 'ws' }
156+
beforeEach(() => mocks.impact.mockReset().mockResolvedValue([credential]))
157+
it("refuses a delegated member previewing somebody else's removal before reading credentials", async () => {
158+
queueTableRows(member, [{ role: 'member' }])
159+
await expect(
160+
previewOrganizationMemberRemoval.execute({ principal, input })
161+
).rejects.toMatchObject({ code: 'forbidden' })
162+
expect(mocks.impact).not.toHaveBeenCalled()
163+
})
164+
it('lets a delegated member preview leaving', async () => {
165+
queueTableRows(member, [{ role: 'member' }])
166+
await expect(
167+
previewOrganizationMemberRemoval.execute({
168+
principal,
169+
input: { organizationId: 'org', userId: 'actor' },
170+
})
171+
).resolves.toEqual({ credentials: [credential] })
172+
expect(mocks.impact).toHaveBeenCalledExactlyOnceWith('actor', 'org')
173+
})
174+
})

‎apps/sim/lib/organizations/application/members.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { AuditAction, AuditResourceType } from '@sim/audit'
22
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
3+
import { getOrganizationTransferCredentialDependencies } from '@/lib/billing/organizations/membership'
34
import {
45
defineAuthorizedOrganizationUseCase,
56
type OrganizationUseCaseContext,
@@ -79,3 +80,27 @@ export const removeOrganizationMember = defineAuthorizedOrganizationUseCase({
7980
},
8081
}),
8182
})
83+
84+
/**
85+
* Identity-bound credentials the target user owns in this organization's workspaces: the set that
86+
* stops working when their workspace access is revoked. Administrators preview removing anyone;
87+
* members preview leaving.
88+
*/
89+
export const previewOrganizationMemberRemoval = defineAuthorizedOrganizationUseCase({
90+
operation: organizationOperations.previewMemberRemoval,
91+
authorizeResource: ({ input, context }) => {
92+
if (!isOrgAdminRole(context.role) && input.userId !== context.userId)
93+
throw new ForbiddenOperationError(
94+
'ORGANIZATION_ADMIN_REQUIRED',
95+
'Forbidden - Admin access required'
96+
)
97+
},
98+
execute: async ({
99+
input,
100+
}: OrganizationUseCaseContext<{ organizationId: string; userId: string }>) => ({
101+
credentials: await getOrganizationTransferCredentialDependencies(
102+
input.userId,
103+
input.organizationId
104+
),
105+
}),
106+
})

‎apps/sim/lib/organizations/application/operations.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ export const organizationOperations = {
6767
delegatedServices: ['copilot'],
6868
oauthScope: 'api:write',
6969
}),
70+
/**
71+
* permission-group-exempt: the application allows administrators to preview any member's removal and members to preview leaving.
72+
*/
73+
previewMemberRemoval: defineOrganizationOperation({
74+
id: 'organizations.members.removal_impact.read',
75+
contentDelivery: 'none',
76+
minimumRole: 'member',
77+
capability: 'none',
78+
principalKinds: ['session', 'organization_delegated'],
79+
delegationAudience: 'sim:settings',
80+
delegatedServices: ['copilot'],
81+
}),
7082
/**
7183
* permission-group-exempt: administrators can inspect existing invitations even when new invitations are disabled.
7284
*/

0 commit comments

Comments
 (0)