diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.test.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.test.tsx new file mode 100644 index 0000000000..7edce89d8e --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.test.tsx @@ -0,0 +1,188 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { DeviceWithChecks, FleetPolicy, Host } from '../../devices/types'; + +// Radix Tabs renders non-active content with `hidden`. For device-tab tests we +// stub Tabs/TabsContent to always render children so assertions work without +// user interaction. +vi.mock('@trycompai/design-system', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + Tabs: ({ children }: { children: ReactNode }) =>
{children}
, + TabsList: ({ children }: { children: ReactNode }) =>
{children}
, + TabsTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + TabsContent: ({ children, value }: { children: ReactNode; value: string }) => ( +
{children}
+ ), + }; +}); + +// PolicyItem pulls in heavy UI modules; none of its behaviour matters here. +vi.mock('../../devices/components/PolicyItem', () => ({ + PolicyItem: () => null, +})); + +// Server actions invoked from download handlers — never triggered in these tests. +vi.mock('../actions/download-training-certificate', () => ({ + downloadTrainingCertificate: vi.fn(), +})); +vi.mock('../actions/download-hipaa-certificate', () => ({ + downloadHipaaCertificate: vi.fn(), +})); + +import { EmployeeTasks } from './EmployeeTasks'; + +const baseEmployee = { + id: 'mem_1', + userId: 'usr_1', + organizationId: 'org_1', + role: 'employee', + department: null, + isActive: true, + deactivated: false, + fleetDmLabelId: null, + createdAt: new Date(), + updatedAt: new Date(), + user: { + id: 'usr_1', + name: 'Jane Doe', + email: 'jane@example.com', + emailVerified: true, + image: null, + role: 'user', + createdAt: new Date(), + updatedAt: new Date(), + banned: false, + banReason: null, + banExpires: null, + }, +} as unknown as Parameters[0]['employee']; + +const baseOrganization = { + id: 'org_1', + name: 'Test Org', + securityTrainingStepEnabled: true, + deviceAgentStepEnabled: true, +} as unknown as Parameters[0]['organization']; + +function makeDevice(overrides: Partial = {}): DeviceWithChecks { + return { + id: 'dev_1', + name: 'Jane MacBook', + hostname: 'jane-mbp', + platform: 'macos', + osVersion: '14.0', + serialNumber: 'SN1', + hardwareModel: 'MBP', + isCompliant: true, + diskEncryptionEnabled: true, + antivirusEnabled: true, + passwordPolicySet: true, + screenLockEnabled: true, + checkDetails: null, + lastCheckIn: new Date().toISOString(), + agentVersion: '1.0.0', + installedAt: new Date().toISOString(), + memberId: 'mem_1', + user: { name: 'Jane', email: 'jane@example.com' }, + source: 'device_agent', + complianceStatus: 'compliant', + daysSinceLastCheckIn: 0, + ...overrides, + }; +} + +function renderWithDevice(memberDevice: DeviceWithChecks | null) { + return render( + , + ); +} + +describe('EmployeeTasks device compliance badge', () => { + it('shows "Compliant" badge when complianceStatus is compliant', () => { + renderWithDevice(makeDevice({ complianceStatus: 'compliant' })); + expect(screen.getByText('Compliant')).toBeInTheDocument(); + expect(screen.queryByText('Non-Compliant')).not.toBeInTheDocument(); + }); + + it('shows "Non-Compliant" badge when complianceStatus is non_compliant', () => { + renderWithDevice( + makeDevice({ + complianceStatus: 'non_compliant', + isCompliant: false, + diskEncryptionEnabled: false, + }), + ); + expect(screen.getByText('Non-Compliant')).toBeInTheDocument(); + }); + + it('shows "Stale (Nd)" badge and em-dash check badges when complianceStatus is stale', () => { + renderWithDevice( + makeDevice({ + complianceStatus: 'stale', + daysSinceLastCheckIn: 12, + }), + ); + expect(screen.getByText('Stale (12d)')).toBeInTheDocument(); + expect(screen.queryByText('Compliant')).not.toBeInTheDocument(); + expect(screen.queryByText('Non-Compliant')).not.toBeInTheDocument(); + expect(screen.queryByText('Pass')).not.toBeInTheDocument(); + expect(screen.queryByText('Fail')).not.toBeInTheDocument(); + // One per check (4 checks) + expect(screen.getAllByText('—').length).toBe(4); + }); + + it('shows plain "Stale" when daysSinceLastCheckIn is null', () => { + renderWithDevice( + makeDevice({ + complianceStatus: 'stale', + daysSinceLastCheckIn: null, + lastCheckIn: null, + }), + ); + expect(screen.getByText('Stale')).toBeInTheDocument(); + }); + + it('sets stale badge title tooltip based on daysSinceLastCheckIn', () => { + const { rerender } = renderWithDevice( + makeDevice({ complianceStatus: 'stale', daysSinceLastCheckIn: 9 }), + ); + expect(screen.getByText('Stale (9d)').closest('[title]')?.getAttribute('title')).toBe( + 'No check-in in 9 days', + ); + + rerender( + , + ); + expect(screen.getByText('Stale').closest('[title]')?.getAttribute('title')).toBe( + 'No check-ins recorded', + ); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx index ecb62cb7ab..be68e5506d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx @@ -52,6 +52,30 @@ const PLATFORM_LABELS: Record = { linux: 'Linux', }; +function staleLabel(daysSinceLastCheckIn: number | null): string { + return daysSinceLastCheckIn === null ? 'Stale' : `Stale (${daysSinceLastCheckIn}d)`; +} + +function staleTitle(daysSinceLastCheckIn: number | null): string { + return daysSinceLastCheckIn === null + ? 'No check-ins recorded' + : `No check-in in ${daysSinceLastCheckIn} days`; +} + +function DeviceComplianceBadge({ device }: { device: DeviceWithChecks }) { + if (device.complianceStatus === 'stale') { + return ( + + {staleLabel(device.daysSinceLastCheckIn)} + + ); + } + if (device.complianceStatus === 'compliant') { + return Compliant; + } + return Non-Compliant; +} + export const EmployeeTasks = ({ employee, policies, @@ -340,15 +364,14 @@ export const EmployeeTasks = ({ {memberDevice.hardwareModel ? ` \u2022 ${memberDevice.hardwareModel}` : ''} - - {memberDevice.isCompliant ? 'Compliant' : 'Non-Compliant'} - +
{CHECK_FIELDS.map(({ key, dbKey, label }) => { const isFleetUnsupported = memberDevice.source === 'fleet' && key !== 'diskEncryptionEnabled'; + const isStale = memberDevice.complianceStatus === 'stale'; const passed = memberDevice[key]; const details = memberDevice.checkDetails?.[dbKey]; return ( @@ -358,7 +381,7 @@ export const EmployeeTasks = ({ >
{label} - {!isFleetUnsupported && details?.message && ( + {!isFleetUnsupported && !isStale && details?.message && (

{details.message}

@@ -368,7 +391,7 @@ export const EmployeeTasks = ({ Not tracked by Fleet

)} - {details?.exception && ( + {!isFleetUnsupported && !isStale && details?.exception && (

{details.exception}

@@ -376,6 +399,13 @@ export const EmployeeTasks = ({
{isFleetUnsupported ? ( N/A + ) : isStale ? ( + + — + ) : ( {passed ? 'Pass' : 'Fail'} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index 8722da9725..d81dd3d58b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -40,6 +40,7 @@ import { useMemo } from 'react'; import { useAgentDevices } from '../../devices/hooks/useAgentDevices'; import { useFleetHosts } from '../../devices/hooks/useFleetHosts'; import { buildDisplayItems, filterDisplayItems } from './filter-members'; +import { computeDeviceStatusMap } from './compute-device-status-map'; import { MemberRow } from './MemberRow'; import { PendingInvitationRow } from './PendingInvitationRow'; import type { MemberWithUser, TaskCompletion, TeamMembersData } from './TeamMembers'; @@ -72,35 +73,10 @@ export function TeamMembersClient({ const { fleetHosts, isLoading: isFleetHostsLoading } = useFleetHosts(); const isDeviceStatusLoading = isAgentDevicesLoading || isFleetHostsLoading; - const deviceStatusMap = useMemo(() => { - const map: Record = - {}; - const complianceSet = new Set(complianceMemberIds); - for (const id of complianceSet) { - map[id] = 'not-installed'; - } - - const agentComplianceByMember = new Map(); - for (const d of agentDevices) { - if (!d.memberId || !complianceSet.has(d.memberId)) continue; - const prev = agentComplianceByMember.get(d.memberId); - agentComplianceByMember.set(d.memberId, (prev ?? true) && d.isCompliant); - } - for (const [memberId, allCompliant] of agentComplianceByMember) { - map[memberId] = allCompliant ? 'compliant' : 'non-compliant'; - } - - for (const host of fleetHosts) { - if (!host.member_id || !complianceSet.has(host.member_id)) continue; - if (agentComplianceByMember.has(host.member_id)) continue; - const isCompliant = host.policies.every((p) => p.response === 'pass'); - if (map[host.member_id] !== 'non-compliant') { - map[host.member_id] = isCompliant ? 'compliant' : 'non-compliant'; - } - } - - return map; - }, [agentDevices, fleetHosts, complianceMemberIds]); + const deviceStatusMap = useMemo( + () => computeDeviceStatusMap({ agentDevices, fleetHosts, complianceMemberIds }), + [agentDevices, fleetHosts, complianceMemberIds], + ); const router = useRouter(); const [searchQuery, setSearchQuery] = useState(''); const [roleFilter, setRoleFilter] = useState(''); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.test.ts b/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.test.ts new file mode 100644 index 0000000000..4115120731 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; + +import type { DeviceWithChecks, Host } from '../../devices/types'; +import { computeDeviceStatusMap } from './compute-device-status-map'; + +function makeAgentDevice(overrides: Partial = {}): DeviceWithChecks { + return { + id: `dev_${Math.random().toString(36).slice(2)}`, + name: 'Laptop', + hostname: 'laptop', + platform: 'macos', + osVersion: '14.0', + serialNumber: 'SN', + hardwareModel: 'MBP', + isCompliant: true, + diskEncryptionEnabled: true, + antivirusEnabled: true, + passwordPolicySet: true, + screenLockEnabled: true, + checkDetails: null, + lastCheckIn: new Date().toISOString(), + agentVersion: '1.0.0', + installedAt: new Date().toISOString(), + memberId: 'mem_1', + user: { name: 'A', email: 'a@example.com' }, + source: 'device_agent', + complianceStatus: 'compliant', + daysSinceLastCheckIn: 0, + ...overrides, + }; +} + +function makeFleetHost(overrides: Partial = {}): Host { + return { + id: Math.floor(Math.random() * 10000), + member_id: 'mem_2', + policies: [{ id: 1, name: 'Disk Encryption', response: 'pass' }], + ...overrides, + } as unknown as Host; +} + +describe('computeDeviceStatusMap', () => { + it('returns not-installed for members with no device', () => { + const map = computeDeviceStatusMap({ + agentDevices: [], + fleetHosts: [], + complianceMemberIds: ['mem_1', 'mem_2'], + }); + expect(map).toEqual({ mem_1: 'not-installed', mem_2: 'not-installed' }); + }); + + it('returns compliant when all agent devices for a member are compliant', () => { + const map = computeDeviceStatusMap({ + agentDevices: [ + makeAgentDevice({ memberId: 'mem_1', complianceStatus: 'compliant' }), + makeAgentDevice({ memberId: 'mem_1', complianceStatus: 'compliant' }), + ], + fleetHosts: [], + complianceMemberIds: ['mem_1'], + }); + expect(map.mem_1).toBe('compliant'); + }); + + it('returns non-compliant when any agent device is non_compliant', () => { + const map = computeDeviceStatusMap({ + agentDevices: [ + makeAgentDevice({ memberId: 'mem_1', complianceStatus: 'compliant' }), + makeAgentDevice({ memberId: 'mem_1', complianceStatus: 'non_compliant' }), + ], + fleetHosts: [], + complianceMemberIds: ['mem_1'], + }); + expect(map.mem_1).toBe('non-compliant'); + }); + + it('counts a stale device as non-compliant in the roll-up', () => { + const map = computeDeviceStatusMap({ + agentDevices: [ + makeAgentDevice({ + memberId: 'mem_1', + complianceStatus: 'stale', + daysSinceLastCheckIn: 15, + isCompliant: true, // raw field — should NOT matter + }), + ], + fleetHosts: [], + complianceMemberIds: ['mem_1'], + }); + expect(map.mem_1).toBe('non-compliant'); + }); + + it('counts stale among mixed devices as non-compliant', () => { + const map = computeDeviceStatusMap({ + agentDevices: [ + makeAgentDevice({ memberId: 'mem_1', complianceStatus: 'compliant' }), + makeAgentDevice({ + memberId: 'mem_1', + complianceStatus: 'stale', + daysSinceLastCheckIn: 8, + }), + ], + fleetHosts: [], + complianceMemberIds: ['mem_1'], + }); + expect(map.mem_1).toBe('non-compliant'); + }); + + it('falls back to Fleet policy status when no agent device is present', () => { + const map = computeDeviceStatusMap({ + agentDevices: [], + fleetHosts: [ + makeFleetHost({ + member_id: 'mem_1', + policies: [ + { id: 1, name: 'Enc', response: 'pass' }, + { id: 2, name: 'AV', response: 'pass' }, + ], + }), + ], + complianceMemberIds: ['mem_1'], + }); + expect(map.mem_1).toBe('compliant'); + }); + + it('prefers agent device status over fleet for the same member', () => { + const map = computeDeviceStatusMap({ + agentDevices: [ + makeAgentDevice({ + memberId: 'mem_1', + complianceStatus: 'stale', + daysSinceLastCheckIn: 10, + }), + ], + fleetHosts: [ + makeFleetHost({ + member_id: 'mem_1', + policies: [{ id: 1, name: 'Enc', response: 'pass' }], + }), + ], + complianceMemberIds: ['mem_1'], + }); + // Agent says stale → non-compliant, even if fleet would say compliant. + expect(map.mem_1).toBe('non-compliant'); + }); + + it('ignores devices for members not in the compliance set', () => { + const map = computeDeviceStatusMap({ + agentDevices: [ + makeAgentDevice({ memberId: 'mem_admin', complianceStatus: 'non_compliant' }), + ], + fleetHosts: [], + complianceMemberIds: ['mem_1'], + }); + expect(map).toEqual({ mem_1: 'not-installed' }); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.ts b/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.ts new file mode 100644 index 0000000000..629ad4745c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.ts @@ -0,0 +1,53 @@ +import type { DeviceWithChecks, Host } from '../../devices/types'; + +export type MemberDeviceStatus = 'compliant' | 'non-compliant' | 'not-installed'; + +/** + * Roll-up per-member device compliance for the People table. + * + * Rules (in order): + * 1. Every member in `complianceMemberIds` starts as `not-installed`. + * 2. For each agent device with a memberId in the set, ALL of that member's + * devices must have `complianceStatus === 'compliant'` to roll up to + * `compliant`. `non_compliant` and `stale` both count as non-compliant. + * 3. If a member has no agent device but has a Fleet host, we fall back to + * Fleet policy status. Agent data always wins when present. + */ +export function computeDeviceStatusMap({ + agentDevices, + fleetHosts, + complianceMemberIds, +}: { + agentDevices: DeviceWithChecks[]; + fleetHosts: Host[]; + complianceMemberIds: string[]; +}): Record { + const map: Record = {}; + const complianceSet = new Set(complianceMemberIds); + for (const id of complianceSet) { + map[id] = 'not-installed'; + } + + const agentComplianceByMember = new Map(); + for (const d of agentDevices) { + if (!d.memberId || !complianceSet.has(d.memberId)) continue; + const prev = agentComplianceByMember.get(d.memberId); + // Stale devices count as non-compliant for the roll-up. + const isCompliant = d.complianceStatus === 'compliant'; + agentComplianceByMember.set(d.memberId, (prev ?? true) && isCompliant); + } + for (const [memberId, allCompliant] of agentComplianceByMember) { + map[memberId] = allCompliant ? 'compliant' : 'non-compliant'; + } + + for (const host of fleetHosts) { + if (!host.member_id || !complianceSet.has(host.member_id)) continue; + if (agentComplianceByMember.has(host.member_id)) continue; + const isCompliant = host.policies.every((p) => p.response === 'pass'); + if (map[host.member_id] !== 'non-compliant') { + map[host.member_id] = isCompliant ? 'compliant' : 'non-compliant'; + } + } + + return map; +} diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.test.tsx new file mode 100644 index 0000000000..f79c8a6742 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.test.tsx @@ -0,0 +1,113 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { DeviceWithChecks } from '../types'; +import { DeviceDetails } from './DeviceDetails'; + +function makeDevice(overrides: Partial = {}): DeviceWithChecks { + return { + id: 'dev_1', + name: 'Work Laptop', + hostname: 'work-laptop', + platform: 'macos', + osVersion: '14.0', + serialNumber: 'SN123', + hardwareModel: 'MBP', + isCompliant: true, + diskEncryptionEnabled: true, + antivirusEnabled: true, + passwordPolicySet: true, + screenLockEnabled: true, + checkDetails: null, + lastCheckIn: new Date().toISOString(), + agentVersion: '1.0.0', + installedAt: new Date().toISOString(), + memberId: 'mem_1', + user: { name: 'Jane', email: 'jane@example.com' }, + source: 'device_agent', + complianceStatus: 'compliant', + daysSinceLastCheckIn: 0, + ...overrides, + }; +} + +describe('DeviceDetails compliance badge', () => { + it('renders "Compliant" when complianceStatus is compliant', () => { + render(); + expect(screen.getByText('Compliant')).toBeInTheDocument(); + expect(screen.queryByText('Non-Compliant')).not.toBeInTheDocument(); + }); + + it('renders "Non-Compliant" when complianceStatus is non_compliant', () => { + render( + , + ); + expect(screen.getByText('Non-Compliant')).toBeInTheDocument(); + }); + + it('renders "Stale (Nd)" and em-dash result badges when complianceStatus is stale', () => { + render( + , + ); + expect(screen.getByText('Stale (21d)')).toBeInTheDocument(); + expect(screen.queryByText('Pass')).not.toBeInTheDocument(); + expect(screen.queryByText('Fail')).not.toBeInTheDocument(); + // 4 check rows + the 4 exception cells + 4 detail cells all show '—' + const dashes = screen.getAllByText('—'); + // At minimum: 4 result badges (stale) + expect(dashes.length).toBeGreaterThanOrEqual(4); + }); + + it('renders bare "Stale" when daysSinceLastCheckIn is null', () => { + render( + , + ); + expect(screen.getByText('Stale')).toBeInTheDocument(); + }); + + it('sets stale badge title tooltip based on daysSinceLastCheckIn', () => { + const { rerender } = render( + , + ); + expect(screen.getByText('Stale (30d)').closest('[title]')?.getAttribute('title')).toBe( + 'No check-in in 30 days', + ); + + rerender( + , + ); + expect(screen.getByText('Stale').closest('[title]')?.getAttribute('title')).toBe( + 'No check-ins recorded', + ); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx index c5c51205ab..af5b350f8a 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx @@ -38,6 +38,30 @@ function isDeviceOnline(lastCheckIn: string | null): boolean { return diffMs < 2 * 60 * 60 * 1000; } +function staleLabel(daysSinceLastCheckIn: number | null): string { + return daysSinceLastCheckIn === null ? 'Stale' : `Stale (${daysSinceLastCheckIn}d)`; +} + +function staleTitle(daysSinceLastCheckIn: number | null): string { + return daysSinceLastCheckIn === null + ? 'No check-ins recorded' + : `No check-in in ${daysSinceLastCheckIn} days`; +} + +function DeviceComplianceBadge({ device }: { device: DeviceWithChecks }) { + if (device.complianceStatus === 'stale') { + return ( + + {staleLabel(device.daysSinceLastCheckIn)} + + ); + } + if (device.complianceStatus === 'compliant') { + return Compliant; + } + return Non-Compliant; +} + interface DeviceDetailsProps { device: DeviceWithChecks; onClose: () => void; @@ -78,9 +102,7 @@ export const DeviceDetails = ({ device, onClose }: DeviceDetailsProps) => { {device.hardwareModel ? ` \u2022 ${device.hardwareModel}` : ''}
- - {device.isCompliant ? 'Compliant' : 'Non-Compliant'} - + @@ -154,6 +176,7 @@ export const DeviceDetails = ({ device, onClose }: DeviceDetailsProps) => { {CHECK_FIELDS.map(({ key, dbKey, label }) => { const isFleetUnsupported = device.source === 'fleet' && key !== 'diskEncryptionEnabled'; + const isStale = device.complianceStatus === 'stale'; const passed = device[key]; const details = device.checkDetails?.[dbKey]; return ( @@ -165,12 +188,23 @@ export const DeviceDetails = ({ device, onClose }: DeviceDetailsProps) => { - {isFleetUnsupported ? 'Not tracked by Fleet' : (details?.message ?? '—')} + {isFleetUnsupported + ? 'Not tracked by Fleet' + : isStale + ? '—' + : (details?.message ?? '—')} {isFleetUnsupported ? ( N/A + ) : isStale ? ( + + — + ) : ( {passed ? 'Pass' : 'Fail'} @@ -179,7 +213,7 @@ export const DeviceDetails = ({ device, onClose }: DeviceDetailsProps) => { - {details?.exception ?? '—'} + {isStale ? '—' : (details?.exception ?? '—')}