Skip to content

Commit b1b072e

Browse files
tofikwestclaude
andauthored
fix(portal): recognize device agent compliance for task completion (#2152)
* fix(portal): recognize device agent compliance for task completion The portal was only checking FleetDM data to determine if the device agent task was complete. Device agent check results (stored in the Device table) were being ignored, so even when all agent checks passed, the portal still showed the task as incomplete. Now checks both sources: Fleet device + policies OR device agent isCompliant flag. Either one being complete marks the task as done. Also updates Prisma in API Dockerfile from 6.13.0 to 6.18.0 to match the version used in packages/db. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prevent null reference crash when only agent device exists The installed device view assumed a Fleet host always exists, but with the new agent device support, hasInstalledAgent can be true from agentDevice alone while host is null. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: device agent takes priority over Fleet in portal When both Fleet and device agent exist, device agent completion and UI now takes priority. Fleet is still fully supported as fallback for orgs that only use Fleet. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: correct download filename and instructions for all platforms - Add LINUX_FILENAME export and use it for Linux downloads (was falling through to WINDOWS_FILENAME) - Add Linux-specific install instruction for DEB packages - Unify step 3 to show device agent login instructions for all platforms (was showing Fleet MDM instructions for non-macOS) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add Linux to system requirements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prevent unchecked device from hiding compliant device PostgreSQL puts NULL values first in DESC ordering, so a newly registered device (lastCheckIn=null) would be selected over an older compliant device. Use nulls:'last' to prefer devices that have actually checked in. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add SWR polling for agent device compliance status Agent device status was fetched once server-side and never refreshed, so compliance changes required a full page reload. Now polls /api/device-agent/status every 30s and revalidates on tab focus, matching the Fleet SWR pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: sort agent devices by lastCheckIn to match server-side ordering The status API returns devices sorted by installedAt, but page.tsx fetches by lastCheckIn desc nulls last. Without client-side sorting, SWR could pick a freshly registered device (null lastCheckIn) over an older compliant one, causing the task to flash back to incomplete. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fd648bd commit b1b072e

6 files changed

Lines changed: 112 additions & 44 deletions

File tree

apps/api/Dockerfile.multistage

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ ENV NODE_ENV=production
9292
ENV PORT=3333
9393

9494
# Install Prisma CLI and regenerate client in production stage
95-
RUN npm install -g prisma@6.13.0 && \
95+
RUN npm install -g prisma@6.18.0 && \
9696
prisma generate --schema=./prisma/schema.prisma
9797

9898
# Create non-root user

apps/portal/src/app/(app)/(home)/[orgId]/components/EmployeeTasksList.tsx

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { trainingVideos } from '@/lib/data/training-videos';
44
import { evidenceFormDefinitionList } from '@comp/company';
55
import { Accordion } from '@comp/ui/accordion';
66
import { Card, CardContent } from '@comp/ui/card';
7-
import type { EmployeeTrainingVideoCompletion, Member, Policy, PolicyVersion } from '@db';
7+
import type { Device, EmployeeTrainingVideoCompletion, Member, Policy, PolicyVersion } from '@db';
88
import { Button } from '@trycompai/design-system';
99
import Link from 'next/link';
1010
import { CheckCircle2 } from 'lucide-react';
@@ -27,6 +27,7 @@ interface EmployeeTasksListProps {
2727
member: Member;
2828
fleetPolicies: FleetPolicy[];
2929
host: Host | null;
30+
agentDevice: Device | null;
3031
deviceAgentStepEnabled: boolean;
3132
securityTrainingStepEnabled: boolean;
3233
whistleblowerReportEnabled: boolean;
@@ -40,6 +41,7 @@ export const EmployeeTasksList = ({
4041
member,
4142
fleetPolicies,
4243
host,
44+
agentDevice,
4345
deviceAgentStepEnabled,
4446
securityTrainingStepEnabled,
4547
whistleblowerReportEnabled,
@@ -64,18 +66,50 @@ export const EmployeeTasksList = ({
6466
},
6567
);
6668

69+
// Poll agent device status so compliance updates appear without full reload
70+
const { data: agentDeviceResponse } = useSWR<{ devices: Device[] }>(
71+
deviceAgentStepEnabled
72+
? `/api/device-agent/status?organizationId=${organizationId}`
73+
: null,
74+
async (url) => {
75+
const res = await fetch(url);
76+
if (!res.ok) throw new Error('Failed to fetch');
77+
return res.json();
78+
},
79+
{
80+
fallbackData: agentDevice ? { devices: [agentDevice] } : { devices: [] },
81+
refreshInterval: 30_000,
82+
revalidateOnFocus: true,
83+
revalidateOnMount: false,
84+
},
85+
);
86+
6787
if (!response) {
6888
return null;
6989
}
7090

91+
// Pick the most recently checked-in device (matching page.tsx ordering: lastCheckIn desc, nulls last)
92+
const currentAgentDevice =
93+
agentDeviceResponse?.devices
94+
?.sort((a, b) => {
95+
if (!a.lastCheckIn && !b.lastCheckIn) return 0;
96+
if (!a.lastCheckIn) return 1;
97+
if (!b.lastCheckIn) return -1;
98+
return new Date(b.lastCheckIn).getTime() - new Date(a.lastCheckIn).getTime();
99+
})[0] ?? null;
100+
71101
// Check completion status
72102
const hasAcceptedPolicies =
73103
policies.length === 0 || policies.every((p) => p.signedBy.includes(member.id));
74-
const hasInstalledAgent = response.device !== null;
75-
const allFleetPoliciesPass =
76-
response.fleetPolicies.length === 0 ||
77-
response.fleetPolicies.every((policy) => policy.response === 'pass');
78-
const hasCompletedDeviceSetup = hasInstalledAgent && allFleetPoliciesPass;
104+
105+
// Device agent takes priority over Fleet for completion
106+
const hasAgentDevice = currentAgentDevice !== null;
107+
const hasFleetDevice = response.device !== null;
108+
const hasCompletedDeviceSetup = hasAgentDevice
109+
? currentAgentDevice.isCompliant
110+
: hasFleetDevice &&
111+
(response.fleetPolicies.length === 0 ||
112+
response.fleetPolicies.every((policy) => policy.response === 'pass'));
79113

80114
// Calculate general training completion (matching logic from GeneralTrainingAccordionItem)
81115
const generalTrainingVideoIds = trainingVideos
@@ -109,6 +143,7 @@ export const EmployeeTasksList = ({
109143
<DeviceAgentAccordionItem
110144
member={member}
111145
host={response.device}
146+
agentDevice={currentAgentDevice}
112147
fleetPolicies={response.fleetPolicies}
113148
isLoading={isValidating}
114149
fetchFleetPolicies={fetchFleetPolicies}

apps/portal/src/app/(app)/(home)/[orgId]/components/OrganizationDashboard.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Member, Organization, User } from '@db';
1+
import type { Device, Member, Organization, User } from '@db';
22
import { db } from '@db';
33
import { NoAccessMessage } from '../../components/NoAccessMessage';
44
import type { FleetPolicy, Host } from '../types';
@@ -15,13 +15,15 @@ interface OrganizationDashboardProps {
1515
member: MemberWithUserOrg;
1616
fleetPolicies: FleetPolicy[];
1717
host: Host | null;
18+
agentDevice: Device | null;
1819
}
1920

2021
export async function OrganizationDashboard({
2122
organizationId,
2223
member,
2324
fleetPolicies,
2425
host,
26+
agentDevice,
2527
}: OrganizationDashboardProps) {
2628
// Fetch policies specific to the selected organization
2729
const policies = await db.policy.findMany({
@@ -68,6 +70,7 @@ export async function OrganizationDashboard({
6870
member={member}
6971
fleetPolicies={fleetPolicies}
7072
host={host}
73+
agentDevice={agentDevice}
7174
deviceAgentStepEnabled={org.deviceAgentStepEnabled}
7275
securityTrainingStepEnabled={org.securityTrainingStepEnabled}
7376
whistleblowerReportEnabled={org.whistleblowerReportEnabled}

apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import {
4+
LINUX_FILENAME,
45
MAC_APPLE_SILICON_FILENAME,
56
MAC_INTEL_FILENAME,
67
WINDOWS_FILENAME,
@@ -10,7 +11,7 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@c
1011
import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card';
1112
import { cn } from '@comp/ui/cn';
1213
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select';
13-
import type { Member } from '@db';
14+
import type { Device, Member } from '@db';
1415
import { Button } from '@trycompai/design-system';
1516
import { CheckCircle2, Circle, Download, Loader2, RefreshCw } from 'lucide-react';
1617
import { useEffect, useMemo, useState } from 'react';
@@ -21,6 +22,7 @@ import { FleetPolicyItem } from './FleetPolicyItem';
2122
interface DeviceAgentAccordionItemProps {
2223
member: Member;
2324
host: Host | null;
25+
agentDevice: Device | null;
2426
isLoading: boolean;
2527
fleetPolicies?: FleetPolicy[];
2628
fetchFleetPolicies: () => void;
@@ -29,6 +31,7 @@ interface DeviceAgentAccordionItemProps {
2931
export function DeviceAgentAccordionItem({
3032
member,
3133
host,
34+
agentDevice,
3235
isLoading,
3336
fleetPolicies = [],
3437
fetchFleetPolicies,
@@ -41,13 +44,20 @@ export function DeviceAgentAccordionItem({
4144
[detectedOS],
4245
);
4346

44-
const hasInstalledAgent = host !== null;
47+
const hasFleetDevice = host !== null;
48+
const hasAgentDevice = agentDevice !== null;
49+
const hasInstalledAgent = hasFleetDevice || hasAgentDevice;
4550
const failedPoliciesCount = useMemo(
4651
() => fleetPolicies.filter((policy) => policy.response !== 'pass').length,
4752
[fleetPolicies],
4853
);
4954

50-
const isCompleted = hasInstalledAgent && failedPoliciesCount === 0;
55+
// Device agent takes priority over Fleet
56+
const isCompleted = hasAgentDevice
57+
? agentDevice.isCompliant
58+
: hasFleetDevice
59+
? failedPoliciesCount === 0
60+
: false;
5161

5262
const handleDownload = async () => {
5363
if (!detectedOS) {
@@ -87,6 +97,8 @@ export function DeviceAgentAccordionItem({
8797
// Set filename based on OS and architecture
8898
if (isMacOS) {
8999
a.download = detectedOS === 'macos' ? MAC_APPLE_SILICON_FILENAME : MAC_INTEL_FILENAME;
100+
} else if (detectedOS === 'linux') {
101+
a.download = LINUX_FILENAME;
90102
} else {
91103
a.download = WINDOWS_FILENAME;
92104
}
@@ -149,7 +161,7 @@ export function DeviceAgentAccordionItem({
149161
<span className={cn('text-base', isCompleted && 'text-muted-foreground line-through')}>
150162
Device Agent
151163
</span>
152-
{hasInstalledAgent && failedPoliciesCount > 0 && (
164+
{!hasAgentDevice && hasFleetDevice && failedPoliciesCount > 0 && (
153165
<span className="text-amber-600 dark:text-amber-400 text-xs ml-auto">
154166
{failedPoliciesCount} policies failing
155167
</span>
@@ -196,40 +208,47 @@ export function DeviceAgentAccordionItem({
196208
<p className="mt-1">
197209
{isMacOS
198210
? 'Double-click the downloaded DMG file and follow the installation instructions.'
199-
: 'Double-click the downloaded EXE file and follow the installation instructions.'}
211+
: detectedOS === 'linux'
212+
? 'Install the downloaded DEB package using your package manager or by double-clicking it.'
213+
: 'Double-click the downloaded EXE file and follow the installation instructions.'}
214+
</p>
215+
</li>
216+
<li>
217+
<strong>Login with your work email</strong>
218+
<p className="mt-1">
219+
After installation, login with your work email, select your organization and
220+
then click &quot;Link Device&quot;.
200221
</p>
201222
</li>
202-
{isMacOS ? (
203-
<li>
204-
<strong>Login with your work email</strong>
205-
<p className="mt-1">
206-
After installation, login with your work email, select your organization and
207-
then click "Link Device" and "Install Agent".
208-
</p>
209-
</li>
210-
) : (
211-
<li>
212-
<strong>Enable MDM</strong>
213-
<div className="space-y-2">
214-
<p>
215-
Find the Fleet Desktop app in your system tray (bottom right corner). Click
216-
on it and click My Device.
217-
</p>
218-
<p>
219-
You should see a banner that asks you to enable MDM. Click the button and
220-
follow the instructions.
221-
</p>
222-
<p>
223-
After you've enabled MDM, if you refresh the page, the banner will
224-
disappear. Now your computer will automatically enable the necessary
225-
settings on your computer in order to be compliant.
226-
</p>
227-
</div>
228-
</li>
229-
)}
230223
</ol>
231224
</div>
232-
) : (
225+
) : hasAgentDevice ? (
226+
<Card>
227+
<CardHeader>
228+
<CardTitle className="text-lg">{agentDevice.name}</CardTitle>
229+
</CardHeader>
230+
<CardContent className="space-y-3">
231+
<div className="flex items-center gap-2">
232+
{agentDevice.isCompliant ? (
233+
<CheckCircle2 className="text-green-600 dark:text-green-400 h-4 w-4" />
234+
) : (
235+
<Circle className="text-amber-600 dark:text-amber-400 h-4 w-4" />
236+
)}
237+
<span className="text-sm">
238+
{agentDevice.isCompliant
239+
? 'All security checks passing'
240+
: 'Some security checks need attention'}
241+
</span>
242+
</div>
243+
<p className="text-muted-foreground text-xs">
244+
{agentDevice.platform} &middot; {agentDevice.osVersion}
245+
{agentDevice.lastCheckIn && (
246+
<> &middot; Last check-in: {new Date(agentDevice.lastCheckIn).toLocaleDateString()}</>
247+
)}
248+
</p>
249+
</CardContent>
250+
</Card>
251+
) : hasFleetDevice ? (
233252
<Card>
234253
<CardHeader>
235254
<div className="flex items-center gap-2">
@@ -263,7 +282,7 @@ export function DeviceAgentAccordionItem({
263282
)}
264283
</CardContent>
265284
</Card>
266-
)}
285+
) : null}
267286
</div>
268287

269288
<div className="mt-4 space-y-2">
@@ -276,7 +295,7 @@ export function DeviceAgentAccordionItem({
276295
<AccordionContent className="px-4 pb-4">
277296
<div className="text-muted-foreground space-y-2 text-sm">
278297
<p>
279-
<strong>Operating Systems:</strong> macOS 14+, Windows 10+
298+
<strong>Operating Systems:</strong> macOS 14+, Windows 10+, Linux (Ubuntu 20.04+)
280299
</p>
281300
<p>
282301
<strong>Memory:</strong> 512MB RAM minimum

apps/portal/src/app/(app)/(home)/[orgId]/page.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ export default async function OrganizationPage({ params }: { params: Promise<{ o
5757
// Fleet policies - only fetch if member has a fleet device label
5858
const fleetData = await getFleetPolicies(member);
5959

60+
// Device agent device - fetch from DB
61+
const agentDevice = await db.device.findFirst({
62+
where: {
63+
memberId: member.id,
64+
organizationId: orgId,
65+
},
66+
orderBy: { lastCheckIn: { sort: 'desc', nulls: 'last' } },
67+
});
68+
6069
return (
6170
<PageLayout>
6271
<PageHeader title="Comp AI - Employee Portal" />
@@ -66,6 +75,7 @@ export default async function OrganizationPage({ params }: { params: Promise<{ o
6675
member={member}
6776
fleetPolicies={fleetData.fleetPolicies}
6877
host={fleetData.device}
78+
agentDevice={agentDevice}
6979
/>
7080
</PageLayout>
7181
);

apps/portal/src/app/api/download-agent/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,4 @@ export const DOWNLOAD_TARGETS: Record<
3434
export const MAC_APPLE_SILICON_FILENAME = DOWNLOAD_TARGETS.macos.filename;
3535
export const MAC_INTEL_FILENAME = DOWNLOAD_TARGETS['macos-intel'].filename;
3636
export const WINDOWS_FILENAME = DOWNLOAD_TARGETS.windows.filename;
37+
export const LINUX_FILENAME = DOWNLOAD_TARGETS.linux.filename;

0 commit comments

Comments
 (0)