Skip to content

Commit b56f7dc

Browse files
authored
fix(devices): propagate three-state compliance to employee & device drill-ins (CS-276)
fix(devices): propagate three-state compliance to employee & device drill-ins (CS-276)
1 parent 249a3fe commit b56f7dc

7 files changed

Lines changed: 589 additions & 39 deletions

File tree

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { render, screen } from '@testing-library/react';
2+
import type { ReactNode } from 'react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
5+
import type { DeviceWithChecks, FleetPolicy, Host } from '../../devices/types';
6+
7+
// Radix Tabs renders non-active content with `hidden`. For device-tab tests we
8+
// stub Tabs/TabsContent to always render children so assertions work without
9+
// user interaction.
10+
vi.mock('@trycompai/design-system', async (importOriginal) => {
11+
const mod = await importOriginal<typeof import('@trycompai/design-system')>();
12+
return {
13+
...mod,
14+
Tabs: ({ children }: { children: ReactNode }) => <div>{children}</div>,
15+
TabsList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
16+
TabsTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
17+
TabsContent: ({ children, value }: { children: ReactNode; value: string }) => (
18+
<div data-tab={value}>{children}</div>
19+
),
20+
};
21+
});
22+
23+
// PolicyItem pulls in heavy UI modules; none of its behaviour matters here.
24+
vi.mock('../../devices/components/PolicyItem', () => ({
25+
PolicyItem: () => null,
26+
}));
27+
28+
// Server actions invoked from download handlers — never triggered in these tests.
29+
vi.mock('../actions/download-training-certificate', () => ({
30+
downloadTrainingCertificate: vi.fn(),
31+
}));
32+
vi.mock('../actions/download-hipaa-certificate', () => ({
33+
downloadHipaaCertificate: vi.fn(),
34+
}));
35+
36+
import { EmployeeTasks } from './EmployeeTasks';
37+
38+
const baseEmployee = {
39+
id: 'mem_1',
40+
userId: 'usr_1',
41+
organizationId: 'org_1',
42+
role: 'employee',
43+
department: null,
44+
isActive: true,
45+
deactivated: false,
46+
fleetDmLabelId: null,
47+
createdAt: new Date(),
48+
updatedAt: new Date(),
49+
user: {
50+
id: 'usr_1',
51+
name: 'Jane Doe',
52+
email: 'jane@example.com',
53+
emailVerified: true,
54+
image: null,
55+
role: 'user',
56+
createdAt: new Date(),
57+
updatedAt: new Date(),
58+
banned: false,
59+
banReason: null,
60+
banExpires: null,
61+
},
62+
} as unknown as Parameters<typeof EmployeeTasks>[0]['employee'];
63+
64+
const baseOrganization = {
65+
id: 'org_1',
66+
name: 'Test Org',
67+
securityTrainingStepEnabled: true,
68+
deviceAgentStepEnabled: true,
69+
} as unknown as Parameters<typeof EmployeeTasks>[0]['organization'];
70+
71+
function makeDevice(overrides: Partial<DeviceWithChecks> = {}): DeviceWithChecks {
72+
return {
73+
id: 'dev_1',
74+
name: 'Jane MacBook',
75+
hostname: 'jane-mbp',
76+
platform: 'macos',
77+
osVersion: '14.0',
78+
serialNumber: 'SN1',
79+
hardwareModel: 'MBP',
80+
isCompliant: true,
81+
diskEncryptionEnabled: true,
82+
antivirusEnabled: true,
83+
passwordPolicySet: true,
84+
screenLockEnabled: true,
85+
checkDetails: null,
86+
lastCheckIn: new Date().toISOString(),
87+
agentVersion: '1.0.0',
88+
installedAt: new Date().toISOString(),
89+
memberId: 'mem_1',
90+
user: { name: 'Jane', email: 'jane@example.com' },
91+
source: 'device_agent',
92+
complianceStatus: 'compliant',
93+
daysSinceLastCheckIn: 0,
94+
...overrides,
95+
};
96+
}
97+
98+
function renderWithDevice(memberDevice: DeviceWithChecks | null) {
99+
return render(
100+
<EmployeeTasks
101+
employee={baseEmployee}
102+
policies={[]}
103+
trainingVideos={[]}
104+
host={null as unknown as Host}
105+
fleetPolicies={[] as FleetPolicy[]}
106+
organization={baseOrganization}
107+
memberDevice={memberDevice}
108+
hasHipaaFramework={false}
109+
hipaaCompletedAt={null}
110+
/>,
111+
);
112+
}
113+
114+
describe('EmployeeTasks device compliance badge', () => {
115+
it('shows "Compliant" badge when complianceStatus is compliant', () => {
116+
renderWithDevice(makeDevice({ complianceStatus: 'compliant' }));
117+
expect(screen.getByText('Compliant')).toBeInTheDocument();
118+
expect(screen.queryByText('Non-Compliant')).not.toBeInTheDocument();
119+
});
120+
121+
it('shows "Non-Compliant" badge when complianceStatus is non_compliant', () => {
122+
renderWithDevice(
123+
makeDevice({
124+
complianceStatus: 'non_compliant',
125+
isCompliant: false,
126+
diskEncryptionEnabled: false,
127+
}),
128+
);
129+
expect(screen.getByText('Non-Compliant')).toBeInTheDocument();
130+
});
131+
132+
it('shows "Stale (Nd)" badge and em-dash check badges when complianceStatus is stale', () => {
133+
renderWithDevice(
134+
makeDevice({
135+
complianceStatus: 'stale',
136+
daysSinceLastCheckIn: 12,
137+
}),
138+
);
139+
expect(screen.getByText('Stale (12d)')).toBeInTheDocument();
140+
expect(screen.queryByText('Compliant')).not.toBeInTheDocument();
141+
expect(screen.queryByText('Non-Compliant')).not.toBeInTheDocument();
142+
expect(screen.queryByText('Pass')).not.toBeInTheDocument();
143+
expect(screen.queryByText('Fail')).not.toBeInTheDocument();
144+
// One per check (4 checks)
145+
expect(screen.getAllByText('—').length).toBe(4);
146+
});
147+
148+
it('shows plain "Stale" when daysSinceLastCheckIn is null', () => {
149+
renderWithDevice(
150+
makeDevice({
151+
complianceStatus: 'stale',
152+
daysSinceLastCheckIn: null,
153+
lastCheckIn: null,
154+
}),
155+
);
156+
expect(screen.getByText('Stale')).toBeInTheDocument();
157+
});
158+
159+
it('sets stale badge title tooltip based on daysSinceLastCheckIn', () => {
160+
const { rerender } = renderWithDevice(
161+
makeDevice({ complianceStatus: 'stale', daysSinceLastCheckIn: 9 }),
162+
);
163+
expect(screen.getByText('Stale (9d)').closest('[title]')?.getAttribute('title')).toBe(
164+
'No check-in in 9 days',
165+
);
166+
167+
rerender(
168+
<EmployeeTasks
169+
employee={baseEmployee}
170+
policies={[]}
171+
trainingVideos={[]}
172+
host={null as unknown as Host}
173+
fleetPolicies={[] as FleetPolicy[]}
174+
organization={baseOrganization}
175+
memberDevice={makeDevice({
176+
complianceStatus: 'stale',
177+
daysSinceLastCheckIn: null,
178+
lastCheckIn: null,
179+
})}
180+
hasHipaaFramework={false}
181+
hipaaCompletedAt={null}
182+
/>,
183+
);
184+
expect(screen.getByText('Stale').closest('[title]')?.getAttribute('title')).toBe(
185+
'No check-ins recorded',
186+
);
187+
});
188+
});

apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,30 @@ const PLATFORM_LABELS: Record<string, string> = {
5252
linux: 'Linux',
5353
};
5454

55+
function staleLabel(daysSinceLastCheckIn: number | null): string {
56+
return daysSinceLastCheckIn === null ? 'Stale' : `Stale (${daysSinceLastCheckIn}d)`;
57+
}
58+
59+
function staleTitle(daysSinceLastCheckIn: number | null): string {
60+
return daysSinceLastCheckIn === null
61+
? 'No check-ins recorded'
62+
: `No check-in in ${daysSinceLastCheckIn} days`;
63+
}
64+
65+
function DeviceComplianceBadge({ device }: { device: DeviceWithChecks }) {
66+
if (device.complianceStatus === 'stale') {
67+
return (
68+
<Badge variant="secondary" title={staleTitle(device.daysSinceLastCheckIn)}>
69+
{staleLabel(device.daysSinceLastCheckIn)}
70+
</Badge>
71+
);
72+
}
73+
if (device.complianceStatus === 'compliant') {
74+
return <Badge variant="default">Compliant</Badge>;
75+
}
76+
return <Badge variant="destructive">Non-Compliant</Badge>;
77+
}
78+
5579
export const EmployeeTasks = ({
5680
employee,
5781
policies,
@@ -340,15 +364,14 @@ export const EmployeeTasks = ({
340364
{memberDevice.hardwareModel ? ` \u2022 ${memberDevice.hardwareModel}` : ''}
341365
</Text>
342366
</div>
343-
<Badge variant={memberDevice.isCompliant ? 'default' : 'destructive'}>
344-
{memberDevice.isCompliant ? 'Compliant' : 'Non-Compliant'}
345-
</Badge>
367+
<DeviceComplianceBadge device={memberDevice} />
346368
</div>
347369
</CardHeader>
348370
<CardContent>
349371
<div className="space-y-2">
350372
{CHECK_FIELDS.map(({ key, dbKey, label }) => {
351373
const isFleetUnsupported = memberDevice.source === 'fleet' && key !== 'diskEncryptionEnabled';
374+
const isStale = memberDevice.complianceStatus === 'stale';
352375
const passed = memberDevice[key];
353376
const details = memberDevice.checkDetails?.[dbKey];
354377
return (
@@ -358,7 +381,7 @@ export const EmployeeTasks = ({
358381
>
359382
<div>
360383
<span className="text-sm font-medium">{label}</span>
361-
{!isFleetUnsupported && details?.message && (
384+
{!isFleetUnsupported && !isStale && details?.message && (
362385
<p className="text-muted-foreground text-xs">
363386
{details.message}
364387
</p>
@@ -368,14 +391,21 @@ export const EmployeeTasks = ({
368391
Not tracked by Fleet
369392
</p>
370393
)}
371-
{details?.exception && (
394+
{!isFleetUnsupported && !isStale && details?.exception && (
372395
<p className="text-amber-600 dark:text-amber-400 text-xs mt-0.5">
373396
{details.exception}
374397
</p>
375398
)}
376399
</div>
377400
{isFleetUnsupported ? (
378401
<Badge variant="outline">N/A</Badge>
402+
) : isStale ? (
403+
<Badge
404+
variant="secondary"
405+
title={`${label} — unknown (device is stale)`}
406+
>
407+
408+
</Badge>
379409
) : (
380410
<Badge variant={passed ? 'default' : 'destructive'}>
381411
{passed ? 'Pass' : 'Fail'}

apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { useMemo } from 'react';
4040
import { useAgentDevices } from '../../devices/hooks/useAgentDevices';
4141
import { useFleetHosts } from '../../devices/hooks/useFleetHosts';
4242
import { buildDisplayItems, filterDisplayItems } from './filter-members';
43+
import { computeDeviceStatusMap } from './compute-device-status-map';
4344
import { MemberRow } from './MemberRow';
4445
import { PendingInvitationRow } from './PendingInvitationRow';
4546
import type { MemberWithUser, TaskCompletion, TeamMembersData } from './TeamMembers';
@@ -72,35 +73,10 @@ export function TeamMembersClient({
7273
const { fleetHosts, isLoading: isFleetHostsLoading } = useFleetHosts();
7374
const isDeviceStatusLoading = isAgentDevicesLoading || isFleetHostsLoading;
7475

75-
const deviceStatusMap = useMemo(() => {
76-
const map: Record<string, 'compliant' | 'non-compliant' | 'not-installed'> =
77-
{};
78-
const complianceSet = new Set(complianceMemberIds);
79-
for (const id of complianceSet) {
80-
map[id] = 'not-installed';
81-
}
82-
83-
const agentComplianceByMember = new Map<string, boolean>();
84-
for (const d of agentDevices) {
85-
if (!d.memberId || !complianceSet.has(d.memberId)) continue;
86-
const prev = agentComplianceByMember.get(d.memberId);
87-
agentComplianceByMember.set(d.memberId, (prev ?? true) && d.isCompliant);
88-
}
89-
for (const [memberId, allCompliant] of agentComplianceByMember) {
90-
map[memberId] = allCompliant ? 'compliant' : 'non-compliant';
91-
}
92-
93-
for (const host of fleetHosts) {
94-
if (!host.member_id || !complianceSet.has(host.member_id)) continue;
95-
if (agentComplianceByMember.has(host.member_id)) continue;
96-
const isCompliant = host.policies.every((p) => p.response === 'pass');
97-
if (map[host.member_id] !== 'non-compliant') {
98-
map[host.member_id] = isCompliant ? 'compliant' : 'non-compliant';
99-
}
100-
}
101-
102-
return map;
103-
}, [agentDevices, fleetHosts, complianceMemberIds]);
76+
const deviceStatusMap = useMemo(
77+
() => computeDeviceStatusMap({ agentDevices, fleetHosts, complianceMemberIds }),
78+
[agentDevices, fleetHosts, complianceMemberIds],
79+
);
10480
const router = useRouter();
10581
const [searchQuery, setSearchQuery] = useState('');
10682
const [roleFilter, setRoleFilter] = useState('');

0 commit comments

Comments
 (0)