From ae1b5fe09c75477f99cb4133f36d297ef2925326 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 12 May 2026 10:18:13 -0400 Subject: [PATCH] feat(admin): manage organization frameworks Add platform-admin scoped framework management so admins can add or remove frameworks for a specific organization without touching global framework definitions. Co-authored-by: Cursor --- .../admin-frameworks.controller.spec.ts | 121 ++++++++ .../admin-frameworks.controller.ts | 79 +++++ .../admin-organizations.module.ts | 4 + .../admin-security.spec.ts | 41 ++- .../components/AdminOrgTabs.test.tsx | 2 + .../[adminOrgId]/components/AdminOrgTabs.tsx | 68 +++- .../FrameworkConfirmationDialog.tsx | 69 +++++ .../components/FrameworkMobileCards.tsx | 89 ++++++ .../components/FrameworksTab.test.tsx | 116 +++++++ .../[adminOrgId]/components/FrameworksTab.tsx | 291 ++++++++++++++++++ 10 files changed, 862 insertions(+), 18 deletions(-) create mode 100644 apps/api/src/admin-organizations/admin-frameworks.controller.spec.ts create mode 100644 apps/api/src/admin-organizations/admin-frameworks.controller.ts create mode 100644 apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/FrameworkConfirmationDialog.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/FrameworkMobileCards.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/FrameworksTab.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/FrameworksTab.tsx diff --git a/apps/api/src/admin-organizations/admin-frameworks.controller.spec.ts b/apps/api/src/admin-organizations/admin-frameworks.controller.spec.ts new file mode 100644 index 0000000000..13bcf2713c --- /dev/null +++ b/apps/api/src/admin-organizations/admin-frameworks.controller.spec.ts @@ -0,0 +1,121 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AdminFrameworksController } from './admin-frameworks.controller'; +import { FrameworksService } from '../frameworks/frameworks.service'; + +jest.mock('../auth/platform-admin.guard', () => ({ + PlatformAdminGuard: class { + canActivate() { + return true; + } + }, +})); + +jest.mock('../auth/auth.server', () => ({ + auth: { api: {} }, +})); + +jest.mock('@db', () => ({ + db: {}, + AuditLogEntityType: { + organization: 'organization', + people: 'people', + control: 'control', + policy: 'policy', + task: 'task', + vendor: 'vendor', + risk: 'risk', + finding: 'finding', + framework: 'framework', + integration: 'integration', + trust: 'trust', + pentest: 'pentest', + }, + CommentEntityType: { + task: 'task', + vendor: 'vendor', + risk: 'risk', + policy: 'policy', + }, + FindingType: { soc2: 'soc2', iso27001: 'iso27001' }, +})); + +jest.mock('@trigger.dev/sdk', () => ({ + tasks: { trigger: jest.fn() }, +})); + +jest.mock('../frameworks/frameworks-scores.helper', () => ({ + getOverviewScores: jest.fn(), + getCurrentMember: jest.fn(), + computeFrameworkComplianceScore: jest.fn(), +})); + +describe('AdminFrameworksController', () => { + let controller: AdminFrameworksController; + + const mockService = { + findAll: jest.fn(), + findAvailable: jest.fn(), + addFrameworks: jest.fn(), + delete: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminFrameworksController], + providers: [{ provide: FrameworksService, useValue: mockService }], + }).compile(); + + controller = module.get( + AdminFrameworksController, + ); + jest.clearAllMocks(); + }); + + it('lists active frameworks and only unavailable platform frameworks', async () => { + const activeFramework = { + id: 'fi_1', + framework: { id: 'fw_soc2', name: 'SOC 2' }, + customFramework: null, + }; + mockService.findAll.mockResolvedValue([activeFramework]); + mockService.findAvailable.mockResolvedValue([ + { id: 'fw_soc2', name: 'SOC 2', isCustom: false }, + { id: 'fw_iso', name: 'ISO 27001', isCustom: false }, + { id: 'cf_1', name: 'Custom', isCustom: true }, + ]); + + const result = await controller.list('org_1'); + + expect(mockService.findAll).toHaveBeenCalledWith('org_1'); + expect(mockService.findAvailable).toHaveBeenCalledWith('org_1'); + expect(result).toEqual({ + frameworks: [activeFramework], + availableFrameworks: [ + { id: 'fw_iso', name: 'ISO 27001', isCustom: false }, + ], + }); + }); + + it('adds frameworks to the requested organization', async () => { + const created = { success: true, frameworksAdded: 1 }; + mockService.addFrameworks.mockResolvedValue(created); + + const result = await controller.addFrameworks('org_1', { + frameworkIds: ['fw_soc2'], + }); + + expect(mockService.addFrameworks).toHaveBeenCalledWith('org_1', [ + 'fw_soc2', + ]); + expect(result).toEqual(created); + }); + + it('deletes framework instances from the requested organization', async () => { + mockService.delete.mockResolvedValue({ success: true }); + + const result = await controller.deleteFramework('org_1', 'fi_1'); + + expect(mockService.delete).toHaveBeenCalledWith('fi_1', 'org_1'); + expect(result).toEqual({ success: true }); + }); +}); diff --git a/apps/api/src/admin-organizations/admin-frameworks.controller.ts b/apps/api/src/admin-organizations/admin-frameworks.controller.ts new file mode 100644 index 0000000000..33ae923d83 --- /dev/null +++ b/apps/api/src/admin-organizations/admin-frameworks.controller.ts @@ -0,0 +1,79 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + UseGuards, + UseInterceptors, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { PlatformAdminGuard } from '../auth/platform-admin.guard'; +import { FrameworksService } from '../frameworks/frameworks.service'; +import { AddFrameworksDto } from '../frameworks/dto/add-frameworks.dto'; +import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor'; + +@ApiExcludeController() +@ApiTags('Admin - Frameworks') +@Controller({ path: 'admin/organizations', version: '1' }) +@UseGuards(PlatformAdminGuard) +@UseInterceptors(AdminAuditLogInterceptor) +@Throttle({ default: { ttl: 60000, limit: 30 } }) +export class AdminFrameworksController { + constructor(private readonly frameworksService: FrameworksService) {} + + @Get(':orgId/frameworks') + @ApiOperation({ summary: 'List frameworks for an organization (admin)' }) + async list(@Param('orgId') orgId: string) { + const [frameworks, availableFrameworks] = await Promise.all([ + this.frameworksService.findAll(orgId), + this.frameworksService.findAvailable(orgId), + ]); + + const activeFrameworkIds = new Set( + frameworks + .map( + (framework) => + framework.framework?.id ?? framework.customFramework?.id, + ) + .filter((id): id is string => Boolean(id)), + ); + + return { + frameworks, + availableFrameworks: availableFrameworks.filter( + (framework) => + framework.isCustom === false && !activeFrameworkIds.has(framework.id), + ), + }; + } + + @Post(':orgId/frameworks') + @ApiOperation({ summary: 'Add frameworks to an organization (admin)' }) + @UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + async addFrameworks( + @Param('orgId') orgId: string, + @Body() dto: AddFrameworksDto, + ) { + return this.frameworksService.addFrameworks(orgId, dto.frameworkIds); + } + + @Delete(':orgId/frameworks/:frameworkInstanceId') + @ApiOperation({ summary: 'Remove a framework from an organization (admin)' }) + async deleteFramework( + @Param('orgId') orgId: string, + @Param('frameworkInstanceId') frameworkInstanceId: string, + ) { + return this.frameworksService.delete(frameworkInstanceId, orgId); + } +} diff --git a/apps/api/src/admin-organizations/admin-organizations.module.ts b/apps/api/src/admin-organizations/admin-organizations.module.ts index 4afc166c4c..864bdff8c6 100644 --- a/apps/api/src/admin-organizations/admin-organizations.module.ts +++ b/apps/api/src/admin-organizations/admin-organizations.module.ts @@ -9,6 +9,7 @@ import { CommentsModule } from '../comments/comments.module'; import { AttachmentsModule } from '../attachments/attachments.module'; import { BillingModule } from '../billing/billing.module'; import { SecurityPenetrationTestsModule } from '../security-penetration-tests/security-penetration-tests.module'; +import { FrameworksModule } from '../frameworks/frameworks.module'; import { AdminBillingActionsService } from './admin-billing-actions.service'; import { AdminBillingController } from './admin-billing.controller'; import { AdminBillingService } from './admin-billing.service'; @@ -24,6 +25,7 @@ import { AdminVendorsController } from './admin-vendors.controller'; import { AdminContextController } from './admin-context.controller'; import { AdminEvidenceController } from './admin-evidence.controller'; import { AdminPentestCreditsController } from './admin-pentest-credits.controller'; +import { AdminFrameworksController } from './admin-frameworks.controller'; @Module({ imports: [ @@ -37,6 +39,7 @@ import { AdminPentestCreditsController } from './admin-pentest-credits.controlle AttachmentsModule, BillingModule, SecurityPenetrationTestsModule, + FrameworksModule, ], controllers: [ AdminOrganizationsController, @@ -48,6 +51,7 @@ import { AdminPentestCreditsController } from './admin-pentest-credits.controlle AdminEvidenceController, AdminPentestCreditsController, AdminBillingController, + AdminFrameworksController, ], providers: [ AdminOrganizationsService, diff --git a/apps/api/src/admin-organizations/admin-security.spec.ts b/apps/api/src/admin-organizations/admin-security.spec.ts index 41435770f6..2bfa52ade9 100644 --- a/apps/api/src/admin-organizations/admin-security.spec.ts +++ b/apps/api/src/admin-organizations/admin-security.spec.ts @@ -12,6 +12,7 @@ import { AdminTasksController } from './admin-tasks.controller'; import { AdminVendorsController } from './admin-vendors.controller'; import { AdminContextController } from './admin-context.controller'; import { AdminEvidenceController } from './admin-evidence.controller'; +import { AdminFrameworksController } from './admin-frameworks.controller'; import { AdminIntegrationsController } from '../integration-platform/controllers/admin-integrations.controller'; import { PlatformAuditLogInterceptor } from '../integration-platform/interceptors/platform-audit-log.interceptor'; @@ -21,6 +22,20 @@ jest.mock('../auth/auth.server', () => ({ jest.mock('@db', () => ({ db: {}, + AuditLogEntityType: { + organization: 'organization', + people: 'people', + control: 'control', + policy: 'policy', + task: 'task', + vendor: 'vendor', + risk: 'risk', + finding: 'finding', + framework: 'framework', + integration: 'integration', + trust: 'trust', + pentest: 'pentest', + }, FindingStatus: { open: 'open', ready_for_review: 'ready_for_review', @@ -28,6 +43,12 @@ jest.mock('@db', () => ({ closed: 'closed', }, FindingType: { soc2: 'soc2', iso27001: 'iso27001' }, + FindingSeverity: { + low: 'low', + medium: 'medium', + high: 'high', + critical: 'critical', + }, TaskStatus: { todo: 'todo', in_progress: 'in_progress', done: 'done' }, TaskFrequency: { daily: 'daily', weekly: 'weekly', monthly: 'monthly' }, Departments: { none: 'none', engineering: 'engineering' }, @@ -38,6 +59,17 @@ jest.mock('@db', () => ({ Prisma: {}, })); +jest.mock('@trycompai/auth', () => ({ + RESTRICTED_ROLES: ['employee', 'contractor'], + PRIVILEGED_ROLES: ['owner', 'admin', 'auditor'], +})); + +jest.mock('../frameworks/frameworks-scores.helper', () => ({ + getOverviewScores: jest.fn(), + getCurrentMember: jest.fn(), + computeFrameworkComplianceScore: jest.fn(), +})); + jest.mock('@trigger.dev/sdk', () => ({ auth: { createPublicToken: jest.fn() }, tasks: { trigger: jest.fn() }, @@ -59,6 +91,7 @@ const ORG_ADMIN_CONTROLLERS = [ { name: 'AdminVendorsController', controller: AdminVendorsController }, { name: 'AdminContextController', controller: AdminContextController }, { name: 'AdminEvidenceController', controller: AdminEvidenceController }, + { name: 'AdminFrameworksController', controller: AdminFrameworksController }, ]; describe('Admin controllers security baseline', () => { @@ -103,8 +136,8 @@ describe('Admin controllers security baseline', () => { }); }); - it('covers all 7 expected org-scoped admin controllers', () => { - expect(ORG_ADMIN_CONTROLLERS).toHaveLength(7); + it('covers all 8 expected org-scoped admin controllers', () => { + expect(ORG_ADMIN_CONTROLLERS).toHaveLength(8); }); describe('AdminIntegrationsController', () => { @@ -150,8 +183,8 @@ describe('Admin controllers security baseline', () => { }); }); - it('covers all 8 admin controllers (7 org-scoped + 1 platform-scoped)', () => { - expect(ORG_ADMIN_CONTROLLERS).toHaveLength(7); + it('covers all 9 admin controllers (8 org-scoped + 1 platform-scoped)', () => { + expect(ORG_ADMIN_CONTROLLERS).toHaveLength(8); expect(AdminIntegrationsController).toBeDefined(); }); diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.test.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.test.tsx index 644c7a9681..e0cf395d69 100644 --- a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.test.tsx @@ -6,6 +6,7 @@ vi.mock('@/lib/api-client', () => ({ get: vi.fn().mockResolvedValue({ data: [] }), post: vi.fn().mockResolvedValue({ data: {} }), patch: vi.fn().mockResolvedValue({ data: {} }), + delete: vi.fn().mockResolvedValue({ data: {} }), }, })); @@ -63,6 +64,7 @@ describe('AdminOrgTabs', () => { expect(screen.getByRole('tab', { name: /overview/i })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /findings/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /frameworks/i })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /tasks/i })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /vendors/i })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /context/i })).toBeInTheDocument(); diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx index 71e2c8722d..31b90ec8fd 100644 --- a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx @@ -7,6 +7,10 @@ import { PageHeader, PageHeaderDescription, PageLayout, + Select, + SelectContent, + SelectItem, + SelectTrigger, Tabs, TabsContent, TabsList, @@ -21,6 +25,7 @@ import { ContextTab } from './ContextTab'; import { EvidenceTab } from './EvidenceTab'; import { FeatureFlagsTab } from './FeatureFlagsTab'; import { FindingsTab } from './FindingsTab'; +import { FrameworksTab } from './FrameworksTab'; import { MembersTab } from './MembersTab'; import { OrganizationDetail } from './OrganizationDetail'; import { PoliciesTab } from './PoliciesTab'; @@ -53,6 +58,21 @@ export interface AdminOrgDetail { members: OrgMember[]; } +const ADMIN_ORG_TABS = [ + { value: 'overview', label: 'Overview' }, + { value: 'members', label: 'Members' }, + { value: 'policies', label: 'Policies' }, + { value: 'findings', label: 'Findings' }, + { value: 'frameworks', label: 'Frameworks' }, + { value: 'tasks', label: 'Tasks' }, + { value: 'vendors', label: 'Vendors' }, + { value: 'context', label: 'Context' }, + { value: 'evidence', label: 'Evidence' }, + { value: 'timeline', label: 'Timeline' }, + { value: 'billing', label: 'Billing' }, + { value: 'feature-flags', label: 'Feature Flags' }, +]; + export function AdminOrgTabs({ org, currentOrgId }: { org: AdminOrgDetail; currentOrgId: string }) { const router = useRouter(); const [activeTab, setActiveTab] = useState('overview'); @@ -118,7 +138,7 @@ export function AdminOrgTabs({ org, currentOrgId }: { org: AdminOrgDetail; curre { label: org.name, isCurrent: true }, ]} actions={ -
+
{hasAccess ? 'Active' : 'Inactive'} @@ -143,19 +163,36 @@ export function AdminOrgTabs({ org, currentOrgId }: { org: AdminOrgDetail; curre
} tabs={ - - Overview - Members - Policies - Findings - Tasks - Vendors - Context - Evidence - Timeline - Billing - Feature Flags - +
+
+ +
+
+ + {ADMIN_ORG_TABS.map((tab) => ( + + {tab.label} + + ))} + +
+
} > {org.website && ( @@ -185,6 +222,9 @@ export function AdminOrgTabs({ org, currentOrgId }: { org: AdminOrgDetail; curre + + + diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/FrameworkConfirmationDialog.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/FrameworkConfirmationDialog.tsx new file mode 100644 index 0000000000..459f735c63 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/FrameworkConfirmationDialog.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@trycompai/design-system'; +import type { PendingAction } from './FrameworksTab'; + +function getPendingActionFrameworkName(pendingAction: PendingAction) { + if (pendingAction.type === 'add') { + return pendingAction.framework.name; + } + const details = pendingAction.framework.framework ?? pendingAction.framework.customFramework; + return details?.name ?? 'Unknown framework'; +} + +export function FrameworkConfirmationDialog({ + pendingAction, + submitting, + onOpenChange, + onConfirm, +}: { + pendingAction: PendingAction | null; + submitting: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}) { + const frameworkName = pendingAction ? getPendingActionFrameworkName(pendingAction) : ''; + + return ( + + + + + {pendingAction?.type === 'add' ? `Add ${frameworkName}?` : `Remove ${frameworkName}?`} + + + {pendingAction?.type === 'add' + ? `This will add ${frameworkName} to this organization and create its related structure.` + : `This will remove ${frameworkName} from this organization only. The global framework will not be deleted.`} + + + + Cancel + { + event.preventDefault(); + onConfirm(); + }} + disabled={submitting} + > + {submitting + ? 'Saving...' + : pendingAction?.type === 'delete' + ? 'Yes, remove framework' + : 'Yes, add framework'} + + + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/FrameworkMobileCards.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/FrameworkMobileCards.tsx new file mode 100644 index 0000000000..41bb14ba62 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/FrameworkMobileCards.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { Badge, Button, Text } from '@trycompai/design-system'; +import { Add, TrashCan } from '@trycompai/design-system/icons'; +import type { ActiveFramework, FrameworkDetails } from './FrameworksTab'; + +function getActiveDetails(framework: ActiveFramework) { + return framework.framework ?? framework.customFramework; +} + +export function ActiveFrameworkCards({ + frameworks, + onDelete, +}: { + frameworks: ActiveFramework[]; + onDelete: (framework: ActiveFramework) => void; +}) { + return ( +
+ {frameworks.map((framework) => { + const details = getActiveDetails(framework); + + return ( +
+
+
+ + {details?.name ?? 'Unknown framework'} + + {details?.description && ( + + {details.description} + + )} +
+ + {framework.customFramework ? 'Custom' : 'Platform'} + +
+
+ v{details?.version ?? '--'} + +
+
+ ); + })} +
+ ); +} + +export function AvailableFrameworkCards({ + frameworks, + onAdd, +}: { + frameworks: FrameworkDetails[]; + onAdd: (framework: FrameworkDetails) => void; +}) { + return ( +
+ {frameworks.map((framework) => ( +
+
+
+ + {framework.name} + + v{framework.version} +
+ + {framework.description ?? 'No description provided.'} + +
+
+ +
+
+ ))} +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/FrameworksTab.test.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/FrameworksTab.test.tsx new file mode 100644 index 0000000000..543106f58c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/FrameworksTab.test.tsx @@ -0,0 +1,116 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGet = vi.fn(); +const mockPost = vi.fn(); +const mockDelete = vi.fn(); + +vi.mock('@/lib/api-client', () => ({ + api: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + delete: (...args: unknown[]) => mockDelete(...args), + }, +})); + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +import { FrameworksTab } from './FrameworksTab'; + +const frameworkResponse = { + frameworks: [ + { + id: 'fi_soc2', + framework: { + id: 'fw_soc2', + name: 'SOC 2', + description: 'Security framework', + version: '2024', + visible: true, + }, + customFramework: null, + }, + ], + availableFrameworks: [ + { + id: 'fw_iso', + name: 'ISO 27001', + description: 'Security standard', + version: '2022', + visible: true, + }, + ], +}; + +describe('FrameworksTab', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGet.mockResolvedValue({ data: frameworkResponse }); + mockPost.mockResolvedValue({ data: { success: true } }); + mockDelete.mockResolvedValue({ data: { success: true } }); + }); + + it('loads admin frameworks endpoint', async () => { + render(); + + await waitFor(() => { + expect(mockGet).toHaveBeenCalledWith('/v1/admin/organizations/org_1/frameworks'); + }); + }); + + it('renders active and available frameworks', async () => { + render(); + + await waitFor(() => { + expect(screen.getAllByText('SOC 2').length).toBeGreaterThan(0); + }); + expect(screen.getAllByText('ISO 27001').length).toBeGreaterThan(0); + expect(screen.getByText(/active frameworks \(1\)/i)).toBeInTheDocument(); + expect(screen.getByText(/available frameworks \(1\)/i)).toBeInTheDocument(); + }); + + it('confirms before adding a framework', async () => { + render(); + + await waitFor(() => { + expect(screen.getAllByText('ISO 27001').length).toBeGreaterThan(0); + }); + + fireEvent.click(screen.getAllByRole('button', { name: /^add$/i })[0]); + + const confirmButton = screen.getByRole('button', { + name: /yes, add framework/i, + }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith('/v1/admin/organizations/org_1/frameworks', { + frameworkIds: ['fw_iso'], + }); + }); + }); + + it('confirms before removing a framework', async () => { + render(); + + await waitFor(() => { + expect(screen.getAllByText('SOC 2').length).toBeGreaterThan(0); + }); + + fireEvent.click(screen.getAllByRole('button', { name: /remove/i })[0]); + + const confirmButton = screen.getByRole('button', { + name: /yes, remove framework/i, + }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockDelete).toHaveBeenCalledWith('/v1/admin/organizations/org_1/frameworks/fi_soc2'); + }); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/FrameworksTab.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/FrameworksTab.tsx new file mode 100644 index 0000000000..714823f4a9 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/FrameworksTab.tsx @@ -0,0 +1,291 @@ +'use client'; + +import { api } from '@/lib/api-client'; +import { + Badge, + Button, + Section, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, +} from '@trycompai/design-system'; +import { Add, TrashCan } from '@trycompai/design-system/icons'; +import { useCallback, useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { FrameworkConfirmationDialog } from './FrameworkConfirmationDialog'; +import { ActiveFrameworkCards, AvailableFrameworkCards } from './FrameworkMobileCards'; + +export interface FrameworkDetails { + id: string; + name: string; + description: string | null; + version: string; + visible: boolean; +} + +export interface ActiveFramework { + id: string; + framework: FrameworkDetails | null; + customFramework: FrameworkDetails | null; +} + +interface AdminFrameworksResponse { + frameworks: ActiveFramework[]; + availableFrameworks: FrameworkDetails[]; +} + +export type PendingAction = + | { type: 'add'; framework: FrameworkDetails } + | { type: 'delete'; framework: ActiveFramework }; + +function getActiveFrameworkDetails(framework: ActiveFramework) { + return framework.framework ?? framework.customFramework; +} + +export function getActiveFrameworkName(framework: ActiveFramework) { + return getActiveFrameworkDetails(framework)?.name ?? 'Unknown framework'; +} + +export function FrameworksTab({ orgId }: { orgId: string }) { + const [frameworks, setFrameworks] = useState([]); + const [availableFrameworks, setAvailableFrameworks] = useState([]); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [pendingAction, setPendingAction] = useState(null); + + const fetchFrameworks = useCallback(async () => { + setLoading(true); + const res = await api.get( + `/v1/admin/organizations/${orgId}/frameworks`, + ); + if (res.error) { + toast.error(res.error); + } + if (res.data) { + setFrameworks(res.data.frameworks); + setAvailableFrameworks(res.data.availableFrameworks); + } + setLoading(false); + }, [orgId]); + + useEffect(() => { + void fetchFrameworks(); + }, [fetchFrameworks]); + + const handleDialogOpenChange = (open: boolean) => { + if (submitting) return; + if (!open) { + setPendingAction(null); + } + }; + + const handleConfirm = async () => { + if (!pendingAction) return; + + setSubmitting(true); + const response = + pendingAction.type === 'add' + ? await api.post(`/v1/admin/organizations/${orgId}/frameworks`, { + frameworkIds: [pendingAction.framework.id], + }) + : await api.delete( + `/v1/admin/organizations/${orgId}/frameworks/${pendingAction.framework.id}`, + ); + + setSubmitting(false); + + if (response.error) { + toast.error(response.error); + return; + } + + toast.success( + pendingAction.type === 'add' + ? 'Framework added to organization' + : 'Framework removed from organization', + ); + setPendingAction(null); + await fetchFrameworks(); + }; + + const sortedFrameworks = [...frameworks].sort((a, b) => + getActiveFrameworkName(a).localeCompare(getActiveFrameworkName(b)), + ); + const sortedAvailableFrameworks = [...availableFrameworks].sort((a, b) => + a.name.localeCompare(b.name), + ); + + if (loading) { + return ( +
+ Loading frameworks... +
+ ); + } + + return ( +
+
+ {frameworks.length === 0 ? ( +
+ No frameworks have been added to this organization. +
+ ) : ( + <> + { + setPendingAction({ type: 'delete', framework }); + }} + /> +
+ + + + Name + Version + Type + Actions + + + + {sortedFrameworks.map((framework) => ( + { + setPendingAction({ + type: 'delete', + framework: selectedFramework, + }); + }} + /> + ))} + +
+
+ + )} +
+ +
+ {availableFrameworks.length === 0 ? ( +
+ No additional visible frameworks are available to add. +
+ ) : ( + <> + { + setPendingAction({ type: 'add', framework }); + }} + /> +
+ + + + Name + Version + Description + Actions + + + + {sortedAvailableFrameworks.map((framework) => ( + + + + {framework.name} + + + + v{framework.version} + + + + {framework.description ?? '--'} + + + + + + + ))} + +
+
+ + )} +
+ + +
+ ); +} + +function ActiveFrameworkRow({ + framework, + onDelete, +}: { + framework: ActiveFramework; + onDelete: (framework: ActiveFramework) => void; +}) { + const details = getActiveFrameworkDetails(framework); + + return ( + + +
+
+ + {details?.name ?? 'Unknown framework'} + +
+ {details?.description && ( +
+ + {details.description} + +
+ )} +
+
+ + v{details?.version ?? '--'} + + + + {framework.customFramework ? 'Custom' : 'Platform'} + + + + + +
+ ); +}