diff --git a/apps/api/src/admin-organizations/admin-policies.controller.ts b/apps/api/src/admin-organizations/admin-policies.controller.ts index 98caca9679..c3d56fa8e0 100644 --- a/apps/api/src/admin-organizations/admin-policies.controller.ts +++ b/apps/api/src/admin-organizations/admin-policies.controller.ts @@ -44,7 +44,7 @@ export class AdminPoliciesController { @Get(':orgId/policies') @ApiOperation({ summary: 'List all policies for an organization (admin)' }) async list(@Param('orgId') orgId: string) { - return this.policiesService.findAll(orgId); + return this.policiesService.findAll({ organizationId: orgId }); } @Post(':orgId/policies') diff --git a/apps/api/src/device-agent/device-registration.helpers.spec.ts b/apps/api/src/device-agent/device-registration.helpers.spec.ts new file mode 100644 index 0000000000..2e2f6a8862 --- /dev/null +++ b/apps/api/src/device-agent/device-registration.helpers.spec.ts @@ -0,0 +1,157 @@ +jest.mock('@db', () => ({ + db: { + device: { + findUnique: jest.fn(), + findFirst: jest.fn(), + update: jest.fn(), + create: jest.fn(), + }, + }, +})); + +import { db } from '@db'; +import { + registerWithSerial, + registerWithoutSerial, +} from './device-registration.helpers'; +import type { RegisterDeviceDto } from './dto/register-device.dto'; + +const mockDb = db as jest.Mocked; + +const orgId = 'org_test'; +const member = { id: 'mem_test' }; + +function makeDto( + overrides: Partial = {}, +): RegisterDeviceDto { + return { + organizationId: orgId, + hostname: 'my-laptop.local', + name: 'My Laptop', + platform: 'macos', + osVersion: '15.0', + serialNumber: 'ABC123', + hardwareModel: 'MacBookPro18,1', + agentVersion: '1.0.0', + ...overrides, + }; +} + +describe('registerWithSerial — orphan adoption', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('adopts an existing serial-less row for the same hostname+member instead of creating a duplicate', async () => { + // The bug scenario: agent first registered without a serial (e.g. cold- + // boot `system_profiler` returned empty), creating a row with + // serialNumber=null. A later registration succeeds in reading the + // serial. Without adoption, registerWithSerial would create a brand-new + // row and the old one would stay orphaned. + (mockDb.device.findUnique as jest.Mock).mockResolvedValue(null); + (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ + id: 'dev_orphan', + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ + id: 'dev_orphan', + }); + + const dto = makeDto(); + await registerWithSerial({ member, dto }); + + expect(mockDb.device.findFirst).toHaveBeenCalledWith({ + where: { + hostname: dto.hostname, + memberId: member.id, + organizationId: orgId, + serialNumber: null, + }, + select: { id: true }, + }); + expect(mockDb.device.update).toHaveBeenCalledWith({ + where: { id: 'dev_orphan' }, + data: expect.objectContaining({ + serialNumber: dto.serialNumber, + hostname: dto.hostname, + }), + }); + expect(mockDb.device.create).not.toHaveBeenCalled(); + }); + + it('creates a fresh row when no orphan exists', async () => { + (mockDb.device.findUnique as jest.Mock).mockResolvedValue(null); + (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.device.create as jest.Mock).mockResolvedValue({ id: 'dev_new' }); + + const dto = makeDto(); + await registerWithSerial({ member, dto }); + + expect(mockDb.device.update).not.toHaveBeenCalled(); + expect(mockDb.device.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + serialNumber: dto.serialNumber, + memberId: member.id, + organizationId: orgId, + }), + }); + }); + + it('updates the existing serial-match row without looking for an orphan', async () => { + // Plain re-registration of an already-known device — must not trigger + // the orphan lookup or do anything other than an in-place update. + (mockDb.device.findUnique as jest.Mock).mockResolvedValue({ + id: 'dev_existing', + memberId: member.id, + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ + id: 'dev_existing', + }); + + const dto = makeDto(); + await registerWithSerial({ member, dto }); + + expect(mockDb.device.findFirst).not.toHaveBeenCalled(); + expect(mockDb.device.create).not.toHaveBeenCalled(); + expect(mockDb.device.update).toHaveBeenCalledWith({ + where: { id: 'dev_existing' }, + data: expect.objectContaining({ hostname: dto.hostname }), + }); + }); + + it('only adopts an orphan that belongs to the same member', async () => { + // Safety: the orphan lookup is scoped by memberId, so another member's + // serial-less row for the same hostname must not be hijacked. + (mockDb.device.findUnique as jest.Mock).mockResolvedValue(null); + (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.device.create as jest.Mock).mockResolvedValue({ id: 'dev_new' }); + + const dto = makeDto(); + await registerWithSerial({ member, dto }); + + const call = (mockDb.device.findFirst as jest.Mock).mock.calls[0]?.[0]; + expect(call?.where.memberId).toBe(member.id); + expect(call?.where.serialNumber).toBeNull(); + }); +}); + +describe('registerWithoutSerial — unchanged behavior', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('updates the matching null-serial row when one exists', async () => { + (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ + id: 'dev_null', + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ id: 'dev_null' }); + + const dto = makeDto({ serialNumber: undefined }); + await registerWithoutSerial({ member, dto }); + + expect(mockDb.device.update).toHaveBeenCalledWith({ + where: { id: 'dev_null' }, + data: expect.any(Object), + }); + expect(mockDb.device.create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/device-agent/device-registration.helpers.ts b/apps/api/src/device-agent/device-registration.helpers.ts index e170c4e8b9..57c6e5ac57 100644 --- a/apps/api/src/device-agent/device-registration.helpers.ts +++ b/apps/api/src/device-agent/device-registration.helpers.ts @@ -46,6 +46,33 @@ export async function registerWithSerial({ }); } + // Adopt any prior serial-less registration for the same physical device + // before creating a new row. The agent's serial extraction can return + // undefined on a cold boot (e.g. macOS `system_profiler` cache not yet + // built) and a real value on a subsequent boot — without this, the second + // registration creates a duplicate while the first row stays orphaned and + // never receives another check-in (frozen at its old compliance state). + const orphan = await db.device.findFirst({ + where: { + hostname: dto.hostname, + memberId: member.id, + organizationId: dto.organizationId, + serialNumber: null, + }, + select: { id: true }, + }); + + if (orphan) { + return db.device.update({ + where: { id: orphan.id }, + data: { + ...updateData, + hostname: dto.hostname, + serialNumber: dto.serialNumber!, + }, + }); + } + return db.device.create({ data: { ...updateData, diff --git a/apps/api/src/openapi/operation-metadata.ts b/apps/api/src/openapi/operation-metadata.ts index f18d6971b8..b843ee0715 100644 --- a/apps/api/src/openapi/operation-metadata.ts +++ b/apps/api/src/openapi/operation-metadata.ts @@ -50,7 +50,7 @@ const CORE_OPERATION_METADATA: Record = { PoliciesController_getAllPolicies_v1: { summary: 'List compliance policies', description: - 'Lists compliance policies for the organization. Use this to find a policy by name, look up a policy ID, browse drafts, or get an overview of all policies for SOC 2, ISO 27001, HIPAA, and GDPR workflows. Returns id, name, status, department, and other metadata for each policy. Pass excludeContent=true to skip the heavy TipTap content fields — recommended when you only need to identify a policy. To read or edit a single policy in detail, fetch it by ID via get-compliance-policy.', + 'Lists active compliance policies by default. Use includeArchived=true to include archived rows and excludeContent=true when you only need policy metadata.', codeSamples: [ { lang: 'bash', @@ -64,6 +64,12 @@ const CORE_OPERATION_METADATA: Record = { source: 'curl --request GET --url "https://api.trycomp.ai/v1/policies?excludeContent=true" --header "X-API-Key: $COMP_AI_API_KEY"', }, + { + lang: 'bash', + label: 'List policies including archived', + source: + 'curl --request GET --url "https://api.trycomp.ai/v1/policies?includeArchived=true" --header "X-API-Key: $COMP_AI_API_KEY"', + }, ], }, PoliciesController_createPolicy_v1: { diff --git a/apps/api/src/policies/dto/policy-responses.dto.ts b/apps/api/src/policies/dto/policy-responses.dto.ts index 755d708cd2..74c05b18f1 100644 --- a/apps/api/src/policies/dto/policy-responses.dto.ts +++ b/apps/api/src/policies/dto/policy-responses.dto.ts @@ -95,6 +95,13 @@ export class PolicyResponseDto { }) isArchived: boolean; + @ApiProperty({ + description: 'When the policy was archived by framework sync', + example: '2024-02-01T00:00:00.000Z', + nullable: true, + }) + archivedAt?: Date; + @ApiProperty({ description: 'When the policy was created', example: '2024-01-01T00:00:00.000Z', diff --git a/apps/api/src/policies/policies.controller.spec.ts b/apps/api/src/policies/policies.controller.spec.ts index 408c632437..b9a5aa5998 100644 --- a/apps/api/src/policies/policies.controller.spec.ts +++ b/apps/api/src/policies/policies.controller.spec.ts @@ -25,6 +25,7 @@ jest.mock('@db', () => ({ db: { policy: { findFirst: jest.fn(), + findUnique: jest.fn(), update: jest.fn(), }, control: { @@ -43,6 +44,10 @@ jest.mock('@db', () => ({ findFirst: jest.fn(), update: jest.fn(), }, + frameworkControlPolicyLink: { + deleteMany: jest.fn(), + }, + $transaction: jest.fn(), }, Frequency: { monthly: 'monthly', @@ -152,8 +157,10 @@ describe('PoliciesController', () => { const result = await controller.getAllPolicies(orgId, mockAuthContext); - expect(policiesService.findAll).toHaveBeenCalledWith(orgId, { + expect(policiesService.findAll).toHaveBeenCalledWith({ + organizationId: orgId, excludeContent: false, + includeArchived: false, }); expect(result).toEqual({ data: mockPolicies, @@ -167,8 +174,10 @@ describe('PoliciesController', () => { await controller.getAllPolicies(orgId, mockAuthContext, 'true'); - expect(policiesService.findAll).toHaveBeenCalledWith(orgId, { + expect(policiesService.findAll).toHaveBeenCalledWith({ + organizationId: orgId, excludeContent: true, + includeArchived: false, }); }); @@ -177,8 +186,22 @@ describe('PoliciesController', () => { await controller.getAllPolicies(orgId, mockAuthContext, 'false'); - expect(policiesService.findAll).toHaveBeenCalledWith(orgId, { + expect(policiesService.findAll).toHaveBeenCalledWith({ + organizationId: orgId, + excludeContent: false, + includeArchived: false, + }); + }); + + it('should pass includeArchived=true to service when query param is "true"', async () => { + mockPoliciesService.findAll.mockResolvedValue([]); + + await controller.getAllPolicies(orgId, mockAuthContext, undefined, 'true'); + + expect(policiesService.findAll).toHaveBeenCalledWith({ + organizationId: orgId, excludeContent: false, + includeArchived: true, }); }); @@ -621,7 +644,19 @@ describe('PoliciesController', () => { describe('removePolicyControl', () => { it('should disconnect control from policy and return success', async () => { const { db } = require('@db'); + db.policy.findUnique.mockResolvedValue({ controls: [] }); db.policy.update.mockResolvedValue({}); + db.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise) => + callback({ + policy: { + findUnique: db.policy.findUnique, + update: db.policy.update, + }, + frameworkControlPolicyLink: { + deleteMany: db.frameworkControlPolicyLink.deleteMany, + }, + }), + ); const result = await controller.removePolicyControl( 'pol_1', diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index 5faab9b76f..7416cb8e7a 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -122,6 +122,13 @@ export class PoliciesController { description: 'When true, omits `content` and `draftContent` from each policy in the response. Use this when listing policies to find one by name/ID — fetch the full content via GET /v1/policies/{id} after.', }) + @ApiQuery({ + name: 'includeArchived', + required: false, + type: Boolean, + description: + 'When true, includes user-archived and framework-sync-archived policies in the response. Defaults to false.', + }) @ApiExtension('x-speakeasy-mcp', { name: 'list-policies' }) @ApiResponse(GET_ALL_POLICIES_RESPONSES[200]) @ApiResponse(GET_ALL_POLICIES_RESPONSES[401]) @@ -129,9 +136,12 @@ export class PoliciesController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, @Query('excludeContent') excludeContent?: string, + @Query('includeArchived') includeArchived?: string, ) { - const policies = await this.policiesService.findAll(organizationId, { + const policies = await this.policiesService.findAll({ + organizationId, excludeContent: excludeContent === 'true', + includeArchived: includeArchived === 'true', }); return { diff --git a/apps/api/src/policies/policies.service.spec.ts b/apps/api/src/policies/policies.service.spec.ts index ac8bd73243..218309eda8 100644 --- a/apps/api/src/policies/policies.service.spec.ts +++ b/apps/api/src/policies/policies.service.spec.ts @@ -140,7 +140,7 @@ describe('PoliciesService', () => { }); it('includes content and draftContent in the select by default', async () => { - await service.findAll(orgId); + await service.findAll({ organizationId: orgId }); const callArgs = db.policy.findMany.mock.calls[0][0]; expect(callArgs.select.content).toBe(true); @@ -148,7 +148,7 @@ describe('PoliciesService', () => { }); it('includes content and draftContent when excludeContent is false', async () => { - await service.findAll(orgId, { excludeContent: false }); + await service.findAll({ organizationId: orgId, excludeContent: false }); const callArgs = db.policy.findMany.mock.calls[0][0]; expect(callArgs.select.content).toBe(true); @@ -156,7 +156,7 @@ describe('PoliciesService', () => { }); it('omits content and draftContent from select when excludeContent is true', async () => { - await service.findAll(orgId, { excludeContent: true }); + await service.findAll({ organizationId: orgId, excludeContent: true }); const callArgs = db.policy.findMany.mock.calls[0][0]; expect(callArgs.select.content).toBeUndefined(); @@ -169,13 +169,22 @@ describe('PoliciesService', () => { }); it('scopes results to the organization regardless of excludeContent', async () => { - await service.findAll(orgId, { excludeContent: true }); + await service.findAll({ organizationId: orgId, excludeContent: true }); const callArgs = db.policy.findMany.mock.calls[0][0]; expect(callArgs.where.organizationId).toBe(orgId); expect(callArgs.where.isArchived).toBe(false); expect(callArgs.where.archivedAt).toBeNull(); }); + + it('includes archived policies when includeArchived is true', async () => { + await service.findAll({ organizationId: orgId, includeArchived: true }); + + const callArgs = db.policy.findMany.mock.calls[0][0]; + expect(callArgs.where).toEqual({ organizationId: orgId }); + expect(callArgs.select.archivedAt).toBe(true); + expect(callArgs.select.isArchived).toBe(true); + }); }); describe('updateById', () => { diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index f58db3170f..a7b0c2702a 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -43,6 +43,7 @@ const POLICY_UPDATE_SELECT = { signedBy: true, reviewDate: true, isArchived: true, + archivedAt: true, createdAt: true, updatedAt: true, lastArchivedAt: true, @@ -79,19 +80,26 @@ export class PoliciesService { private readonly timelinesService: TimelinesService, ) {} - async findAll( - organizationId: string, - options?: { excludeContent?: boolean }, - ) { + async findAll({ + organizationId, + excludeContent, + includeArchived, + }: { + organizationId: string; + excludeContent?: boolean; + includeArchived?: boolean; + }) { try { const policies = await db.policy.findMany({ - where: { organizationId, isArchived: false, archivedAt: null }, + where: includeArchived + ? { organizationId } + : { organizationId, isArchived: false, archivedAt: null }, select: { id: true, name: true, description: true, status: true, - ...(options?.excludeContent + ...(excludeContent ? {} : { content: true, draftContent: true }), frequency: true, @@ -100,6 +108,7 @@ export class PoliciesService { signedBy: true, reviewDate: true, isArchived: true, + archivedAt: true, createdAt: true, updatedAt: true, lastArchivedAt: true, @@ -128,7 +137,8 @@ export class PoliciesService { this.logger.log( `Retrieved ${policies.length} policies for organization ${organizationId}` + - (options?.excludeContent ? ' (content excluded)' : ''), + (excludeContent ? ' (content excluded)' : '') + + (includeArchived ? ' (archived included)' : ''), ); return policies; } catch (error) { @@ -240,6 +250,7 @@ export class PoliciesService { signedBy: true, reviewDate: true, isArchived: true, + archivedAt: true, createdAt: true, updatedAt: true, lastArchivedAt: true, @@ -338,6 +349,7 @@ export class PoliciesService { signedBy: true, reviewDate: true, isArchived: true, + archivedAt: true, createdAt: true, updatedAt: true, lastArchivedAt: true, diff --git a/apps/api/src/policies/schemas/get-all-policies.responses.ts b/apps/api/src/policies/schemas/get-all-policies.responses.ts index c322decaf2..2e9a0a06c8 100644 --- a/apps/api/src/policies/schemas/get-all-policies.responses.ts +++ b/apps/api/src/policies/schemas/get-all-policies.responses.ts @@ -65,6 +65,7 @@ export const GET_ALL_POLICIES_RESPONSES: Record = { signedBy: [], reviewDate: '2024-12-31T00:00:00.000Z', isArchived: false, + archivedAt: null, createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-15T00:00:00.000Z', lastArchivedAt: null, diff --git a/apps/api/src/policies/schemas/policy-operations.ts b/apps/api/src/policies/schemas/policy-operations.ts index 1ca19caf39..481f26ea56 100644 --- a/apps/api/src/policies/schemas/policy-operations.ts +++ b/apps/api/src/policies/schemas/policy-operations.ts @@ -4,7 +4,7 @@ export const POLICY_OPERATIONS: Record = { getAllPolicies: { summary: 'Get all policies', description: - 'Lists all policies. Pass excludeContent=true to skip the heavy content fields (recommended unless you need every policy fully). Fetch one policy via get-policy by ID when you need the full content to edit.', + 'Lists active policies by default. Pass includeArchived=true to include archived rows and excludeContent=true to skip heavy content fields. Fetch one policy by ID for full content.', }, getPolicyById: { summary: 'Get policy by ID', diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.test.tsx new file mode 100644 index 0000000000..75b182b63c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { EditableSOAFields } from './EditableSOAFields'; + +vi.mock('../hooks/useSOADocument', () => ({ + useSOADocument: () => ({ + saveAnswer: vi.fn(), + }), +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +describe('EditableSOAFields', () => { + it('shows the edit action without requiring hover', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: 'Edit answer' })).toHaveClass('opacity-100'); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx index aa2dc5d144..d067f26b54 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx @@ -1,24 +1,22 @@ 'use client'; import { useState, useEffect, useRef } from 'react'; -import { Button } from '@trycompai/design-system'; -import { Textarea } from '@trycompai/ui/textarea'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@trycompai/ui/select'; import { + Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, -} from '@trycompai/ui/dialog'; -import { X, Loader2, Edit2 } from 'lucide-react'; + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Textarea, +} from '@trycompai/design-system'; +import { Close, Edit } from '@trycompai/design-system/icons'; import { toast } from 'sonner'; import { useSOADocument } from '../hooks/useSOADocument'; import { ApplicableReadOnlyDisplay, ApplicableSwatchRow } from './ApplicableSwatch'; @@ -128,7 +126,11 @@ export function EditableSOAFields({ setIsEditing(true); }; - const handleSelectChange = (value: 'yes' | 'no' | 'null') => { + const handleSelectChange = (value: string | null) => { + if (value !== 'yes' && value !== 'no' && value !== 'null') { + return; + } + const newValue = value === 'yes' ? true : value === 'no' ? false : null; setIsApplicable(newValue); setError(null); @@ -191,10 +193,11 @@ export function EditableSOAFields({ ); @@ -203,25 +206,27 @@ export function EditableSOAFields({ return (
- +
+ +
@@ -250,13 +255,14 @@ export function EditableSOAFields({ setError(null); }} placeholder="Enter justification (required)" - className="min-h-[120px]" + rows={5} + size="full" required /> {error && (

{error}

)} - + diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx index dc0c3383d2..47eb2096c0 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx @@ -22,6 +22,12 @@ import { useParams, useRouter } from 'next/navigation'; import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; import { useCallback, useMemo, useState } from 'react'; import { FamilyFilterDropdown } from './FamilyFilterDropdown'; +import { + areAllFamiliesExpanded, + isFamilyExpanded, + toggleAllFamilyExpansion, + toggleFamilyExpansion, +} from './family-expansion-state'; import { buildControlItems, buildRequirementMap, @@ -57,7 +63,7 @@ export function FrameworkControlsGrouped({ const [searchTerm, setSearchTerm] = useQueryState('q', parseAsString.withDefault('').withOptions({ shallow: true, throttleMs: 300 })); const [familyFilterParam, setFamilyFilterParam] = useQueryState('families', parseAsArrayOf(parseAsString, '|').withDefault([]).withOptions({ shallow: true })); - const [collapsedFamilies, setCollapsedFamilies] = useState>(new Set()); + const [expandedFamilies, setExpandedFamilies] = useState>(new Set()); const selectedFamilyFilter = useMemo(() => new Set(familyFilterParam), [familyFilterParam]); @@ -95,36 +101,26 @@ export function FrameworkControlsGrouped({ const familyCounts = useMemo(() => new Map(allGroups.map((g) => [g.family, g.items.length])), [allGroups]); const isSearching = searchTerm.trim().length > 0; - const allCollapsed = groups.length > 0 && groups.every((g) => collapsedFamilies.has(g.family)); + const visibleFamilyNames = useMemo(() => groups.map((g) => g.family), [groups]); + const allExpanded = areAllFamiliesExpanded({ + expandedFamilies, + familyNames: visibleFamilyNames, + }); const handleToggleFamily = (family: string) => { - setCollapsedFamilies((prev) => { - const next = new Set(prev); - if (next.has(family)) { - next.delete(family); - } else { - next.add(family); - } - return next; - }); + setExpandedFamilies((prev) => + toggleFamilyExpansion({ expandedFamilies: prev, family }), + ); }; - const visibleFamilyNames = useMemo(() => groups.map((g) => g.family), [groups]); - const handleToggleAll = () => { - if (allCollapsed) { - setCollapsedFamilies((prev) => { - const next = new Set(prev); - for (const name of visibleFamilyNames) next.delete(name); - return next; - }); - } else { - setCollapsedFamilies((prev) => { - const next = new Set(prev); - for (const name of visibleFamilyNames) next.add(name); - return next; - }); - } + setExpandedFamilies((prev) => + toggleAllFamilyExpansion({ + expandedFamilies: prev, + familyNames: visibleFamilyNames, + shouldExpand: !allExpanded, + }), + ); }; const handleSearchChange = (e: React.ChangeEvent) => { @@ -145,7 +141,8 @@ export function FrameworkControlsGrouped({ setFamilyFilterParam(null); }; - const isFamilyExpanded = (family: string) => isSearching || !collapsedFamilies.has(family); + const getIsFamilyExpanded = (family: string) => + isFamilyExpanded({ expandedFamilies, family, isSearching }); return (
@@ -172,7 +169,7 @@ export function FrameworkControlsGrouped({ /> {!isSearching && ( )}
@@ -202,7 +199,7 @@ export function FrameworkControlsGrouped({ handleToggleFamily(group.family)} tasks={tasks} evidenceSubmissions={evidenceSubmissions} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx index 6809df96b7..04bdbb62ab 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx @@ -1,36 +1,39 @@ 'use client'; +import type { StatusType } from '@/components/status-indicator'; +import { + type EvidenceSubmissionInfo, + type RequirementArtifactCounts, + getControlProgressPercent, + getControlStatus, + getRequirementArtifactCounts, + getRequirementCompliancePercent, + getRequirementStatus, +} from '@/lib/control-compliance'; +import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; import type { Control, FrameworkEditorRequirement, Task } from '@db'; import { Badge, - InputGroup, InputGroupAddon, InputGroupInput, Table, TableBody, TableCell, - TableHead, - TableHeader, TableRow, Text, } from '@trycompai/design-system'; import { Search } from '@trycompai/design-system/icons'; import { useParams, useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; +import { + REQUIREMENTS_TABLE_COLUMN_COUNT, + REQUIREMENTS_TABLE_STYLE, + RequirementsTableColumnGroup, + RequirementsTableHeader, +} from './requirements-table-layout'; const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; -import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; -import { - type EvidenceSubmissionInfo, - type RequirementArtifactCounts, - getControlProgressPercent, - getControlStatus, - getRequirementArtifactCounts, - getRequirementCompliancePercent, - getRequirementStatus, -} from '@/lib/control-compliance'; -import type { StatusType } from '@/components/status-indicator'; interface RequirementItem extends FrameworkEditorRequirement { mappedControlsCount: number; @@ -109,9 +112,10 @@ export function FrameworkRequirements({ }, [requirementDefinitions, frameworkInstanceWithControls.controls, tasks, evidenceSubmissions]); const sortedItems = useMemo( - () => [...items].sort((a, b) => - (a.identifier ?? '').localeCompare(b.identifier ?? '', undefined, { numeric: true }), - ), + () => + [...items].sort((a, b) => + (a.identifier ?? '').localeCompare(b.identifier ?? '', undefined, { numeric: true }), + ), [items], ); @@ -151,12 +155,15 @@ export function FrameworkRequirements({ ) => setSearchTerm(event.target.value)} + onChange={(event: React.ChangeEvent) => + setSearchTerm(event.target.value) + } />
- - - Identifier - Name - Description - Compliance - Status - Controls - Policies - Tasks - Documents - - + + {paginatedItems.length === 0 ? ( - + No requirements found. @@ -214,24 +210,18 @@ export function FrameworkRequirements({ {identifier || '—'} - + {item.name} - + {item.description || '—'} -
-
+
+
- {item.artifactCounts.policies.completed}/{item.artifactCounts.policies.total} + {item.artifactCounts.policies.completed}/ + {item.artifactCounts.policies.total}
@@ -271,7 +262,8 @@ export function FrameworkRequirements({
- {item.artifactCounts.documents.completed}/{item.artifactCounts.documents.total} + {item.artifactCounts.documents.completed}/ + {item.artifactCounts.documents.total}
diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirementsGrouped.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirementsGrouped.tsx index facf74d3c3..ac10dafb9b 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirementsGrouped.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirementsGrouped.tsx @@ -5,15 +5,12 @@ import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; import type { Control, FrameworkEditorRequirement, Task } from '@db'; import { Button, - Heading, InputGroup, InputGroupAddon, InputGroupInput, Table, TableBody, TableCell, - TableHead, - TableHeader, TableRow, Text, } from '@trycompai/design-system'; @@ -21,6 +18,12 @@ import { ChevronDown, ChevronRight, Search } from '@trycompai/design-system/icon import { useParams, useRouter } from 'next/navigation'; import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; import { useCallback, useMemo, useState } from 'react'; +import { + areAllFamiliesExpanded, + isFamilyExpanded, + toggleAllFamilyExpansion, + toggleFamilyExpansion, +} from './family-expansion-state'; import { FamilyFilterDropdown } from './FamilyFilterDropdown'; import { buildRequirementItems, @@ -29,8 +32,12 @@ import { type RequirementFamilyGroup, } from './framework-controls-shared'; import { GroupedRequirementRow } from './GroupedRequirementRow'; - -const COLUMN_COUNT = 9; +import { + REQUIREMENTS_TABLE_COLUMN_COUNT, + REQUIREMENTS_TABLE_STYLE, + RequirementsTableColumnGroup, + RequirementsTableHeader, +} from './requirements-table-layout'; export function FrameworkRequirementsGrouped({ requirementDefinitions, @@ -43,7 +50,10 @@ export function FrameworkRequirementsGrouped({ tasks?: (Task & { controls: Control[] })[]; evidenceSubmissions?: EvidenceSubmissionInfo[]; }) { - const { orgId, frameworkInstanceId } = useParams<{ orgId: string; frameworkInstanceId: string }>(); + const { orgId, frameworkInstanceId } = useParams<{ + orgId: string; + frameworkInstanceId: string; + }>(); const router = useRouter(); const handleRowClick = useCallback( @@ -53,19 +63,26 @@ export function FrameworkRequirementsGrouped({ [orgId, frameworkInstanceId, router], ); - const [searchTerm, setSearchTerm] = useQueryState('rq', parseAsString.withDefault('').withOptions({ shallow: true, throttleMs: 300 })); - const [familyFilterParam, setFamilyFilterParam] = useQueryState('rfamilies', parseAsArrayOf(parseAsString, '|').withDefault([]).withOptions({ shallow: true })); - const [collapsedFamilies, setCollapsedFamilies] = useState>(new Set()); + const [searchTerm, setSearchTerm] = useQueryState( + 'rq', + parseAsString.withDefault('').withOptions({ shallow: true, throttleMs: 300 }), + ); + const [familyFilterParam, setFamilyFilterParam] = useQueryState( + 'rfamilies', + parseAsArrayOf(parseAsString, '|').withDefault([]).withOptions({ shallow: true }), + ); + const [expandedFamilies, setExpandedFamilies] = useState>(new Set()); const selectedFamilyFilter = useMemo(() => new Set(familyFilterParam), [familyFilterParam]); const allItems = useMemo( - () => buildRequirementItems( - requirementDefinitions, - frameworkInstanceWithControls.controls, - tasks ?? [], - evidenceSubmissions, - ), + () => + buildRequirementItems( + requirementDefinitions, + frameworkInstanceWithControls.controls, + tasks ?? [], + evidenceSubmissions, + ), [requirementDefinitions, frameworkInstanceWithControls.controls, tasks, evidenceSubmissions], ); @@ -88,23 +105,30 @@ export function FrameworkRequirementsGrouped({ }, [allGroups, selectedFamilyFilter]); const allFamilyNames = useMemo(() => allGroups.map((g) => g.family), [allGroups]); - const familyCounts = useMemo(() => new Map(allGroups.map((g) => [g.family, g.items.length])), [allGroups]); + const familyCounts = useMemo( + () => new Map(allGroups.map((g) => [g.family, g.items.length])), + [allGroups], + ); const isSearching = searchTerm.trim().length > 0; - const allCollapsed = groups.length > 0 && groups.every((g) => collapsedFamilies.has(g.family)); + const visibleFamilyNames = useMemo(() => groups.map((g) => g.family), [groups]); + const allExpanded = areAllFamiliesExpanded({ + expandedFamilies, + familyNames: visibleFamilyNames, + }); const handleToggleFamily = (family: string) => { - setCollapsedFamilies((prev) => { - const next = new Set(prev); - if (next.has(family)) next.delete(family); - else next.add(family); - return next; - }); + setExpandedFamilies((prev) => toggleFamilyExpansion({ expandedFamilies: prev, family })); }; const handleToggleAll = () => { - if (allCollapsed) setCollapsedFamilies(new Set()); - else setCollapsedFamilies(new Set(allFamilyNames)); + setExpandedFamilies((prev) => + toggleAllFamilyExpansion({ + expandedFamilies: prev, + familyNames: visibleFamilyNames, + shouldExpand: !allExpanded, + }), + ); }; const handleSearchChange = (e: React.ChangeEvent) => { @@ -122,11 +146,11 @@ export function FrameworkRequirementsGrouped({ setFamilyFilterParam(null); }; - const isFamilyExpanded = (family: string) => isSearching || !collapsedFamilies.has(family); + const getIsFamilyExpanded = (family: string) => + isFamilyExpanded({ expandedFamilies, family, isSearching }); return (
- Requirements ({filteredItems.length})
@@ -149,28 +173,17 @@ export function FrameworkRequirementsGrouped({ /> {!isSearching && ( )}
-
- - - Identifier - Name - Description - Compliance - Status - Controls - Policies - Tasks - Documents - - +
+ + {groups.length === 0 ? ( - + No requirements found. @@ -181,7 +194,7 @@ export function FrameworkRequirementsGrouped({ handleToggleFamily(group.family)} orgId={orgId} frameworkInstanceId={frameworkInstanceId} @@ -215,7 +228,7 @@ function RequirementFamilySection({ return ( <> - +
+ + +
, + ); + + expect(container.querySelectorAll('col')).toHaveLength(REQUIREMENTS_TABLE_COLUMN_COUNT); + expect(screen.getByRole('columnheader', { name: 'Identifier' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Description' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Controls' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Compliance' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Status' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Docs' })).toHaveAttribute( + 'title', + 'Documents', + ); + expect(REQUIREMENTS_TABLE_STYLE).toMatchObject({ tableLayout: 'fixed' }); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/requirements-table-layout.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/requirements-table-layout.tsx new file mode 100644 index 0000000000..21a7a1df84 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/requirements-table-layout.tsx @@ -0,0 +1,55 @@ +import { TableHead, TableHeader, TableRow } from '@trycompai/design-system'; +import type { CSSProperties } from 'react'; + +interface RequirementsTableColumn { + id: string; + label: string; + title?: string; + width: string; +} + +const REQUIREMENTS_TABLE_COLUMNS = [ + { id: 'identifier', label: 'Identifier', width: '10%' }, + { id: 'name', label: 'Name', width: '19%' }, + { id: 'description', label: 'Description', width: '22%' }, + { id: 'compliance', label: 'Compliance', width: '13%' }, + { id: 'status', label: 'Status', width: '11%' }, + { id: 'controls', label: 'Controls', width: '7%' }, + { id: 'policies', label: 'Policies', width: '6.5%' }, + { id: 'tasks', label: 'Tasks', width: '5.5%' }, + { id: 'documents', label: 'Docs', title: 'Documents', width: '6%' }, +] as const satisfies readonly RequirementsTableColumn[]; + +export const REQUIREMENTS_TABLE_COLUMN_COUNT = REQUIREMENTS_TABLE_COLUMNS.length; + +export const REQUIREMENTS_TABLE_STYLE: CSSProperties = { + tableLayout: 'fixed', +}; + +export function RequirementsTableColumnGroup() { + return ( + + {REQUIREMENTS_TABLE_COLUMNS.map((column) => ( + + ))} + + ); +} + +export function RequirementsTableHeader() { + return ( + + + {REQUIREMENTS_TABLE_COLUMNS.map((column) => ( + + {column.label} + + ))} + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx b/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx index 05092eafde..f7457e2abb 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx +++ b/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx @@ -38,6 +38,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { CATEGORIES, type Integration, type IntegrationCategory } from '../data/integrations'; +import { matchesIntegrationNameSearch } from './integration-search'; import { SearchInput } from './SearchInput'; import { TaskCard, TaskCardSkeleton } from './TaskCard'; @@ -288,18 +289,12 @@ export function PlatformIntegrations({ className, taskTemplates }: PlatformInteg // Search filter if (searchQuery.trim()) { - const query = searchQuery.toLowerCase().trim(); - const terms = query.split(' ').filter(Boolean); + const query = searchQuery.trim(); filtered = filtered.filter((item) => { - if (item.type === 'platform') { - const searchText = - `${item.provider.name} ${item.provider.description} ${item.provider.category}`.toLowerCase(); - return terms.every((term) => searchText.includes(term)); - } - const searchText = - `${item.integration.name} ${item.integration.description} ${item.integration.category} ${item.integration.examplePrompts.join(' ')}`.toLowerCase(); - return terms.every((term) => searchText.includes(term)); + const name = item.type === 'platform' ? item.provider.name : item.integration.name; + + return matchesIntegrationNameSearch(name, query); }); } diff --git a/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.test.ts b/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.test.ts new file mode 100644 index 0000000000..3e94b8dfbd --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { matchesIntegrationNameSearch } from './integration-search'; + +describe('matchesIntegrationNameSearch', () => { + it('matches integration names', () => { + expect(matchesIntegrationNameSearch('GitHub', 'git')).toBe(true); + expect(matchesIntegrationNameSearch('Google Workspace', 'workspace')).toBe(true); + }); + + it('does not match terms that only appear in descriptions', () => { + expect(matchesIntegrationNameSearch('Datto', 'sast')).toBe(false); + }); + + it('requires every search term to match the name', () => { + expect(matchesIntegrationNameSearch('Google Workspace', 'google work')).toBe(true); + expect(matchesIntegrationNameSearch('Google Workspace', 'google backup')).toBe(false); + }); + + it('handles punctuation in name searches', () => { + expect(matchesIntegrationNameSearch('SOC 2', 'soc-2')).toBe(true); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.ts b/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.ts new file mode 100644 index 0000000000..7c97a0b31b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.ts @@ -0,0 +1,14 @@ +const normalizeSearchText = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); + +export function matchesIntegrationNameSearch(name: string, searchQuery: string): boolean { + const terms = searchQuery.toLowerCase().match(/[a-z0-9]+/g) ?? []; + if (terms.length === 0) return true; + + const normalizedName = normalizeSearchText(name); + + return terms.every((term) => normalizedName.includes(term)); +} diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/ConfirmActionDialog.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/ConfirmActionDialog.tsx deleted file mode 100644 index b57ab3820e..0000000000 --- a/apps/app/src/app/(app)/[orgId]/overview/components/ConfirmActionDialog.tsx +++ /dev/null @@ -1,59 +0,0 @@ -'use client'; - -import { Button } from '@trycompai/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@trycompai/ui/dialog'; - -interface ConfirmActionDialogProps { - isOpen: boolean; - onClose: () => void; - onConfirm: () => void; - title: string; - description: string; - confirmText?: string; - cancelText?: string; - variant?: 'default' | 'destructive'; - isLoading?: boolean; -} - -export function ConfirmActionDialog({ - isOpen, - onClose, - onConfirm, - title, - description, - confirmText = 'Confirm', - cancelText = 'Cancel', - variant = 'default', - isLoading = false, -}: ConfirmActionDialogProps) { - const handleConfirm = () => { - onConfirm(); - onClose(); - }; - - return ( - - - - {title} - {description} - - - - - - - - ); -} diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.test.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.test.tsx index 02413cab70..4f7ac451e8 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.test.tsx @@ -1,12 +1,13 @@ -import { render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; import { - setMockPermissions, ADMIN_PERMISSIONS, AUDITOR_PERMISSIONS, NO_PERMISSIONS, mockHasPermission, + setMockPermissions, } from '@/test-utils/mocks/permissions'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@/hooks/use-permissions', () => ({ usePermissions: () => ({ @@ -27,6 +28,15 @@ vi.mock('next/link', () => ({ default: ({ children, href }: any) => {children}, })); +const { mockRefresh } = vi.hoisted(() => ({ + mockRefresh: vi.fn(), +})); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + refresh: mockRefresh, + }), +})); + vi.mock('@trycompai/ui/button', () => ({ Button: ({ children, disabled, asChild, ...props }: any) => ( + + ) : null, })); vi.mock('lucide-react', () => ({ @@ -89,12 +114,14 @@ const mockUnpublishedPolicies = [ name: 'Security Policy', status: 'draft', organizationId: 'org-1', + signedBy: ['user-1', 'user-2'], }, { id: 'pol-2', name: 'Privacy Policy', status: 'draft', organizationId: 'org-1', + signedBy: [], }, ] as any[]; @@ -123,6 +150,14 @@ const defaultProps = { describe('ToDoOverview', () => { beforeEach(() => { vi.clearAllMocks(); + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve({ + ok: true, + }), + ), + ); }); describe('Permission gating', () => { @@ -131,9 +166,7 @@ describe('ToDoOverview', () => { render(); - expect( - screen.getByRole('button', { name: /publish all policies/i }), - ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /publish all policies/i })).toBeInTheDocument(); }); it('hides "Publish All Policies" button when user lacks policy:update permission (auditor)', () => { @@ -188,37 +221,21 @@ describe('ToDoOverview', () => { render(); - expect( - screen.getByText('Complete SOC 2 audit'), - ).toBeInTheDocument(); + expect(screen.getByText('Complete SOC 2 audit')).toBeInTheDocument(); }); it('shows "All policies are published!" when no unpublished policies exist', () => { setMockPermissions(ADMIN_PERMISSIONS); - render( - , - ); + render(); - expect( - screen.getByText('All policies are published!'), - ).toBeInTheDocument(); + expect(screen.getByText('All policies are published!')).toBeInTheDocument(); }); it('does not show publish button even with permissions when no unpublished policies', () => { setMockPermissions(ADMIN_PERMISSIONS); - render( - , - ); + render(); expect( screen.queryByRole('button', { name: /publish all policies/i }), @@ -232,9 +249,47 @@ describe('ToDoOverview', () => { expect(screen.getByTestId('tab-trigger-policies')).toBeInTheDocument(); expect(screen.getByTestId('tab-trigger-tasks')).toBeInTheDocument(); - expect( - screen.getByTestId('tab-trigger-offboarding'), - ).toBeInTheDocument(); + expect(screen.getByTestId('tab-trigger-offboarding')).toBeInTheDocument(); + }); + + it('shows acknowledgment warning before publishing policies with existing acknowledgments', async () => { + const user = userEvent.setup(); + setMockPermissions(ADMIN_PERMISSIONS); + + render(); + + await user.click(screen.getByRole('button', { name: /publish all policies/i })); + + expect(screen.getByTestId('acknowledgment-dialog')).toHaveTextContent('2'); + expect(fetch).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: /publish and invalidate/i })); + + expect(fetch).toHaveBeenCalledWith('/api/policies/publish-all', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }); + }); + + it('publishes immediately when no acknowledgments will be invalidated', async () => { + const user = userEvent.setup(); + setMockPermissions(ADMIN_PERMISSIONS); + + render( + ({ + ...policy, + signedBy: [], + }))} + />, + ); + + await user.click(screen.getByRole('button', { name: /publish all policies/i })); + + expect(screen.queryByTestId('acknowledgment-dialog')).not.toBeInTheDocument(); + expect(fetch).toHaveBeenCalledOnce(); }); }); }); diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.tsx index ccb6621184..4a3b5602a0 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.tsx @@ -1,11 +1,12 @@ 'use client'; import { useApiSWR } from '@/hooks/use-api-swr'; +import { usePermissions } from '@/hooks/use-permissions'; +import { Policy, Task } from '@db'; import { Button } from '@trycompai/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@trycompai/ui/card'; import { ScrollArea } from '@trycompai/ui/scroll-area'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@trycompai/ui/tabs'; -import { Policy, Task } from '@db'; import { ArrowRight, CheckCircle2, @@ -16,25 +17,14 @@ import { Upload, UserMinus, } from 'lucide-react'; -import { usePermissions } from '@/hooks/use-permissions'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { useEffect, useMemo, useState } from 'react'; -import { toast } from 'sonner'; -import { ConfirmActionDialog } from './ConfirmActionDialog'; - -interface PendingOffboardingMember { - memberId: string; - name: string; - email: string; - offboardDate: string; - completedItems: number; - totalItems: number; -} - -interface PendingOffboardingResponse { - members: PendingOffboardingMember[]; -} +import { useEffect, useState } from 'react'; +import { + formatQuickActionStatus, + getQuickActionProgressWidth, + type PendingOffboardingResponse, + usePublishAllPoliciesAction, +} from './overview-quick-actions'; export function ToDoOverview({ totalPolicies, @@ -60,8 +50,6 @@ export function ToDoOverview({ onboardingTriggerJobId: string | null; }) { const { hasPermission } = usePermissions(); - const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); const [activeTab, setActiveTab] = useState( unpublishedPolicies.length === 0 ? 'tasks' : 'policies', ); @@ -81,57 +69,23 @@ export function ToDoOverview({ const isOnboardingInProgress = !!onboardingTriggerJobId; - const formatStatus = (status: string) => { - return status.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase()); - }; - - const router = useRouter(); const canPublishPolicies = hasPermission('policy', 'update'); - - const handleConfirmAction = async () => { - setIsLoading(true); - try { - const response = await fetch('/api/policies/publish-all', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - }); - - if (!response.ok) { - throw new Error('Failed to publish policies'); - } - - toast.success('All policies published!'); - router.refresh(); - } catch { - toast.error('Failed to publish policies.'); - } finally { - setIsLoading(false); - } - }; - - const width = useMemo(() => { - return totalPolicies + totalTasks === 0 - ? 0 - : ((totalPolicies + - totalTasks - - (unpublishedPolicies.length + incompleteTasks.length)) / - (totalPolicies + totalTasks)) * - 100; - }, [ + const { handlePublishAllClick, isPublishing, publishAllPoliciesDialog } = + usePublishAllPoliciesAction({ + unpublishedPolicies, + }); + const width = getQuickActionProgressWidth({ totalPolicies, totalTasks, - unpublishedPolicies.length, - incompleteTasks.length, - ]); + unpublishedPolicies: unpublishedPolicies.length, + incompleteTasks: incompleteTasks.length, + }); return (
- - {'Quick Actions'} - + {'Quick Actions'}
@@ -146,10 +100,7 @@ export function ToDoOverview({ - + Policies ({remainingPolicies}) @@ -157,10 +108,7 @@ export function ToDoOverview({ Tasks ({remainingTasks}) - + Offboarding ({pendingOffboardings.length}) @@ -172,19 +120,15 @@ export function ToDoOverview({
)} @@ -210,14 +154,12 @@ export function ToDoOverview({ {policy.name} - Status: {formatStatus(policy.status)} + Status: {formatQuickActionStatus(policy.status)} @@ -237,9 +179,7 @@ export function ToDoOverview({ {incompleteTasks.length === 0 ? (
- - All tasks are completed! - + All tasks are completed!
) : (
@@ -257,14 +197,12 @@ export function ToDoOverview({ {task.title} - Status: {formatStatus(task.status)} + Status: {formatQuickActionStatus(task.status)}
@@ -283,22 +221,16 @@ export function ToDoOverview({ {isPendingLoading ? (
- - Loading offboardings... - + Loading offboardings...
) : pendingError ? (
- - Failed to load offboardings - + Failed to load offboardings
) : pendingOffboardings.length === 0 ? (
- - No pending offboardings - + No pending offboardings
) : (
@@ -316,8 +248,7 @@ export function ToDoOverview({ Complete offboarding for {member.name} - {member.completedItems}/{member.totalItems}{' '} - tasks done + {member.completedItems}/{member.totalItems} tasks done
@@ -342,16 +273,7 @@ export function ToDoOverview({ - setIsConfirmDialogOpen(false)} - onConfirm={handleConfirmAction} - title="Are you sure you want to publish all policies?" - description="This will automatically publish all policies that are in draft status. This action cannot be undone." - confirmText="Publish Policies" - cancelText="Cancel" - isLoading={isLoading} - /> + {publishAllPoliciesDialog}
); } diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/overview-quick-actions.test.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/overview-quick-actions.test.tsx new file mode 100644 index 0000000000..3a8e443367 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/components/overview-quick-actions.test.tsx @@ -0,0 +1,68 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { usePublishAllPoliciesAction } from './overview-quick-actions'; + +const { mockRefresh } = vi.hoisted(() => ({ + mockRefresh: vi.fn(), +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + refresh: mockRefresh, + }), +})); + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock('@/components/policies/PolicyAcknowledgmentInvalidationDialog', () => ({ + getPolicyAcknowledgmentTotal: (policies: Array<{ signedBy?: string[] }>) => + policies.reduce((total, policy) => total + (policy.signedBy?.length ?? 0), 0), + PolicyAcknowledgmentInvalidationDialog: () => null, +})); + +function PublishAllButton() { + const { handlePublishAllClick, isPublishing } = usePublishAllPoliciesAction({ + unpublishedPolicies: [{ signedBy: [] }], + }); + + return ( + + ); +} + +describe('usePublishAllPoliciesAction', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('blocks duplicate no-warning publishes while the request is in flight', async () => { + const user = userEvent.setup(); + let resolveFetch: () => void = () => {}; + const fetchPromise = new Promise((resolve) => { + resolveFetch = () => resolve(new Response(null, { status: 200 })); + }); + const fetchMock = vi.fn(() => fetchPromise); + vi.stubGlobal('fetch', fetchMock); + + render(); + + const publishButton = screen.getByRole('button', { name: /publish all policies/i }); + await user.dblClick(publishButton); + + expect(fetchMock).toHaveBeenCalledOnce(); + await waitFor(() => expect(publishButton).toBeDisabled()); + + resolveFetch(); + + await waitFor(() => expect(mockRefresh).toHaveBeenCalledOnce()); + await waitFor(() => expect(publishButton).not.toBeDisabled()); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/overview-quick-actions.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/overview-quick-actions.tsx new file mode 100644 index 0000000000..3cdaa37533 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/components/overview-quick-actions.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { + getPolicyAcknowledgmentTotal, + PolicyAcknowledgmentInvalidationDialog, +} from '@/components/policies/PolicyAcknowledgmentInvalidationDialog'; +import type { Policy } from '@db'; +import { useRouter } from 'next/navigation'; +import { useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; + +export interface PendingOffboardingMember { + memberId: string; + name: string; + email: string; + offboardDate: string; + completedItems: number; + totalItems: number; +} + +export interface PendingOffboardingResponse { + members: PendingOffboardingMember[]; +} + +type PublishablePolicy = Pick; + +export function formatQuickActionStatus(status: string) { + return status.replace('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +export function getQuickActionProgressWidth({ + totalPolicies, + totalTasks, + unpublishedPolicies, + incompleteTasks, +}: { + totalPolicies: number; + totalTasks: number; + unpublishedPolicies: number; + incompleteTasks: number; +}) { + if (totalPolicies + totalTasks === 0) { + return 0; + } + + return ( + ((totalPolicies + totalTasks - (unpublishedPolicies + incompleteTasks)) / + (totalPolicies + totalTasks)) * + 100 + ); +} + +export function usePublishAllPoliciesAction({ + unpublishedPolicies, +}: { + unpublishedPolicies: PublishablePolicy[]; +}) { + const router = useRouter(); + const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const isPublishingRef = useRef(false); + const bulkAcknowledgmentInvalidations = useMemo( + () => getPolicyAcknowledgmentTotal(unpublishedPolicies), + [unpublishedPolicies], + ); + + const handlePublishAllPolicies = async () => { + if (isPublishingRef.current) { + return; + } + + isPublishingRef.current = true; + setIsLoading(true); + try { + const response = await fetch('/api/policies/publish-all', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }); + + if (!response.ok) { + throw new Error('Failed to publish policies'); + } + + toast.success('All policies published!'); + setIsConfirmDialogOpen(false); + router.refresh(); + } catch { + toast.error('Failed to publish policies.'); + } finally { + isPublishingRef.current = false; + setIsLoading(false); + } + }; + + const handlePublishAllClick = () => { + if (isPublishingRef.current) { + return; + } + + if (bulkAcknowledgmentInvalidations === 0) { + void handlePublishAllPolicies(); + return; + } + + setIsConfirmDialogOpen(true); + }; + + const publishAllPoliciesDialog = ( + void handlePublishAllPolicies()} + onOpenChange={setIsConfirmDialogOpen} + open={isConfirmDialogOpen} + /> + ); + + return { + handlePublishAllClick, + isPublishing: isLoading, + publishAllPoliciesDialog, + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx index 5cb8463ebd..1026a33b74 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx @@ -149,6 +149,7 @@ export function MemberRow({ const currentRoles = parseRoles(member.role); const isOwner = currentRoles.includes('owner'); + const isAuditorOnly = currentRoles.length > 0 && currentRoles.every((role) => role === 'auditor'); const isPlatformAdmin = member.user.role === 'admin'; const canRemove = !isOwner; const isDeactivated = member.deactivated || !member.isActive; @@ -190,7 +191,7 @@ export function MemberRow({ }); } - if (shouldShowTaskRequirements && backgroundCheckStepEnabled && !memberExempt) { + if (shouldShowTaskRequirements && backgroundCheckStepEnabled && !memberExempt && !isAuditorOnly) { taskItems.push({ label: 'Background check', completed: hasCompletedBackgroundCheck ? 1 : 0, diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRowBackgroundCheck.test.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRowBackgroundCheck.test.tsx index 269db85f97..09d7c528a2 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRowBackgroundCheck.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRowBackgroundCheck.test.tsx @@ -46,12 +46,18 @@ const member = { const noop = vi.fn(); -function renderRow(backgroundCheckStatus?: 'completed' | 'completed_with_flags' | 'invited') { +function renderRow({ + backgroundCheckStatus, + role = 'employee', +}: { + backgroundCheckStatus?: 'completed' | 'completed_with_flags' | 'invited'; + role?: string; +} = {}) { return render( { it('shows background check as incomplete in the tasks column', () => { - renderRow('invited'); + renderRow({ backgroundCheckStatus: 'invited' }); expect(screen.getByText('Background check 0/1')).toBeInTheDocument(); }); it('shows background check as complete in the tasks column', () => { - renderRow('completed_with_flags'); + renderRow({ backgroundCheckStatus: 'completed_with_flags' }); expect(screen.getByText('Background check 1/1')).toBeInTheDocument(); expect(screen.getByLabelText('Employee has completed a background check')).toBeInTheDocument(); }); it('does not show the verified tick for incomplete checks', () => { - renderRow('invited'); + renderRow({ backgroundCheckStatus: 'invited' }); expect(screen.queryByLabelText('Employee has completed a background check')).not.toBeInTheDocument(); }); + + it('does not show background check tracking for auditor-only members', () => { + renderRow({ backgroundCheckStatus: 'invited', role: 'auditor' }); + expect(screen.queryByText(/Background check/)).not.toBeInTheDocument(); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/hooks/usePoliciesOverview.ts b/apps/app/src/app/(app)/[orgId]/policies/(overview)/hooks/usePoliciesOverview.ts index 244e9a9fbe..77e4143788 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/(overview)/hooks/usePoliciesOverview.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/hooks/usePoliciesOverview.ts @@ -16,6 +16,7 @@ interface PolicyFromApi { id: string; status: string; isArchived: boolean; + archivedAt: string | null; assigneeId: string | null; assignee?: { id: string; @@ -32,8 +33,8 @@ interface UsePoliciesOverviewOptions { export function usePoliciesOverview({ organizationId, initialData }: UsePoliciesOverviewOptions) { const { data, error, isLoading, mutate } = useSWR( - ['/v1/policies', organizationId], - async ([endpoint, orgId]) => { + ['/v1/policies?includeArchived=true', organizationId], + async ([endpoint]) => { const response = await apiClient.get<{ data: PolicyFromApi[] }>(endpoint); if (response.error) throw new Error(response.error); return response.data?.data ?? []; diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/lib/compute-overview.ts b/apps/app/src/app/(app)/[orgId]/policies/(overview)/lib/compute-overview.ts index ac2a2ee29a..6f35c353c6 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/(overview)/lib/compute-overview.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/lib/compute-overview.ts @@ -1,9 +1,11 @@ // Shared utility - can be used by both server and client +import { isArchivedPolicy } from '../../lib/policy-archive-state'; interface PolicyForOverview { id: string; status: string; isArchived: boolean; + archivedAt?: Date | string | null; assigneeId: string | null; assignee?: { id: string; @@ -45,8 +47,10 @@ export function computePoliciesOverview(policies: PolicyForOverview[]): Policies const policyDataByOwner = new Map(); for (const policy of policies) { + const archived = isArchivedPolicy(policy); + // Count by status - if (policy.isArchived) { + if (archived) { archivedPolicies += 1; } else if (policy.status === 'published') { publishedPolicies += 1; @@ -74,7 +78,7 @@ export function computePoliciesOverview(policies: PolicyForOverview[]): Policies const assigneeData = policyDataByOwner.get(assigneeId)!; assigneeData.total += 1; - if (policy.isArchived) { + if (archived) { assigneeData.archived += 1; } else if (policy.status === 'published') { assigneeData.published += 1; diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/page.tsx b/apps/app/src/app/(app)/[orgId]/policies/(overview)/page.tsx index efa640c196..519e183493 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/(overview)/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/page.tsx @@ -5,6 +5,7 @@ import type { Metadata } from 'next'; import { Suspense } from 'react'; import { PolicyFilters } from '../all/components/PolicyFilters'; import { PolicyPageActions } from '../all/components/PolicyPageActions'; +import { isArchivedPolicy } from '../lib/policy-archive-state'; import { PolicyChartsClient } from './components/PolicyChartsClient'; import { computePoliciesOverview } from './lib/compute-overview'; import Loading from './loading'; @@ -25,7 +26,7 @@ export default async function PoliciesPage({ params }: PoliciesPageProps) { // policies…" status during first-run AI generation. Mirrors the pattern // used by the risks and vendors pages. const [policiesRes, onboardingRes] = await Promise.all([ - serverApi.get<{ data: PolicyWithAssignee[] }>('/v1/policies'), + serverApi.get<{ data: PolicyWithAssignee[] }>('/v1/policies?includeArchived=true'), serverApi.get<{ triggerJobId: string | null; triggerJobCompleted: boolean; @@ -41,7 +42,7 @@ export default async function PoliciesPage({ params }: PoliciesPageProps) { policies.map((p) => ({ id: p.id, status: p.status, - isArchived: p.isArchived, + isArchived: isArchivedPolicy(p), assigneeId: p.assigneeId, assignee: p.assignee, })), @@ -52,7 +53,7 @@ export default async function PoliciesPage({ params }: PoliciesPageProps) { !p.isArchived)} />} + actions={ !isArchivedPolicy(p))} />} /> }> diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyAlerts.test.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyAlerts.test.tsx index c620ad73e2..fd827ef23b 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyAlerts.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyAlerts.test.tsx @@ -1,11 +1,13 @@ -import { render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; import { - setMockPermissions, - mockHasPermission, ADMIN_PERMISSIONS, AUDITOR_PERMISSIONS, + mockHasPermission, + setMockPermissions, } from '@/test-utils/mocks/permissions'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ComponentProps, ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; // Mock usePermissions vi.mock('@/hooks/use-permissions', () => ({ @@ -15,6 +17,97 @@ vi.mock('@/hooks/use-permissions', () => ({ }), })); +interface MockApprovalBannerProps { + approveConfirmation?: { content?: ReactNode }; + approveLoading?: boolean; + description: string; + onApprove: () => void; + onReject: () => void; + rejectLoading?: boolean; + title: string; +} + +type MockButtonProps = ComponentProps<'button'> & { + iconLeft?: ReactNode; + size?: string; + variant?: string; +}; + +interface MockLayoutProps { + children: ReactNode; +} + +type MockTextProps = MockLayoutProps & { + as?: string; + leading?: string; + size?: string; + variant?: string; + weight?: string; +}; + +vi.mock('@trycompai/design-system', async () => { + const { forwardRef } = await import('react'); + + return { + ApprovalBanner: ({ + approveConfirmation, + approveLoading, + description, + onApprove, + onReject, + rejectLoading, + title, + }: MockApprovalBannerProps) => ( +
+
{title}
+
{description}
+ {approveConfirmation?.content} + + +
+ ), + Button: ({ children, iconLeft, ...props }: MockButtonProps) => ( + + ), + HStack: ({ children }: MockLayoutProps) =>
{children}
, + Label: ({ children, ...props }: ComponentProps<'label'>) => ( + + ), + Stack: ({ children }: MockLayoutProps) =>
{children}
, + Text: ({ children }: MockTextProps) => {children}, + Textarea: forwardRef>((props, ref) => ( +