From 3e19936db4e43967580440817873167e05a48e77 Mon Sep 17 00:00:00 2001 From: Mariano Date: Tue, 5 May 2026 10:19:05 +0100 Subject: [PATCH 01/12] feat(db): add Member.backgroundCheckExempt Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 2 ++ packages/db/prisma/schema/auth.prisma | 1 + 2 files changed, 3 insertions(+) create mode 100644 packages/db/prisma/migrations/20260505091858_add_member_background_check_exempt/migration.sql diff --git a/packages/db/prisma/migrations/20260505091858_add_member_background_check_exempt/migration.sql b/packages/db/prisma/migrations/20260505091858_add_member_background_check_exempt/migration.sql new file mode 100644 index 0000000000..7400b270ee --- /dev/null +++ b/packages/db/prisma/migrations/20260505091858_add_member_background_check_exempt/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Member" ADD COLUMN "backgroundCheckExempt" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index 21af680245..801083b868 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -118,6 +118,7 @@ model Member { jobTitle String? isActive Boolean @default(true) deactivated Boolean @default(false) + backgroundCheckExempt Boolean @default(false) externalUserId String? externalUserSource String? employeeTrainingVideoCompletion EmployeeTrainingVideoCompletion[] From 798849865d453d5599cc7a77c941a6742685c0b6 Mon Sep 17 00:00:00 2001 From: Mariano Date: Tue, 5 May 2026 10:54:01 +0100 Subject: [PATCH 02/12] feat(api): exempt per-member from BG-check requirement Add getExemptMemberIds helper and update computePeopleScore to skip the background check requirement for members with backgroundCheckExempt=true. Both the BG-check query and exempt query are gated behind backgroundCheckStepEnabled. Co-Authored-By: Claude Sonnet 4.6 --- .../frameworks-people-score.helper.spec.ts | 81 +++++++++++++++++++ .../frameworks-people-score.helper.ts | 27 ++++++- 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts b/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts index a2348bf4d8..82e1aacbce 100644 --- a/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts +++ b/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts @@ -53,6 +53,12 @@ describe('computePeopleScore', () => { (mockDb.backgroundCheckRequest.findMany as jest.Mock).mockResolvedValue([ { memberId: 'mem_1' }, ]); + (mockDb.member.findMany as jest.Mock).mockImplementation( + async (args: { where?: { backgroundCheckExempt?: boolean } }) => { + if (args?.where?.backgroundCheckExempt === true) return []; + return []; + }, + ); }); it('requires a completed or uploaded background check for people completion', async () => { @@ -125,4 +131,79 @@ describe('computePeopleScore', () => { expect(mockDb.backgroundCheckRequest.findMany).not.toHaveBeenCalled(); }); + + it('treats an exempt member as complete without a BG check (org-level on)', async () => { + // mem_1 has no completed BG check; mem_2 has none either. Mark mem_1 exempt. + (mockDb.backgroundCheckRequest.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.member.findMany as jest.Mock).mockImplementation( + async (args: { where?: { backgroundCheckExempt?: boolean } }) => { + if (args?.where?.backgroundCheckExempt === true) { + return [{ id: 'mem_1' }]; + } + return []; + }, + ); + + const score = await computePeopleScore({ + organizationId: 'org_1', + allPolicies: [], + employees: members, + securityTrainingStepEnabled: false, + deviceAgentStepEnabled: false, + backgroundCheckStepEnabled: true, + hasHipaaFramework: false, + }); + + // mem_1 exempt → counts complete; mem_2 not exempt + no BG check → not complete + expect(score).toEqual({ total: 2, completed: 1 }); + }); + + it('counts a mix of completed BG checks and exempt members correctly', async () => { + // mem_1 has a completed BG check; mem_2 is exempt + (mockDb.backgroundCheckRequest.findMany as jest.Mock).mockResolvedValue([ + { memberId: 'mem_1' }, + ]); + (mockDb.member.findMany as jest.Mock).mockImplementation( + async (args: { where?: { backgroundCheckExempt?: boolean } }) => { + if (args?.where?.backgroundCheckExempt === true) { + return [{ id: 'mem_2' }]; + } + return []; + }, + ); + + const score = await computePeopleScore({ + organizationId: 'org_1', + allPolicies: [], + employees: members, + securityTrainingStepEnabled: false, + deviceAgentStepEnabled: false, + backgroundCheckStepEnabled: true, + hasHipaaFramework: false, + }); + + expect(score).toEqual({ total: 2, completed: 2 }); + }); + + it('skips the exempt query entirely when backgroundCheckStepEnabled is false', async () => { + (mockDb.member.findMany as jest.Mock).mockClear(); + + await computePeopleScore({ + organizationId: 'org_1', + allPolicies: [], + employees: members, + securityTrainingStepEnabled: false, + deviceAgentStepEnabled: false, + backgroundCheckStepEnabled: false, + hasHipaaFramework: false, + }); + + // The exempt query targets backgroundCheckExempt: true. Confirm it was not called with that arg. + const findManyCalls = (mockDb.member.findMany as jest.Mock).mock.calls; + const exemptQueryCalled = findManyCalls.some( + ([args]: [{ where?: { backgroundCheckExempt?: boolean } }]) => + args?.where?.backgroundCheckExempt === true, + ); + expect(exemptQueryCalled).toBe(false); + }); }); diff --git a/apps/api/src/frameworks/frameworks-people-score.helper.ts b/apps/api/src/frameworks/frameworks-people-score.helper.ts index e313edf517..427ff88da5 100644 --- a/apps/api/src/frameworks/frameworks-people-score.helper.ts +++ b/apps/api/src/frameworks/frameworks-people-score.helper.ts @@ -61,6 +61,7 @@ export async function computePeopleScore({ membersWithInstalledDevices, trainingCompletions, membersWithCompletedBackgroundChecks, + exemptMemberIds, ] = await Promise.all([ getMembersWithInstalledDevices({ organizationId, @@ -75,6 +76,9 @@ export async function computePeopleScore({ backgroundCheckStepEnabled ? getMembersWithCompletedBackgroundChecks({ organizationId, memberIds }) : Promise.resolve(new Set()), + backgroundCheckStepEnabled + ? getExemptMemberIds({ organizationId, memberIds }) + : Promise.resolve(new Set()), ]); const completed = activeEmployees.filter((employee) => { @@ -101,7 +105,9 @@ export async function computePeopleScore({ const hasInstalledDevice = deviceAgentStepEnabled ? membersWithInstalledDevices.has(employee.id) : true; - const hasCompletedBackgroundCheck = backgroundCheckStepEnabled + const memberRequiresBgCheck = + backgroundCheckStepEnabled && !exemptMemberIds.has(employee.id); + const hasCompletedBackgroundCheck = memberRequiresBgCheck ? membersWithCompletedBackgroundChecks.has(employee.id) : true; @@ -205,3 +211,22 @@ async function getMembersWithCompletedBackgroundChecks({ return new Set(completedBackgroundChecks.map((check) => check.memberId)); } + +async function getExemptMemberIds({ + organizationId, + memberIds, +}: { + organizationId: string; + memberIds: string[]; +}) { + const exemptMembers = await db.member.findMany({ + where: { + organizationId, + id: { in: memberIds }, + backgroundCheckExempt: true, + }, + select: { id: true }, + }); + + return new Set(exemptMembers.map((member) => member.id)); +} From c919fdbdb874dfacaf335e1847b577158cdc4004 Mon Sep 17 00:00:00 2001 From: Mariano Date: Tue, 5 May 2026 10:56:41 +0100 Subject: [PATCH 03/12] feat(api): allow backgroundCheckStepEnabled in PATCH /v1/organization --- apps/api/src/organization/dto/update-organization.dto.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/src/organization/dto/update-organization.dto.ts b/apps/api/src/organization/dto/update-organization.dto.ts index de0f3f6f73..18c301ffc0 100644 --- a/apps/api/src/organization/dto/update-organization.dto.ts +++ b/apps/api/src/organization/dto/update-organization.dto.ts @@ -10,4 +10,5 @@ export interface UpdateOrganizationDto { isFleetSetupCompleted?: boolean; primaryColor?: string; advancedModeEnabled?: boolean; + backgroundCheckStepEnabled?: boolean; } From 84a8622083dc9d8f82825d67479a95add0ad2733 Mon Sep 17 00:00:00 2001 From: Mariano Date: Tue, 5 May 2026 11:00:00 +0100 Subject: [PATCH 04/12] feat(api): allow backgroundCheckExempt in PATCH /v1/people/:id - Add backgroundCheckExempt field to UpdatePeopleDto with @IsOptional + @IsBoolean decorators - Add backgroundCheckExempt: true to MemberQueries.MEMBER_SELECT so the field flows to responses - Add @db mock and backgroundCheckExempt controller test; fix transitive enum mocks so the suite runs Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/people/dto/update-people.dto.ts | 10 ++++ apps/api/src/people/people.controller.spec.ts | 54 +++++++++++++++++++ apps/api/src/people/utils/member-queries.ts | 1 + 3 files changed, 65 insertions(+) diff --git a/apps/api/src/people/dto/update-people.dto.ts b/apps/api/src/people/dto/update-people.dto.ts index 8c5c53f070..951118b401 100644 --- a/apps/api/src/people/dto/update-people.dto.ts +++ b/apps/api/src/people/dto/update-people.dto.ts @@ -44,4 +44,14 @@ export class UpdatePeopleDto extends PartialType(CreatePeopleDto) { @IsOptional() @IsDateString() createdAt?: string; + + @ApiProperty({ + description: + 'When true, this member is exempt from the org-level background check requirement and will count as complete in people scores.', + example: false, + required: false, + }) + @IsOptional() + @IsBoolean() + backgroundCheckExempt?: boolean; } diff --git a/apps/api/src/people/people.controller.spec.ts b/apps/api/src/people/people.controller.spec.ts index d418cd8be5..e93c62580d 100644 --- a/apps/api/src/people/people.controller.spec.ts +++ b/apps/api/src/people/people.controller.spec.ts @@ -7,6 +7,42 @@ import { PermissionGuard } from '../auth/permission.guard'; import { PeopleController } from './people.controller'; import { BadRequestException } from '@nestjs/common'; +// Mock @db to avoid PrismaClient initialization in controller tests +jest.mock('@db', () => ({ + db: { + member: { + findMany: jest.fn(), + findFirst: jest.fn(), + findFirstOrThrow: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + createMany: jest.fn(), + }, + user: { update: jest.fn() }, + organization: { findUnique: jest.fn() }, + session: { deleteMany: jest.fn() }, + task: { findMany: jest.fn(), updateMany: jest.fn() }, + policy: { findMany: jest.fn(), updateMany: jest.fn() }, + risk: { findMany: jest.fn(), updateMany: jest.fn() }, + vendor: { findMany: jest.fn(), updateMany: jest.fn() }, + organizationChart: { findUnique: jest.fn(), update: jest.fn() }, + }, + BackgroundCheckStatus: { + pending: 'pending', + in_progress: 'in_progress', + completed: 'completed', + completed_with_flags: 'completed_with_flags', + cancelled: 'cancelled', + }, + FindingType: { soc2: 'soc2', iso27001: 'iso27001' }, + FindingStatus: { open: 'open', closed: 'closed' }, + PhaseCompletionType: { manual: 'manual', auto: 'auto' }, + TimelinePhaseStatus: { pending: 'pending', completed: 'completed' }, + TimelineStatus: { draft: 'draft', active: 'active' }, + Departments: { it: 'it', none: 'none' }, +})); + // Mock auth.server to avoid importing better-auth ESM in Jest jest.mock('../auth/auth.server', () => ({ auth: { @@ -220,6 +256,24 @@ describe('PeopleController', () => { 'usr_123', ); }); + + it('passes backgroundCheckExempt through to the service', async () => { + mockPeopleService.updateById.mockResolvedValue({ id: 'mem_1' }); + + await controller.updateMember( + 'mem_1', + { backgroundCheckExempt: true } as any, + 'org_123', + mockAuthContext, + ); + + expect(mockPeopleService.updateById).toHaveBeenCalledWith( + 'mem_1', + 'org_123', + { backgroundCheckExempt: true }, + 'usr_123', + ); + }); }); describe('deleteMember', () => { diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts index 92ab728914..05ced2524c 100644 --- a/apps/api/src/people/utils/member-queries.ts +++ b/apps/api/src/people/utils/member-queries.ts @@ -20,6 +20,7 @@ export class MemberQueries { jobTitle: true, isActive: true, deactivated: true, + backgroundCheckExempt: true, fleetDmLabelId: true, user: { select: { From 2474526deec78206f16dd2577f3d086e6fab4c29 Mon Sep 17 00:00:00 2001 From: Mariano Date: Tue, 5 May 2026 11:11:32 +0100 Subject: [PATCH 05/12] feat(app): add People > Settings tab with org-level BG-check toggle Co-Authored-By: Claude Opus 4.7 (1M context) --- .../people/components/PeoplePageTabs.tsx | 14 ++++ .../app/src/app/(app)/[orgId]/people/page.tsx | 22 +++++++ .../components/PeopleSettings.test.tsx | 59 +++++++++++++++++ .../settings/components/PeopleSettings.tsx | 64 +++++++++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.tsx diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx index 562f4315c2..82523307d8 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -24,6 +24,8 @@ interface PeoplePageTabsProps { roleMappingContent: ReactNode | null; showRoleMapping: boolean; showEmployeeTasks: boolean; + settingsContent: ReactNode | null; + showSettings: boolean; canInviteUsers: boolean; canManageMembers: boolean; organizationId: string; @@ -34,6 +36,7 @@ function tabParamToInternal( tabParam: string | null, showEmployeeTasks: boolean, showRoleMapping: boolean, + showSettings: boolean, ): string { if (!tabParam || tabParam === 'people') { return 'people'; @@ -50,6 +53,9 @@ function tabParamToInternal( if (tabParam === 'role-mapping') { return showRoleMapping ? 'role-mapping' : 'people'; } + if (tabParam === 'settings') { + return showSettings ? 'settings' : 'people'; + } return 'people'; } @@ -63,6 +69,7 @@ function internalValueToTabParam(value: string): string { case 'people': case 'devices': case 'role-mapping': + case 'settings': return value; default: return 'people'; @@ -78,6 +85,8 @@ export function PeoplePageTabs({ roleMappingContent, showRoleMapping, showEmployeeTasks, + settingsContent, + showSettings, canInviteUsers, canManageMembers, organizationId, @@ -91,6 +100,7 @@ export function PeoplePageTabs({ searchParams.get('tab'), showEmployeeTasks, showRoleMapping, + showSettings, ); const handleTabChange = useCallback( @@ -121,6 +131,7 @@ export function PeoplePageTabs({ Devices Chart {showRoleMapping && Role Mapping} + {showSettings && Settings} } actions={ @@ -144,6 +155,9 @@ export function PeoplePageTabs({ {showRoleMapping && ( {roleMappingContent} )} + {showSettings && ( + {settingsContent} + )} }) { const { orgId } = await params; @@ -34,6 +35,17 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: const canInviteUsers = canManageMembers || isAuditor; const isCurrentUserOwner = currentUserRoles.includes('owner'); + const canManageOrgSettings = currentUserRoles.some((role) => + ['owner', 'admin'].includes(role), + ); + + const organization = canManageOrgSettings + ? await db.organization.findUnique({ + where: { id: orgId }, + select: { backgroundCheckStepEnabled: true }, + }) + : null; + return ( + ) : null + } showEmployeeTasks canInviteUsers={canInviteUsers} canManageMembers={canManageMembers} diff --git a/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.test.tsx b/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.test.tsx new file mode 100644 index 0000000000..ad128ea271 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.test.tsx @@ -0,0 +1,59 @@ +import '@testing-library/jest-dom/vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PeopleSettings } from './PeopleSettings'; + +const patchMock = vi.fn(); +vi.mock('@/lib/api-client', () => ({ + apiClient: { + patch: (...args: unknown[]) => patchMock(...args), + }, +})); + +describe('PeopleSettings — background-check toggle', () => { + beforeEach(() => { + patchMock.mockReset(); + patchMock.mockResolvedValue({ data: { success: true } }); + }); + + it('shows the toggle in its current state', () => { + render(); + const toggle = screen.getByRole('switch', { + name: /require background checks/i, + }); + expect(toggle).toBeChecked(); + }); + + it('toggles off and PATCHes /v1/organization', async () => { + const user = userEvent.setup(); + render(); + const toggle = screen.getByRole('switch', { + name: /require background checks/i, + }); + + await user.click(toggle); + + await waitFor(() => { + expect(patchMock).toHaveBeenCalledWith('/v1/organization', { + backgroundCheckStepEnabled: false, + }); + }); + }); + + it('rolls back to checked state when PATCH fails', async () => { + patchMock.mockResolvedValue({ error: 'server error' }); + const user = userEvent.setup(); + render(); + const toggle = screen.getByRole('switch', { + name: /require background checks/i, + }); + + await user.click(toggle); + + await waitFor(() => { + expect(toggle).toBeChecked(); + }); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.tsx b/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.tsx new file mode 100644 index 0000000000..a5e5e874ba --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useState } from 'react'; +import { toast } from 'sonner'; +import { apiClient } from '@/lib/api-client'; +import { Section, Stack, Switch, Text } from '@trycompai/design-system'; + +interface PeopleSettingsProps { + backgroundCheckStepEnabled: boolean; +} + +export function PeopleSettings({ + backgroundCheckStepEnabled: initialEnabled, +}: PeopleSettingsProps) { + const [enabled, setEnabled] = useState(initialEnabled); + const [saving, setSaving] = useState(false); + + const handleToggle = async (next: boolean) => { + const previous = enabled; + setEnabled(next); + setSaving(true); + + const res = await apiClient.patch('/v1/organization', { + backgroundCheckStepEnabled: next, + }); + + setSaving(false); + + if (res.error) { + setEnabled(previous); + toast.error('Failed to update background check setting'); + return; + } + + toast.success( + next + ? 'Background checks now required' + : 'Background checks bypassed for your organization', + ); + }; + + return ( + +
+
+
+ Require background checks + + When off, your organization's members do not need to pass a + background check to count toward people completion. Individual + members can also be exempted from their profile. + +
+ +
+
+
+ ); +} From e08818b9c488caeaeb918a35ebb8b80345c68104 Mon Sep 17 00:00:00 2001 From: Mariano Date: Tue, 5 May 2026 11:15:38 +0100 Subject: [PATCH 06/12] fix(app): use hasPermission for People Settings gate Replace manual role-string parsing with resolveBuiltInPermissions + hasPermission for the canManageOrgSettings server-side gate, and add usePermissions self-defense inside PeopleSettings to disable the Switch when the user lacks organization:update. Co-Authored-By: Claude Sonnet 4.6 --- apps/app/src/app/(app)/[orgId]/people/page.tsx | 6 +++--- .../people/settings/components/PeopleSettings.test.tsx | 3 +++ .../[orgId]/people/settings/components/PeopleSettings.tsx | 6 +++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index 2b4dcb7885..a76c8bb16d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -1,3 +1,4 @@ +import { hasPermission, resolveBuiltInPermissions } from '@/lib/permissions'; import { auth } from '@/utils/auth'; import { db } from '@db/server'; import type { Metadata } from 'next'; @@ -35,9 +36,8 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: const canInviteUsers = canManageMembers || isAuditor; const isCurrentUserOwner = currentUserRoles.includes('owner'); - const canManageOrgSettings = currentUserRoles.some((role) => - ['owner', 'admin'].includes(role), - ); + const { permissions } = resolveBuiltInPermissions(currentUserMember?.role); + const canManageOrgSettings = hasPermission(permissions, 'organization', 'update'); const organization = canManageOrgSettings ? await db.organization.findUnique({ diff --git a/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.test.tsx b/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.test.tsx index ad128ea271..eabae8cf65 100644 --- a/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.test.tsx @@ -11,6 +11,9 @@ vi.mock('@/lib/api-client', () => ({ patch: (...args: unknown[]) => patchMock(...args), }, })); +vi.mock('@/hooks/use-permissions', () => ({ + usePermissions: () => ({ hasPermission: () => true }), +})); describe('PeopleSettings — background-check toggle', () => { beforeEach(() => { diff --git a/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.tsx b/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.tsx index a5e5e874ba..390b4b8c36 100644 --- a/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { toast } from 'sonner'; +import { usePermissions } from '@/hooks/use-permissions'; import { apiClient } from '@/lib/api-client'; import { Section, Stack, Switch, Text } from '@trycompai/design-system'; @@ -12,6 +13,9 @@ interface PeopleSettingsProps { export function PeopleSettings({ backgroundCheckStepEnabled: initialEnabled, }: PeopleSettingsProps) { + const { hasPermission } = usePermissions(); + const canUpdate = hasPermission('organization', 'update'); + const [enabled, setEnabled] = useState(initialEnabled); const [saving, setSaving] = useState(false); @@ -53,7 +57,7 @@ export function PeopleSettings({ From c9818104762d4a4125c94fea0299b327955fbab6 Mon Sep 17 00:00:00 2001 From: Mariano Date: Tue, 5 May 2026 11:29:25 +0100 Subject: [PATCH 07/12] feat(app): per-employee BG-check exempt toggle Add memberBackgroundCheckExempt prop to EmployeeBackgroundCheck with an ExemptToggleCard that PATCHes /v1/people/:id on change; gate on member:update permission. Also fix @trycompai/billing vitest alias and backgroundCheckExempt default in createMockMember. Co-Authored-By: Claude Sonnet 4.6 --- .../[employeeId]/background-check/page.tsx | 1 + .../[employeeId]/components/Employee.tsx | 3 + .../EmployeeBackgroundCheck.test.tsx | 53 +++++ .../components/EmployeeBackgroundCheck.tsx | 195 +++++++++++++----- .../[orgId]/people/[employeeId]/page.tsx | 1 + apps/app/src/test-utils/mocks/auth.ts | 1 + apps/app/vitest.config.mts | 1 + 7 files changed, 207 insertions(+), 48 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/background-check/page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/background-check/page.tsx index 1d67176648..a951990f36 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/background-check/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/background-check/page.tsx @@ -59,6 +59,7 @@ export default async function EmployeeBackgroundCheckPage({ } } backgroundCheckStepEnabled={backgroundCheckStepEnabled} + memberBackgroundCheckExempt={employee.backgroundCheckExempt === true} /> ); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx index 7cf635b1f4..053a65511e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx @@ -42,6 +42,7 @@ interface EmployeeProps { initialBackgroundCheck: BackgroundCheckRecord | null; initialBackgroundCheckBillingStatus: BackgroundCheckBillingStatus; backgroundCheckStepEnabled: boolean; + memberBackgroundCheckExempt: boolean; } export function Employee({ @@ -59,6 +60,7 @@ export function Employee({ initialBackgroundCheck, initialBackgroundCheckBillingStatus, backgroundCheckStepEnabled, + memberBackgroundCheckExempt, }: EmployeeProps) { const searchParams = useSearchParams(); const querySelectedTab: EmployeeTab = @@ -140,6 +142,7 @@ export function Employee({ initialBackgroundCheck={initialBackgroundCheck} initialBillingStatus={initialBackgroundCheckBillingStatus} backgroundCheckStepEnabled={backgroundCheckStepEnabled} + memberBackgroundCheckExempt={memberBackgroundCheckExempt} /> )} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx index aec2ae0918..18ee9f5ecb 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx @@ -25,6 +25,7 @@ vi.mock('@/lib/api-client', () => ({ apiClient: { get: vi.fn(), post: vi.fn(), + patch: vi.fn(), }, })); @@ -84,6 +85,7 @@ function renderSection(props?: Partial , @@ -308,6 +310,7 @@ describe('EmployeeBackgroundCheck', () => { initialBackgroundCheck={null} initialBillingStatus={{ hasPaymentMethod: false, setupAt: null }} backgroundCheckStepEnabled={false} + memberBackgroundCheckExempt={false} />, ); @@ -323,6 +326,7 @@ describe('EmployeeBackgroundCheck', () => { initialBackgroundCheck={null} initialBillingStatus={{ hasPaymentMethod: false, setupAt: null }} backgroundCheckStepEnabled={false} + memberBackgroundCheckExempt={false} />, ); @@ -332,4 +336,53 @@ describe('EmployeeBackgroundCheck', () => { expect(apiClient.get).not.toHaveBeenCalled(); }); + + it('renders the exempt info card when memberBackgroundCheckExempt is true', () => { + render( + , + ); + + expect( + screen.getByText(/this employee is exempt/i), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /get started/i }), + ).not.toBeInTheDocument(); + }); + + it('toggles exempt on and PATCHes /v1/people/:id', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 'mem_1' }, status: 200 }); + + render( + , + ); + + const toggle = screen.getByRole('switch', { + name: /exempt this employee/i, + }); + await user.click(toggle); + + await waitFor(() => { + expect(apiClient.patch).toHaveBeenCalledWith( + '/v1/people/mem_1', + { backgroundCheckExempt: true }, + 'org_1', + ); + }); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx index f2d31e3fed..2287b56466 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx @@ -4,7 +4,7 @@ import { usePermissions } from '@/hooks/use-permissions'; import { apiClient } from '@/lib/api-client'; import type { Member, User } from '@db'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Text } from '@trycompai/design-system'; +import { Stack, Switch, Text } from '@trycompai/design-system'; import { Information } from '@trycompai/design-system/icons'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -35,6 +35,7 @@ interface EmployeeBackgroundCheckProps { initialBackgroundCheck: BackgroundCheckRecord | null; initialBillingStatus: BackgroundCheckBillingStatus; backgroundCheckStepEnabled: boolean; + memberBackgroundCheckExempt: boolean; } export function EmployeeBackgroundCheck({ @@ -43,6 +44,7 @@ export function EmployeeBackgroundCheck({ initialBackgroundCheck, initialBillingStatus, backgroundCheckStepEnabled, + memberBackgroundCheckExempt, }: EmployeeBackgroundCheckProps) { const router = useRouter(); const pathname = usePathname(); @@ -57,6 +59,8 @@ export function EmployeeBackgroundCheck({ const [billingSetupComplete, setBillingSetupComplete] = useState(false); const [paymentIssue, setPaymentIssue] = useState(null); const [requestConfirmation, setRequestConfirmation] = useState(null); + const [exempt, setExempt] = useState(memberBackgroundCheckExempt); + const [savingExempt, setSavingExempt] = useState(false); const { hasPermission } = usePermissions(); const { data: backgroundCheck, mutate: mutateBackgroundCheck } = useBackgroundCheckRecord({ @@ -89,6 +93,7 @@ export function EmployeeBackgroundCheck({ }, [backgroundCheck, employee.user.name, form]); const canRequest = hasPermission('member', 'update'); + const canUpdateMember = hasPermission('member', 'update'); const canManageBilling = hasPermission('organization', 'update'); const hasPaymentMethod = billingStatus?.hasPaymentMethod === true; const backgroundChecksRemaining = getBackgroundChecksRemaining({ billingStatus }); @@ -225,6 +230,30 @@ export function EmployeeBackgroundCheck({ setIsOpeningBilling(false); }; + const handleToggleExempt = async (next: boolean) => { + const previous = exempt; + setExempt(next); + setSavingExempt(true); + + const res = await apiClient.patch( + `/v1/people/${employee.id}`, + { backgroundCheckExempt: next }, + organizationId, + ); + + setSavingExempt(false); + + if (res.error) { + setExempt(previous); + toast.error('Failed to update exempt status'); + return; + } + + toast.success( + next ? 'Employee exempted from background check' : 'Employee no longer exempt', + ); + }; + const handleComplete = async (values: BackgroundCheckFormValues) => { if (!hasBackgroundCheckAllowance) { writePendingRequest(values); @@ -252,64 +281,134 @@ export function EmployeeBackgroundCheck({ ); } + if (exempt) { + return ( + + +
+ + + +
+ This employee is exempt from background checks + + Toggle off above to require this employee to complete a background check. + +
+
+
+ ); + } + if (backgroundCheck) { return ( - + + + + ); } return ( - <> - {visibleWizardStep === 'overview' && ( - + + <> + {visibleWizardStep === 'overview' && ( + setWizardStep('details')} + onOpenBilling={() => + router.push(`/${organizationId}/settings/billing/add-ons/background-checks`) + } + /> + )} + {visibleWizardStep === 'details' && ( + setWizardStep('overview')} + onSubmit={handleComplete} + /> + )} + setWizardStep('details')} - onOpenBilling={() => - router.push(`/${organizationId}/settings/billing/add-ons/background-checks`) - } + issue={paymentIssue} + open={paymentIssue !== null} + onOpenChange={(open) => !open && setPaymentIssue(null)} + onUpdatePaymentMethod={() => void handleOpenBilling(form.getValues())} /> - )} - {visibleWizardStep === 'details' && ( - setWizardStep('overview')} - onSubmit={handleComplete} + employeeEmail={employee.user.email} + employeeId={employee.id} + employeeName={employee.user.name ?? employee.user.email} + organizationId={organizationId} + onUploaded={async (uploadedBackgroundCheck) => { + await mutateBackgroundCheck(uploadedBackgroundCheck, { revalidate: false }); + }} /> - )} - !open && setPaymentIssue(null)} - onUpdatePaymentMethod={() => void handleOpenBilling(form.getValues())} - /> - { - await mutateBackgroundCheck(uploadedBackgroundCheck, { revalidate: false }); - }} + + + ); +} + +function ExemptToggleCard({ + exempt, + saving, + canUpdate, + onToggle, +}: { + exempt: boolean; + saving: boolean; + canUpdate: boolean; + onToggle: (next: boolean) => void; +}) { + return ( +
+
+ Exempt this employee from background check + + When on, this employee won't be required to pass a background check to count toward + people completion. + +
+ - +
); } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx index 216d8dd117..80960daa74 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx @@ -106,6 +106,7 @@ export default async function EmployeeDetailsPage({ } } backgroundCheckStepEnabled={organization.backgroundCheckStepEnabled === true} + memberBackgroundCheckExempt={employee.backgroundCheckExempt === true} /> ); } diff --git a/apps/app/src/test-utils/mocks/auth.ts b/apps/app/src/test-utils/mocks/auth.ts index 2df6690459..135b192afc 100644 --- a/apps/app/src/test-utils/mocks/auth.ts +++ b/apps/app/src/test-utils/mocks/auth.ts @@ -82,6 +82,7 @@ export const createMockMember = (overrides?: Partial): Member => ({ deactivated: false, externalUserId: null, externalUserSource: null, + backgroundCheckExempt: false, ...overrides, }); diff --git a/apps/app/vitest.config.mts b/apps/app/vitest.config.mts index 3bd809780b..8154e80599 100644 --- a/apps/app/vitest.config.mts +++ b/apps/app/vitest.config.mts @@ -29,6 +29,7 @@ export default defineConfig({ resolve: { alias: { '@': resolve(__dirname, './src'), + '@trycompai/billing': resolve(__dirname, '../../packages/billing/src/index.ts'), }, }, }); From 8c8df0997d9c7aeb2db589d2845b601c1d733783 Mon Sep 17 00:00:00 2001 From: Mariano Date: Tue, 5 May 2026 11:35:14 +0100 Subject: [PATCH 08/12] refactor(app): merge duplicate canRequest/canUpdateMember constant --- .../[employeeId]/components/EmployeeBackgroundCheck.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx index 2287b56466..19c1c5cbd4 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx @@ -93,7 +93,6 @@ export function EmployeeBackgroundCheck({ }, [backgroundCheck, employee.user.name, form]); const canRequest = hasPermission('member', 'update'); - const canUpdateMember = hasPermission('member', 'update'); const canManageBilling = hasPermission('organization', 'update'); const hasPaymentMethod = billingStatus?.hasPaymentMethod === true; const backgroundChecksRemaining = getBackgroundChecksRemaining({ billingStatus }); @@ -287,7 +286,7 @@ export function EmployeeBackgroundCheck({
@@ -311,7 +310,7 @@ export function EmployeeBackgroundCheck({ <> From 3172b8f48c60db1ae13dc5c7af4cb4d92e7166eb Mon Sep 17 00:00:00 2001 From: Mariano Date: Tue, 5 May 2026 11:39:27 +0100 Subject: [PATCH 09/12] feat(app): hide BG-check counter and tick for exempt members Co-Authored-By: Claude Sonnet 4.6 --- .../[employeeId]/components/Employee.tsx | 1 + .../components/EmployeePageHeader.test.tsx | 20 +++++++++++ .../components/EmployeePageHeader.tsx | 4 ++- .../people/all/components/MemberRow.test.tsx | 33 +++++++++++++++++++ .../people/all/components/MemberRow.tsx | 5 +-- 5 files changed, 60 insertions(+), 3 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx index 053a65511e..c511984343 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx @@ -84,6 +84,7 @@ export function Employee({ orgId={orgId} backgroundCheck={initialBackgroundCheck} backgroundCheckStepEnabled={backgroundCheckStepEnabled} + memberBackgroundCheckExempt={memberBackgroundCheckExempt} /> } > diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.test.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.test.tsx index 7874eb08fc..a15ad2095d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.test.tsx @@ -28,6 +28,7 @@ describe('EmployeePageHeader', () => { orgId="org_123" backgroundCheck={backgroundCheck} backgroundCheckStepEnabled={true} + memberBackgroundCheckExempt={false} />, ); @@ -42,6 +43,7 @@ describe('EmployeePageHeader', () => { orgId="org_123" backgroundCheck={{ ...backgroundCheck, status: 'invited' }} backgroundCheckStepEnabled={true} + memberBackgroundCheckExempt={false} />, ); @@ -55,10 +57,28 @@ describe('EmployeePageHeader', () => { orgId="org_123" backgroundCheck={backgroundCheck} backgroundCheckStepEnabled={false} + memberBackgroundCheckExempt={false} />, ); expect(screen.getByRole('heading', { name: 'Jane Doe' })).toBeInTheDocument(); expect(screen.queryByLabelText('Employee has completed a background check')).not.toBeInTheDocument(); }); + + it('hides the verified tick when the member is exempt', () => { + render( + , + ); + + expect(screen.getByRole('heading', { name: 'Jane Doe' })).toBeInTheDocument(); + expect( + screen.queryByLabelText('Employee has completed a background check'), + ).not.toBeInTheDocument(); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.tsx index 2b797254be..dd18956804 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.tsx @@ -10,11 +10,13 @@ export function EmployeePageHeader({ orgId, backgroundCheck, backgroundCheckStepEnabled, + memberBackgroundCheckExempt, }: { employeeName: string; orgId: string; backgroundCheck: BackgroundCheckRecord | null; backgroundCheckStepEnabled: boolean; + memberBackgroundCheckExempt: boolean; }) { const isVerified = backgroundCheck ? isCompletedBackgroundCheck(backgroundCheck.status) @@ -33,7 +35,7 @@ export function EmployeePageHeader({
{employeeName} - {backgroundCheckStepEnabled && isVerified && ( + {backgroundCheckStepEnabled && !memberBackgroundCheckExempt && isVerified && ( )}
diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx index d56c9de68c..0dd6c16e15 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx @@ -245,4 +245,37 @@ describe('MemberRow device status', () => { screen.queryByLabelText('Employee has completed a background check'), ).not.toBeInTheDocument(); }); + + it('hides the background-check counter and verified tick for an exempt member', () => { + render( + + + + +
, + ); + + expect(screen.queryByText(/background check/i)).not.toBeInTheDocument(); + expect( + screen.queryByLabelText('Employee has completed a background check'), + ).not.toBeInTheDocument(); + }); }); 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 c54b0d9f1d..0f52dff877 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 isDeactivated = member.deactivated || !member.isActive; const profileHref = `/${orgId}/people/${memberId}`; const hasCompletedBackgroundCheck = isBackgroundCheckComplete(backgroundCheckStatus); + const memberExempt = member.backgroundCheckExempt === true; const shouldShowTaskRequirements = !isPlatformAdmin && !isDeactivated; const taskItems: TaskCountItem[] = []; @@ -184,7 +185,7 @@ export function MemberRow({ }); } - if (shouldShowTaskRequirements && backgroundCheckStepEnabled) { + if (shouldShowTaskRequirements && backgroundCheckStepEnabled && !memberExempt) { taskItems.push({ label: 'Background check', completed: hasCompletedBackgroundCheck ? 1 : 0, @@ -274,7 +275,7 @@ export function MemberRow({ > {memberName} - {backgroundCheckStepEnabled && hasCompletedBackgroundCheck && ( + {backgroundCheckStepEnabled && !memberExempt && hasCompletedBackgroundCheck && ( )} From 9ef0c26b64347a071e4763d38b79972a2e711f61 Mon Sep 17 00:00:00 2001 From: Mariano Date: Tue, 5 May 2026 11:54:22 +0100 Subject: [PATCH 10/12] =?UTF-8?q?fix(people-settings):=20address=20final?= =?UTF-8?q?=20review=20=E2=80=94=20copy=20+=20missing=20tests=20+=20null?= =?UTF-8?q?=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../frameworks-people-score.helper.spec.ts | 24 +++++++++++++++++++ .../organization.controller.spec.ts | 21 ++++++++++++++++ .../EmployeeBackgroundCheck.test.tsx | 21 ++++++++++++++++ .../components/EmployeeBackgroundCheck.tsx | 5 ++-- .../app/src/app/(app)/[orgId]/people/page.tsx | 2 +- 5 files changed, 70 insertions(+), 3 deletions(-) diff --git a/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts b/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts index 82e1aacbce..ee76cc0f2f 100644 --- a/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts +++ b/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts @@ -185,6 +185,30 @@ describe('computePeopleScore', () => { expect(score).toEqual({ total: 2, completed: 2 }); }); + it('counts every member as complete when all members are exempt', async () => { + (mockDb.backgroundCheckRequest.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.member.findMany as jest.Mock).mockImplementation(async (args: { + where?: { backgroundCheckExempt?: boolean }; + }) => { + if (args?.where?.backgroundCheckExempt === true) { + return [{ id: 'mem_1' }, { id: 'mem_2' }]; + } + return []; + }); + + const score = await computePeopleScore({ + organizationId: 'org_1', + allPolicies: [], + employees: members, + securityTrainingStepEnabled: false, + deviceAgentStepEnabled: false, + backgroundCheckStepEnabled: true, + hasHipaaFramework: false, + }); + + expect(score).toEqual({ total: 2, completed: 2 }); + }); + it('skips the exempt query entirely when backgroundCheckStepEnabled is false', async () => { (mockDb.member.findMany as jest.Mock).mockClear(); diff --git a/apps/api/src/organization/organization.controller.spec.ts b/apps/api/src/organization/organization.controller.spec.ts index 8f89a27b33..7e44303b07 100644 --- a/apps/api/src/organization/organization.controller.spec.ts +++ b/apps/api/src/organization/organization.controller.spec.ts @@ -1,3 +1,7 @@ +jest.mock('@db', () => ({ + db: {}, +})); + import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException } from '@nestjs/common'; import { OrganizationController } from './organization.controller'; @@ -169,6 +173,23 @@ describe('OrganizationController', () => { }); }); + describe('updateOrganization', () => { + it('passes backgroundCheckStepEnabled through to the service', async () => { + mockOrganizationService.updateById.mockResolvedValue({ id: 'org_123' }); + + await controller.updateOrganization( + 'org_123', + sessionAuthContext, + { backgroundCheckStepEnabled: false }, + ); + + expect(mockOrganizationService.updateById).toHaveBeenCalledWith( + 'org_123', + { backgroundCheckStepEnabled: false }, + ); + }); + }); + describe('updateRoleNotifications', () => { const validSettings = [ { diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx index 18ee9f5ecb..06f71cd001 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx @@ -357,6 +357,27 @@ describe('EmployeeBackgroundCheck', () => { ).not.toBeInTheDocument(); }); + it('prefers the org-level bypass over per-member exempt when both are set', () => { + render( + , + ); + + // Org-level bypass card wins; per-member exempt toggle should not appear. + expect( + screen.getByText(/background checks are disabled for your organization/i), + ).toBeInTheDocument(); + expect( + screen.queryByRole('switch', { name: /exempt this employee/i }), + ).not.toBeInTheDocument(); + }); + it('toggles exempt on and PATCHes /v1/people/:id', async () => { const user = userEvent.setup(); vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 'mem_1' }, status: 200 }); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx index 19c1c5cbd4..0a7a1323bb 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx @@ -272,8 +272,9 @@ export function EmployeeBackgroundCheck({
Background checks are not required for your organization - Comp AI support disabled this requirement. Existing background-check requests, if any, - remain accessible from your billing portal. + Background checks are disabled for your organization. This can be changed in People + > Settings. Existing background-check requests, if any, remain accessible from your + billing portal.
diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index a76c8bb16d..bf9060e018 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -64,7 +64,7 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: findingsContent={null} showRoleMapping={false} roleMappingContent={null} - showSettings={canManageOrgSettings} + showSettings={canManageOrgSettings && organization !== null} settingsContent={ canManageOrgSettings && organization ? ( Date: Tue, 5 May 2026 12:14:20 +0100 Subject: [PATCH 11/12] fix(app): honor custom roles for Settings tab + sync exempt state with header Co-Authored-By: Claude Sonnet 4.6 --- .../[employeeId]/components/Employee.tsx | 6 +++-- .../EmployeeBackgroundCheck.test.tsx | 27 +++++++++++++++++++ .../components/EmployeeBackgroundCheck.tsx | 13 ++++++++- .../app/src/app/(app)/[orgId]/people/page.tsx | 14 +++++++--- 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx index c511984343..0254b87746 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx @@ -69,6 +69,7 @@ export function Employee({ ? 'background-check' : 'details'; const [activeTab, setActiveTab] = useState(querySelectedTab); + const [memberExempt, setMemberExempt] = useState(memberBackgroundCheckExempt); useEffect(() => { if (querySelectedTab === 'background-check') { @@ -84,7 +85,7 @@ export function Employee({ orgId={orgId} backgroundCheck={initialBackgroundCheck} backgroundCheckStepEnabled={backgroundCheckStepEnabled} - memberBackgroundCheckExempt={memberBackgroundCheckExempt} + memberBackgroundCheckExempt={memberExempt} /> } > @@ -143,7 +144,8 @@ export function Employee({ initialBackgroundCheck={initialBackgroundCheck} initialBillingStatus={initialBackgroundCheckBillingStatus} backgroundCheckStepEnabled={backgroundCheckStepEnabled} - memberBackgroundCheckExempt={memberBackgroundCheckExempt} + memberBackgroundCheckExempt={memberExempt} + onMemberBackgroundCheckExemptChange={setMemberExempt} /> )} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx index 06f71cd001..0c9eeda411 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx @@ -378,6 +378,33 @@ describe('EmployeeBackgroundCheck', () => { ).not.toBeInTheDocument(); }); + it('calls onMemberBackgroundCheckExemptChange when toggled (controlled mode)', async () => { + const onChange = vi.fn(); + vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 'mem_1' }, status: 200 }); + const user = userEvent.setup(); + + render( + , + ); + + const toggle = screen.getByRole('switch', { + name: /exempt this employee/i, + }); + await user.click(toggle); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(true); + }); + }); + it('toggles exempt on and PATCHes /v1/people/:id', async () => { const user = userEvent.setup(); vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 'mem_1' }, status: 200 }); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx index 0a7a1323bb..a2fff42c43 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx @@ -36,6 +36,7 @@ interface EmployeeBackgroundCheckProps { initialBillingStatus: BackgroundCheckBillingStatus; backgroundCheckStepEnabled: boolean; memberBackgroundCheckExempt: boolean; + onMemberBackgroundCheckExemptChange?: (next: boolean) => void; } export function EmployeeBackgroundCheck({ @@ -45,6 +46,7 @@ export function EmployeeBackgroundCheck({ initialBillingStatus, backgroundCheckStepEnabled, memberBackgroundCheckExempt, + onMemberBackgroundCheckExemptChange, }: EmployeeBackgroundCheckProps) { const router = useRouter(); const pathname = usePathname(); @@ -59,7 +61,16 @@ export function EmployeeBackgroundCheck({ const [billingSetupComplete, setBillingSetupComplete] = useState(false); const [paymentIssue, setPaymentIssue] = useState(null); const [requestConfirmation, setRequestConfirmation] = useState(null); - const [exempt, setExempt] = useState(memberBackgroundCheckExempt); + const [internalExempt, setInternalExempt] = useState(memberBackgroundCheckExempt); + const isExemptControlled = onMemberBackgroundCheckExemptChange !== undefined; + const exempt = isExemptControlled ? memberBackgroundCheckExempt : internalExempt; + const setExempt = (next: boolean) => { + if (isExemptControlled) { + onMemberBackgroundCheckExemptChange(next); + } else { + setInternalExempt(next); + } + }; const [savingExempt, setSavingExempt] = useState(false); const { hasPermission } = usePermissions(); diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index bf9060e018..0e9dd0da77 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -1,4 +1,5 @@ -import { hasPermission, resolveBuiltInPermissions } from '@/lib/permissions'; +import { hasPermission } from '@/lib/permissions'; +import { resolveUserPermissions } from '@/lib/permissions.server'; import { auth } from '@/utils/auth'; import { db } from '@db/server'; import type { Metadata } from 'next'; @@ -36,8 +37,15 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: const canInviteUsers = canManageMembers || isAuditor; const isCurrentUserOwner = currentUserRoles.includes('owner'); - const { permissions } = resolveBuiltInPermissions(currentUserMember?.role); - const canManageOrgSettings = hasPermission(permissions, 'organization', 'update'); + const userPermissions = await resolveUserPermissions( + currentUserMember?.role ?? null, + orgId, + ); + const canManageOrgSettings = hasPermission( + userPermissions, + 'organization', + 'update', + ); const organization = canManageOrgSettings ? await db.organization.findUnique({ From 95d388556620483112458eb0e15d0a823b3dcb28 Mon Sep 17 00:00:00 2001 From: Mariano Date: Tue, 5 May 2026 12:20:42 +0100 Subject: [PATCH 12/12] fix(app): resync exempt state when prop changes upstream Apply the React render-time-adjust pattern in Employee.tsx and EmployeeBackgroundCheck.tsx so that lifted exempt state stays in sync with the prop after router.refresh() or upstream SWR revalidation. Add regression test covering the uncontrolled-mode resync path. Co-Authored-By: Claude Sonnet 4.6 --- .../[employeeId]/components/Employee.tsx | 6 ++++ .../EmployeeBackgroundCheck.test.tsx | 36 +++++++++++++++++++ .../components/EmployeeBackgroundCheck.tsx | 6 ++++ 3 files changed, 48 insertions(+) diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx index 0254b87746..1a0e8e19f4 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx @@ -70,6 +70,12 @@ export function Employee({ : 'details'; const [activeTab, setActiveTab] = useState(querySelectedTab); const [memberExempt, setMemberExempt] = useState(memberBackgroundCheckExempt); + const [lastSyncedExempt, setLastSyncedExempt] = useState(memberBackgroundCheckExempt); + + if (memberBackgroundCheckExempt !== lastSyncedExempt) { + setLastSyncedExempt(memberBackgroundCheckExempt); + setMemberExempt(memberBackgroundCheckExempt); + } useEffect(() => { if (querySelectedTab === 'background-check') { diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx index 0c9eeda411..4e1d530962 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx @@ -433,4 +433,40 @@ describe('EmployeeBackgroundCheck', () => { ); }); }); + + it('resyncs internal exempt state when the prop changes (uncontrolled mode)', () => { + const { rerender } = render( + , + ); + + // Wizard is rendered (member is not exempt) + expect( + screen.getByRole('switch', { name: /exempt this employee/i }), + ).toBeInTheDocument(); + expect(screen.queryByText(/this employee is exempt/i)).not.toBeInTheDocument(); + + // Parent re-renders with the prop flipped + rerender( + , + ); + + // Exempt info card is now rendered + expect( + screen.getByText(/this employee is exempt/i), + ).toBeInTheDocument(); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx index a2fff42c43..06fc84e578 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx @@ -62,6 +62,12 @@ export function EmployeeBackgroundCheck({ const [paymentIssue, setPaymentIssue] = useState(null); const [requestConfirmation, setRequestConfirmation] = useState(null); const [internalExempt, setInternalExempt] = useState(memberBackgroundCheckExempt); + const [lastSyncedExempt, setLastSyncedExempt] = useState(memberBackgroundCheckExempt); + + if (memberBackgroundCheckExempt !== lastSyncedExempt) { + setLastSyncedExempt(memberBackgroundCheckExempt); + setInternalExempt(memberBackgroundCheckExempt); + } const isExemptControlled = onMemberBackgroundCheckExemptChange !== undefined; const exempt = isExemptControlled ? memberBackgroundCheckExempt : internalExempt; const setExempt = (next: boolean) => {