From 6868f428b417d9e63842729c88172a6b4170d1f2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 18:40:03 -0800 Subject: [PATCH] improvement(chat): let Chat manage sim links, bulk-add permission group members, and preview member removal --- .../[id]/removal-impact/route.ts | 69 ++++--------- apps/sim/lib/api/contracts/sim-links.ts | 24 ++--- .../tools/server/settings-operations.ts | 30 ++++++ .../tools/server/settings-sim-links.ts | 97 +++++++++++++++++++ .../lib/mothership/tools/server/settings.ts | 2 + .../application/member-removal.test.ts | 27 +++++- .../lib/organizations/application/members.ts | 25 +++++ .../organizations/application/operations.ts | 12 +++ apps/sim/lib/settings/sections.ts | 6 ++ .../lib/sim-links/application/operations.ts | 23 ++++- .../lib/sim-links/application/use-cases.ts | 23 +++++ apps/sim/lib/sim-links/repository.ts | 2 +- 12 files changed, 275 insertions(+), 65 deletions(-) create mode 100644 apps/sim/lib/mothership/tools/server/settings-sim-links.ts diff --git a/apps/sim/app/api/organizations/[id]/removal-impact/route.ts b/apps/sim/app/api/organizations/[id]/removal-impact/route.ts index 8b3e9c8a3a9..2085f741d40 100644 --- a/apps/sim/app/api/organizations/[id]/removal-impact/route.ts +++ b/apps/sim/app/api/organizations/[id]/removal-impact/route.ts @@ -1,52 +1,25 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { getMemberRemovalImpactContract } from '@/lib/api/contracts/organization' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' -import { getOrganizationTransferCredentialDependencies } from '@/lib/billing/organizations/membership' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('OrganizationRemovalImpactAPI') +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalOrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' +import { previewOrganizationMemberRemoval } from '@/lib/organizations/application/members' /** - * Identity-bound credentials the target user owns in this organization's - * workspaces — the set that stops working when their workspace access is - * revoked. Readable by org admins (removing someone) and by the user - * themself (leaving). + * Identity-bound credentials the target user owns in this organization's workspaces. Readable by + * org admins (removing someone) and by the user themself (leaving). */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getMemberRemovalImpactContract, request, context) - if (!parsed.success) return parsed.response - - const { id: organizationId } = parsed.data.params - const { userId: targetUserId } = parsed.data.query - - try { - const isSelf = targetUserId === session.user.id - if (!isSelf && !(await isOrganizationOwnerOrAdmin(session.user.id, organizationId))) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } - - const credentials = await getOrganizationTransferCredentialDependencies( - targetUserId, - organizationId - ) - - return NextResponse.json({ credentials }) - } catch (error) { - logger.error('Failed to compute member removal impact', { - organizationId, - targetUserId, - error, - }) - return NextResponse.json({ error: 'Failed to compute removal impact' }, { status: 500 }) - } - } -) +export const GET = defineInternalJsonRoute({ + contract: getMemberRemovalImpactContract, + auth: internalSessionAuth, + operation: previewOrganizationMemberRemoval.operation, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing organization member administration behavior.', + }), + errorPolicy: internalOrganizationErrorPolicy, + mapInput: ({ params, query }) => ({ organizationId: params.id, userId: query.userId }), + useCase: previewOrganizationMemberRemoval, + present: ({ credentials }) => ({ credentials }), +}) diff --git a/apps/sim/lib/api/contracts/sim-links.ts b/apps/sim/lib/api/contracts/sim-links.ts index 98de8a146ac..dff8d0983f3 100644 --- a/apps/sim/lib/api/contracts/sim-links.ts +++ b/apps/sim/lib/api/contracts/sim-links.ts @@ -35,21 +35,23 @@ export type SimLinkBody = z.input const linkResponse = z.object({ link: simLinkSchema }) +export const listSimLinksQuerySchema = z + .object({ + search: z.string().max(200).optional(), + cursor: z.string().min(1).max(128).optional(), + sort: z.enum(SIM_LINK_SORTS).optional(), + dir: z.enum(SORT_DIRECTIONS).optional(), + }) + .refine((query) => !(query.sort === 'popular' && query.cursor), { + path: ['cursor'], + message: 'The most-used view is a single page', + }) + export const listSimLinksContract = defineRouteContract({ method: 'GET', path: '/api/organizations/[id]/sim-links', params, - query: z - .object({ - search: z.string().max(200).optional(), - cursor: z.string().min(1).max(128).optional(), - sort: z.enum(SIM_LINK_SORTS).optional(), - dir: z.enum(SORT_DIRECTIONS).optional(), - }) - .refine((query) => !(query.sort === 'popular' && query.cursor), { - path: ['cursor'], - message: 'The most-used view is a single page', - }), + query: listSimLinksQuerySchema, response: { mode: 'json', schema: z.object({ links: z.array(simLinkSchema), nextCursor: z.string().nullable() }), diff --git a/apps/sim/lib/mothership/tools/server/settings-operations.ts b/apps/sim/lib/mothership/tools/server/settings-operations.ts index d863a971f99..682730a3d1b 100644 --- a/apps/sim/lib/mothership/tools/server/settings-operations.ts +++ b/apps/sim/lib/mothership/tools/server/settings-operations.ts @@ -14,6 +14,7 @@ import { createPermissionGroupBodySchema, updatePermissionGroupBodySchema, } from '@/lib/api/contracts/permission-groups' +import { v2BulkAddPermissionGroupMembersBodySchema } from '@/lib/api/contracts/v2/permission-groups' import { updateWorkspacePermissionsBodySchema } from '@/lib/api/contracts/workspaces' import { getOrganizationUsageBreakdown } from '@/lib/billing/application/organization-usage/get-organization-usage-breakdown' import { getOrganizationUsageSummary } from '@/lib/billing/application/organization-usage/get-organization-usage-summary' @@ -77,12 +78,15 @@ import { settingsWorkspaceId, } from '@/lib/mothership/tools/server/settings-operation' import { organizationDomainSettingsActions } from '@/lib/mothership/tools/server/settings-security' +import { simLinkSettingsActions } from '@/lib/mothership/tools/server/settings-sim-links' import { + previewOrganizationMemberRemoval, removeOrganizationMember, updateOrganizationMember, } from '@/lib/organizations/application/members' import { addPermissionGroupMember, + bulkAddPermissionGroupMembers, createPermissionGroup, deletePermissionGroup, getPermissionGroup, @@ -370,6 +374,22 @@ export const settingsOperations: Record { + const { added, skipped } = await bulkAddPermissionGroupMembers.execute({ + principal: context.principal, + input: { ...input, organizationId: settingsOrganizationId(context) }, + }) + return { added, skipped } + } + ), remove_member: settingsOperation( 'write', z.strictObject({ groupId: z.string().min(1), memberId: z.string().min(1) }), @@ -412,6 +432,15 @@ export const settingsOperations: Record + previewOrganizationMemberRemoval.execute({ + principal: context.principal, + input: { ...input, organizationId: settingsOrganizationId(context) }, + }) + ), remove: settingsOperation( 'write', z.strictObject({ userId: z.string().min(1) }), @@ -443,6 +472,7 @@ export const settingsOperations: Record = {} +) { + const page = await listSimLinks.execute({ + principal: context.principal, + input: { ...query, organizationId: settingsOrganizationId(context) }, + }) + return { links: page.links.map(presentSimLink), nextCursor: page.nextCursor } +} + +/** Per-link edit, unlist and restrict rules, audit and the rollout flag stay in the use cases. */ +export const simLinkSettingsActions = { + list: settingsOperation('read', listSimLinksQuerySchema, readSettingsSimLinks), + get: settingsOperation( + 'read', + z.union([ + z.strictObject({ linkId }), + z.strictObject({ + name: z + .string() + .trim() + .min(1) + .max(255) + .describe('The name as typed, with or without the sim/ prefix'), + }), + ]), + async (context, input) => + presentSimLink( + await getSimLinkView.execute({ + principal: context.principal, + input: { + organizationId: settingsOrganizationId(context), + ...('linkId' in input + ? { linkId: input.linkId } + : { name: input.name.replace(/^sim\//i, '') }), + }, + }) + ) + ), + create: settingsOperation('write', simLinkFieldsSchema, async (context, fields) => + presentSimLink( + await createSimLink.execute({ + principal: context.principal, + input: { ...fields, organizationId: settingsOrganizationId(context) }, + }) + ) + ), + update: settingsOperation( + 'write', + z.strictObject({ + linkId, + fields: simLinkFieldsSchema.describe( + 'Every field of the link as it should be saved; read it with get first' + ), + }), + async (context, input) => + presentSimLink( + await updateSimLink.execute({ + principal: context.principal, + input: { + ...input.fields, + linkId: input.linkId, + organizationId: settingsOrganizationId(context), + }, + }) + ) + ), + delete: settingsOperation('write', z.strictObject({ linkId }), async (context, input) => ({ + deleted: ( + await deleteSimLink.execute({ + principal: context.principal, + input: { ...input, organizationId: settingsOrganizationId(context) }, + }) + ).id, + })), +} diff --git a/apps/sim/lib/mothership/tools/server/settings.ts b/apps/sim/lib/mothership/tools/server/settings.ts index ccd509b7189..21b29c2d014 100644 --- a/apps/sim/lib/mothership/tools/server/settings.ts +++ b/apps/sim/lib/mothership/tools/server/settings.ts @@ -46,6 +46,7 @@ import { } from '@/lib/mothership/tools/server/settings-operation' import { settingsOperations } from '@/lib/mothership/tools/server/settings-operations' import { readSettingsDomains } from '@/lib/mothership/tools/server/settings-security' +import { readSettingsSimLinks } from '@/lib/mothership/tools/server/settings-sim-links' import { organizationRoutes } from '@/lib/navigation/paths' import { getOrganizationDataRetention, @@ -228,6 +229,7 @@ const adapters: Record = { }), }, 'organization/members': { get: readSettingsRoster }, + 'organization/sim-links': { get: (context) => readSettingsSimLinks(context) }, 'organization/whitelabeling': { schema: updateOrganizationWhitelabelBodySchema.strict(), get: async (context) => diff --git a/apps/sim/lib/organizations/application/member-removal.test.ts b/apps/sim/lib/organizations/application/member-removal.test.ts index f89aa23366b..eecdfc7cbf7 100644 --- a/apps/sim/lib/organizations/application/member-removal.test.ts +++ b/apps/sim/lib/organizations/application/member-removal.test.ts @@ -31,7 +31,10 @@ vi.mock('@/lib/posthog/server', () => posthogServerMock) vi.mock('@sim/audit', () => auditMock) vi.mock('@/ee/scim/lib/managed-membership', () => ({ assertMembershipNotScimManaged: vi.fn() })) -import { removeOrganizationMember } from '@/lib/organizations/application/members' +import { + previewOrganizationMemberRemoval, + removeOrganizationMember, +} from '@/lib/organizations/application/members' import { DELETE } from '@/app/api/organizations/[id]/members/[memberId]/route' const mocks = { @@ -40,6 +43,7 @@ const mocks = { remove: organizationMembershipMockFns.mockRemoveUserFromOrganization, external: organizationMembershipMockFns.mockRemoveExternalUserFromOrganizationWorkspaces, audit: auditMockFns.mockRecordAudit, + impact: organizationMembershipMockFns.mockGetOrganizationTransferCredentialDependencies, } const principal = { @@ -147,3 +151,24 @@ describe('organization member removal', () => { expect(mocks.audit).not.toHaveBeenCalled() }) }) +describe('organization member removal preview', () => { + const credential = { id: 'cred', displayName: 'GitHub', type: 'oauth', workspaceId: 'ws' } + beforeEach(() => mocks.impact.mockReset().mockResolvedValue([credential])) + it("refuses a delegated member previewing somebody else's removal before reading credentials", async () => { + queueTableRows(member, [{ role: 'member' }]) + await expect( + previewOrganizationMemberRemoval.execute({ principal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.impact).not.toHaveBeenCalled() + }) + it('lets a delegated member preview leaving', async () => { + queueTableRows(member, [{ role: 'member' }]) + await expect( + previewOrganizationMemberRemoval.execute({ + principal, + input: { organizationId: 'org', userId: 'actor' }, + }) + ).resolves.toEqual({ credentials: [credential] }) + expect(mocks.impact).toHaveBeenCalledExactlyOnceWith('actor', 'org') + }) +}) diff --git a/apps/sim/lib/organizations/application/members.ts b/apps/sim/lib/organizations/application/members.ts index 2817d69a72c..7d2e41a2d4d 100644 --- a/apps/sim/lib/organizations/application/members.ts +++ b/apps/sim/lib/organizations/application/members.ts @@ -1,5 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { getOrganizationTransferCredentialDependencies } from '@/lib/billing/organizations/membership' import { defineAuthorizedOrganizationUseCase, type OrganizationUseCaseContext, @@ -79,3 +80,27 @@ export const removeOrganizationMember = defineAuthorizedOrganizationUseCase({ }, }), }) + +/** + * Identity-bound credentials the target user owns in this organization's workspaces: the set that + * stops working when their workspace access is revoked. Administrators preview removing anyone; + * members preview leaving. + */ +export const previewOrganizationMemberRemoval = defineAuthorizedOrganizationUseCase({ + operation: organizationOperations.previewMemberRemoval, + authorizeResource: ({ input, context }) => { + if (!isOrgAdminRole(context.role) && input.userId !== context.userId) + throw new ForbiddenOperationError( + 'ORGANIZATION_ADMIN_REQUIRED', + 'Forbidden - Admin access required' + ) + }, + execute: async ({ + input, + }: OrganizationUseCaseContext<{ organizationId: string; userId: string }>) => ({ + credentials: await getOrganizationTransferCredentialDependencies( + input.userId, + input.organizationId + ), + }), +}) diff --git a/apps/sim/lib/organizations/application/operations.ts b/apps/sim/lib/organizations/application/operations.ts index e79a5fc75d3..b03be01b5fb 100644 --- a/apps/sim/lib/organizations/application/operations.ts +++ b/apps/sim/lib/organizations/application/operations.ts @@ -67,6 +67,18 @@ export const organizationOperations = { delegatedServices: ['copilot'], oauthScope: 'api:write', }), + /** + * permission-group-exempt: the application allows administrators to preview any member's removal and members to preview leaving. + */ + previewMemberRemoval: defineOrganizationOperation({ + id: 'organizations.members.removal_impact.read', + contentDelivery: 'none', + minimumRole: 'member', + capability: 'none', + principalKinds: ['session', 'organization_delegated'], + delegationAudience: 'sim:settings', + delegatedServices: ['copilot'], + }), /** * permission-group-exempt: administrators can inspect existing invitations even when new invitations are disabled. */ diff --git a/apps/sim/lib/settings/sections.ts b/apps/sim/lib/settings/sections.ts index 3cade5c6717..997fa29ae8a 100644 --- a/apps/sim/lib/settings/sections.ts +++ b/apps/sim/lib/settings/sections.ts @@ -53,6 +53,12 @@ export const settingsSections: Record