Skip to content

Commit 0d59e8f

Browse files
Marfuenclaude
andauthored
fix(devices): flag stale device agents as non-compliant + CSV export (#2612)
* feat(utils): add device staleness helpers * feat(api): derive device complianceStatus on read * feat(api): surface stale status in /v1/devices * feat(trigger): daily cron to flag stale devices non-compliant * fix(trigger): surface error detail on flag-stale-devices failure * feat(devices): add client-side CSV export helper * fix(devices): harden CSV export (CRLF, BOM, filename sanitization) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(devices): render stale state and add CSV export button * refactor(devices): hoist orgId to parent, avoid per-row useParams * chore(utils): apply prettier formatting to devices.test.ts Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(devices): chart treats stale devices as non-compliant * chore(devices): use DS Button iconLeft prop for CSV export * fix(devices): address cubic P1/P2 review feedback - packages/utils: guard daysSinceCheckIn against NaN so corrupt lastCheckIn strings are treated as stale (P1). - devices-csv: neutralize CSV formula injection (leading =, +, -, @, tab, CR) by prefixing with apostrophe per OWASP (P2). - flag-stale-devices: maxDuration is in seconds, not ms (P2). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fa16795 commit 0d59e8f

17 files changed

Lines changed: 1111 additions & 62 deletions

File tree

apps/api/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
"@trycompai/db": "workspace:*",
8282
"@trycompai/email": "workspace:*",
8383
"@trycompai/integration-platform": "workspace:*",
84+
"@trycompai/utils": "workspace:*",
8485
"@upstash/ratelimit": "^2.0.8",
8586
"@upstash/redis": "^1.34.2",
8687
"@upstash/vector": "^1.2.2",
@@ -167,7 +168,8 @@
167168
"^@trycompai/company$": "<rootDir>/../../../packages/company/src/index.ts",
168169
"^@trycompai/db$": "@prisma/client",
169170
"^@trycompai/email$": "<rootDir>/../../../packages/email/index.ts",
170-
"^@trycompai/integration-platform$": "<rootDir>/../../../packages/integration-platform/src/index.ts"
171+
"^@trycompai/integration-platform$": "<rootDir>/../../../packages/integration-platform/src/index.ts",
172+
"^@trycompai/utils/(.*)$": "<rootDir>/../../../packages/utils/src/$1.ts"
171173
}
172174
},
173175
"license": "UNLICENSED",

apps/api/src/devices/devices.service.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
22
import { db } from '@db';
3+
import { getDeviceComplianceStatus } from '@trycompai/utils/devices';
34
import { FleetService } from '../lib/fleet.service';
45
import { DeviceResponseDto } from './dto/device-responses.dto';
56
import type { MemberResponseDto } from './dto/member-responses.dto';
@@ -331,7 +332,14 @@ export class DevicesService {
331332
dto.updated_at = device.updatedAt.toISOString();
332333
dto.display_name = device.name;
333334
dto.display_text = device.name;
334-
dto.status = device.isCompliant ? 'compliant' : 'non-compliant';
335+
const complianceStatus = getDeviceComplianceStatus({
336+
isCompliant: device.isCompliant,
337+
lastCheckIn: device.lastCheckIn,
338+
});
339+
// Keep the existing string shape ('compliant' | 'non-compliant' | 'stale') so
340+
// downstream API consumers see a predictable value.
341+
dto.status =
342+
complianceStatus === 'non_compliant' ? 'non-compliant' : complianceStatus;
335343
dto.disk_encryption_enabled = device.diskEncryptionEnabled;
336344
dto.source = 'device_agent';
337345
// Default empty values for FleetDM-specific fields

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import { db } from '@db/server';
1111
import type { Metadata } from 'next';
1212
import { headers } from 'next/headers';
1313
import { notFound, redirect } from 'next/navigation';
14+
import {
15+
daysSinceCheckIn,
16+
getDeviceComplianceStatus,
17+
} from '@trycompai/utils/devices';
1418
import type { CheckDetails, DeviceWithChecks } from '../devices/types';
1519
import { Employee } from './components/Employee';
1620

@@ -287,6 +291,11 @@ const getMemberDevice = async (
287291
return null;
288292
}
289293

294+
const complianceStatus = getDeviceComplianceStatus({
295+
isCompliant: device.isCompliant,
296+
lastCheckIn: device.lastCheckIn,
297+
});
298+
290299
return {
291300
id: device.id,
292301
name: device.name,
@@ -309,5 +318,7 @@ const getMemberDevice = async (
309318
email: device.member.user.email,
310319
},
311320
source: 'device_agent' as const,
321+
complianceStatus,
322+
daysSinceLastCheckIn: daysSinceCheckIn(device.lastCheckIn),
312323
};
313324
};
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
import { describe, expect, it, vi, beforeEach } from 'vitest';
3+
import type { DeviceWithChecks } from '../types';
4+
5+
vi.mock('next/navigation', () => ({
6+
useParams: () => ({ orgId: 'org_1' }),
7+
}));
8+
9+
vi.mock('../lib/devices-csv', async (importOriginal) => {
10+
const mod = await importOriginal<typeof import('../lib/devices-csv')>();
11+
return {
12+
...mod,
13+
downloadDevicesCsv: vi.fn(),
14+
};
15+
});
16+
17+
import { DeviceAgentDevicesList } from './DeviceAgentDevicesList';
18+
import { downloadDevicesCsv } from '../lib/devices-csv';
19+
20+
const mockDownload = vi.mocked(downloadDevicesCsv);
21+
22+
function makeDevice(overrides: Partial<DeviceWithChecks> = {}): DeviceWithChecks {
23+
return {
24+
id: 'dev_1',
25+
name: 'Mac',
26+
hostname: 'mac',
27+
platform: 'macos',
28+
osVersion: '14.0',
29+
serialNumber: 'SN1',
30+
hardwareModel: 'MBP',
31+
isCompliant: true,
32+
diskEncryptionEnabled: true,
33+
antivirusEnabled: true,
34+
passwordPolicySet: true,
35+
screenLockEnabled: true,
36+
checkDetails: null,
37+
lastCheckIn: new Date().toISOString(),
38+
agentVersion: '1.0.0',
39+
installedAt: new Date().toISOString(),
40+
memberId: 'mem_1',
41+
user: { name: 'Jane', email: 'jane@example.com' },
42+
source: 'device_agent',
43+
complianceStatus: 'compliant',
44+
daysSinceLastCheckIn: 0,
45+
...overrides,
46+
};
47+
}
48+
49+
beforeEach(() => {
50+
vi.clearAllMocks();
51+
});
52+
53+
describe('DeviceAgentDevicesList', () => {
54+
it('renders "Yes" for a compliant device', () => {
55+
render(<DeviceAgentDevicesList devices={[makeDevice()]} />);
56+
expect(screen.getByText('Yes')).toBeInTheDocument();
57+
});
58+
59+
it('renders "No" for a non-compliant fresh device', () => {
60+
render(
61+
<DeviceAgentDevicesList
62+
devices={[makeDevice({ isCompliant: false, complianceStatus: 'non_compliant' })]}
63+
/>,
64+
);
65+
expect(screen.getByText('No')).toBeInTheDocument();
66+
});
67+
68+
it('renders "Stale (Nd)" for a stale device and shows em-dash check badges', () => {
69+
render(
70+
<DeviceAgentDevicesList
71+
devices={[
72+
makeDevice({
73+
complianceStatus: 'stale',
74+
daysSinceLastCheckIn: 34,
75+
diskEncryptionEnabled: true,
76+
antivirusEnabled: true,
77+
passwordPolicySet: true,
78+
screenLockEnabled: true,
79+
}),
80+
]}
81+
/>,
82+
);
83+
expect(screen.getByText('Stale (34d)')).toBeInTheDocument();
84+
// All four check badges should show em-dash when stale.
85+
const dashBadges = screen.getAllByText('—');
86+
expect(dashBadges.length).toBe(4);
87+
});
88+
89+
it('renders "Stale" with no day count when lastCheckIn is null', () => {
90+
render(
91+
<DeviceAgentDevicesList
92+
devices={[
93+
makeDevice({
94+
lastCheckIn: null,
95+
complianceStatus: 'stale',
96+
daysSinceLastCheckIn: null,
97+
}),
98+
]}
99+
/>,
100+
);
101+
expect(screen.getByText('Stale')).toBeInTheDocument();
102+
});
103+
104+
it('shows the Export CSV button when devices are present', () => {
105+
render(<DeviceAgentDevicesList devices={[makeDevice()]} />);
106+
expect(screen.getByRole('button', { name: /export csv/i })).toBeInTheDocument();
107+
});
108+
109+
it('calls the CSV download with the built rows when clicked', () => {
110+
render(
111+
<DeviceAgentDevicesList
112+
devices={[
113+
makeDevice({ name: 'Alpha', id: 'a' }),
114+
makeDevice({ name: 'Beta', id: 'b', complianceStatus: 'stale', daysSinceLastCheckIn: 10 }),
115+
]}
116+
/>,
117+
);
118+
fireEvent.click(screen.getByRole('button', { name: /export csv/i }));
119+
expect(mockDownload).toHaveBeenCalledTimes(1);
120+
const [filename, contents] = mockDownload.mock.calls[0];
121+
expect(filename).toMatch(/^devices-org_1-\d{4}-\d{2}-\d{2}\.csv$/);
122+
expect(contents).toContain('Alpha');
123+
expect(contents).toContain('Beta');
124+
expect(contents).toContain('stale');
125+
});
126+
});

apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx

Lines changed: 85 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import {
44
Badge,
5+
Button,
56
Empty,
67
EmptyDescription,
78
EmptyHeader,
@@ -18,11 +19,16 @@ import {
1819
TableRow,
1920
Text,
2021
} from '@trycompai/design-system';
21-
import { Search } from '@trycompai/design-system/icons';
22+
import { Download, Search } from '@trycompai/design-system/icons';
2223
import Link from 'next/link';
2324
import { useParams } from 'next/navigation';
2425
import { useMemo, useState } from 'react';
2526
import type { DeviceWithChecks } from '../types';
27+
import {
28+
buildDevicesCsv,
29+
devicesCsvFilename,
30+
downloadDevicesCsv,
31+
} from '../lib/devices-csv';
2632
import { DeviceDetails } from './DeviceDetails';
2733

2834
export interface DeviceAgentDevicesListProps {
@@ -62,9 +68,11 @@ function isDeviceOnline(lastCheckIn: string | null): boolean {
6268
return diffMs < 2 * 60 * 60 * 1000;
6369
}
6470

65-
function UserNameCell({ device }: { device: DeviceWithChecks }) {
66-
const params = useParams();
67-
const orgId = params?.orgId as string;
71+
function staleLabel(daysSinceLastCheckIn: number | null): string {
72+
return daysSinceLastCheckIn === null ? 'Stale' : `Stale (${daysSinceLastCheckIn}d)`;
73+
}
74+
75+
function UserNameCell({ device, orgId }: { device: DeviceWithChecks; orgId: string }) {
6876
const memberId = device.memberId;
6977

7078
if (!memberId) {
@@ -90,7 +98,52 @@ function UserNameCell({ device }: { device: DeviceWithChecks }) {
9098
);
9199
}
92100

101+
function CompliantBadge({ device }: { device: DeviceWithChecks }) {
102+
if (device.complianceStatus === 'stale') {
103+
return (
104+
<Badge
105+
variant="secondary"
106+
title={
107+
device.daysSinceLastCheckIn === null
108+
? 'No check-ins recorded'
109+
: `No check-in in ${device.daysSinceLastCheckIn} days`
110+
}
111+
>
112+
{staleLabel(device.daysSinceLastCheckIn)}
113+
</Badge>
114+
);
115+
}
116+
if (device.complianceStatus === 'compliant') {
117+
return <Badge variant="default">Yes</Badge>;
118+
}
119+
return <Badge variant="destructive">No</Badge>;
120+
}
121+
122+
function CheckBadges({ device }: { device: DeviceWithChecks }) {
123+
if (device.complianceStatus === 'stale') {
124+
return (
125+
<div className="flex flex-wrap gap-1">
126+
{CHECK_FIELDS.map(({ key, label }) => (
127+
<Badge key={key} variant="secondary" title={`${label} — unknown (device is stale)`}>
128+
129+
</Badge>
130+
))}
131+
</div>
132+
);
133+
}
134+
return (
135+
<div className="flex flex-wrap gap-1">
136+
{CHECK_FIELDS.map(({ key, label }) => (
137+
<Badge key={key} variant={device[key] ? 'default' : 'destructive'}>
138+
{label}
139+
</Badge>
140+
))}
141+
</div>
142+
);
143+
}
144+
93145
export const DeviceAgentDevicesList = ({ devices }: DeviceAgentDevicesListProps) => {
146+
const { orgId } = useParams<{ orgId: string }>();
94147
const [selectedDevice, setSelectedDevice] = useState<DeviceWithChecks | null>(null);
95148
const [searchQuery, setSearchQuery] = useState('');
96149
const [page, setPage] = useState(1);
@@ -114,6 +167,12 @@ export const DeviceAgentDevicesList = ({ devices }: DeviceAgentDevicesListProps)
114167
return filteredDevices.slice(start, start + perPage);
115168
}, [filteredDevices, page, perPage]);
116169

170+
function handleExport() {
171+
const contents = buildDevicesCsv(devices);
172+
const filename = devicesCsvFilename({ orgId });
173+
downloadDevicesCsv(filename, contents);
174+
}
175+
117176
if (selectedDevice) {
118177
return <DeviceDetails device={selectedDevice} onClose={() => setSelectedDevice(null)} />;
119178
}
@@ -124,20 +183,25 @@ export const DeviceAgentDevicesList = ({ devices }: DeviceAgentDevicesListProps)
124183

125184
return (
126185
<Stack gap="4">
127-
<div className="w-full md:max-w-[300px]">
128-
<InputGroup>
129-
<InputGroupAddon>
130-
<Search size={16} />
131-
</InputGroupAddon>
132-
<InputGroupInput
133-
placeholder="Search devices..."
134-
value={searchQuery}
135-
onChange={(e) => {
136-
setSearchQuery(e.target.value);
137-
setPage(1);
138-
}}
139-
/>
140-
</InputGroup>
186+
<div className="flex w-full flex-col gap-2 md:flex-row md:items-center md:justify-between">
187+
<div className="w-full md:max-w-[300px]">
188+
<InputGroup>
189+
<InputGroupAddon>
190+
<Search size={16} />
191+
</InputGroupAddon>
192+
<InputGroupInput
193+
placeholder="Search devices..."
194+
value={searchQuery}
195+
onChange={(e) => {
196+
setSearchQuery(e.target.value);
197+
setPage(1);
198+
}}
199+
/>
200+
</InputGroup>
201+
</div>
202+
<Button variant="outline" iconLeft={<Download />} onClick={handleExport}>
203+
Export CSV
204+
</Button>
141205
</div>
142206

143207
{filteredDevices.length === 0 ? (
@@ -195,7 +259,7 @@ export const DeviceAgentDevicesList = ({ devices }: DeviceAgentDevicesListProps)
195259
</div>
196260
</TableCell>
197261
<TableCell>
198-
<UserNameCell device={device} />
262+
<UserNameCell device={device} orgId={orgId} />
199263
</TableCell>
200264
<TableCell>
201265
<div className="flex flex-col">
@@ -209,21 +273,10 @@ export const DeviceAgentDevicesList = ({ devices }: DeviceAgentDevicesListProps)
209273
<Text size="sm">{formatTimeAgo(device.lastCheckIn)}</Text>
210274
</TableCell>
211275
<TableCell>
212-
<div className="flex flex-wrap gap-1">
213-
{CHECK_FIELDS.map(({ key, label }) => (
214-
<Badge
215-
key={key}
216-
variant={device[key] ? 'default' : 'destructive'}
217-
>
218-
{label}
219-
</Badge>
220-
))}
221-
</div>
276+
<CheckBadges device={device} />
222277
</TableCell>
223278
<TableCell>
224-
<Badge variant={device.isCompliant ? 'default' : 'destructive'}>
225-
{device.isCompliant ? 'Yes' : 'No'}
226-
</Badge>
279+
<CompliantBadge device={device} />
227280
</TableCell>
228281
</TableRow>
229282
))}

0 commit comments

Comments
 (0)