Skip to content

Commit 2036702

Browse files
github-actions[bot]Marfuenclaude
authored
fix(app): check FleetDM device compliance alongside new device agent in people tab
* fix(app): check FleetDM device compliance alongside new device agent The people tab DEVICE column previously only checked the new device agent. Now it also checks FleetDM (legacy) so members using either agent show correct compliance status: Compliant, Non-Compliant, or Not Installed. Device agent takes priority when both are present. The devices tab compliance chart now includes both sources in its totals. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(app): unify device compliance logic between member list and chart Move device status computation from TeamMembers into page.tsx where fleet host data (with Fleet API automated checks + DB manual overrides) is already fetched. This ensures the member list DEVICE column and the compliance chart use identical data and compliance logic. Previously TeamMembers only checked FleetPolicyResult DB records, missing Fleet API automated policy checks. A device passing Fleet's automated check but lacking a DB override would show "Non-Compliant" in the member list but "Compliant" in the chart. Also fixes a bug where multiple fleet devices for the same member could not downgrade status from compliant to non-compliant. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Mariano Fuentes <marfuen98@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d165cea commit 2036702

7 files changed

Lines changed: 474 additions & 39 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import type { MemberWithUser } from './TeamMembers';
4+
5+
// Mock next/navigation
6+
vi.mock('next/navigation', () => ({
7+
useParams: () => ({ orgId: 'org_123' }),
8+
}));
9+
10+
// Mock next/link
11+
vi.mock('next/link', () => ({
12+
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
13+
<a href={href}>{children}</a>
14+
),
15+
}));
16+
17+
// Mock sonner
18+
vi.mock('sonner', () => ({
19+
toast: { success: vi.fn(), error: vi.fn() },
20+
}));
21+
22+
// Mock child components that aren't relevant
23+
vi.mock('./MultiRoleCombobox', () => ({
24+
MultiRoleCombobox: () => null,
25+
}));
26+
vi.mock('./RemoveDeviceAlert', () => ({
27+
RemoveDeviceAlert: () => null,
28+
}));
29+
vi.mock('./RemoveMemberAlert', () => ({
30+
RemoveMemberAlert: () => null,
31+
}));
32+
33+
import { MemberRow } from './MemberRow';
34+
35+
const baseMember = {
36+
id: 'mem_1',
37+
userId: 'usr_1',
38+
organizationId: 'org_123',
39+
role: 'employee',
40+
department: null,
41+
isActive: true,
42+
deactivated: false,
43+
fleetDmLabelId: null,
44+
createdAt: new Date(),
45+
updatedAt: new Date(),
46+
user: {
47+
id: 'usr_1',
48+
name: 'Jane Doe',
49+
email: 'jane@example.com',
50+
emailVerified: true,
51+
image: null,
52+
role: 'user',
53+
createdAt: new Date(),
54+
updatedAt: new Date(),
55+
banned: false,
56+
banReason: null,
57+
banExpires: null,
58+
},
59+
} as unknown as MemberWithUser;
60+
61+
const noop = vi.fn();
62+
63+
function renderMemberRow(deviceStatus?: 'compliant' | 'non-compliant' | 'not-installed') {
64+
return render(
65+
<table>
66+
<tbody>
67+
<MemberRow
68+
member={baseMember}
69+
onRemove={noop}
70+
onRemoveDevice={noop}
71+
onUpdateRole={noop}
72+
onReactivate={noop}
73+
canEdit={false}
74+
isCurrentUserOwner={false}
75+
deviceStatus={deviceStatus}
76+
/>
77+
</tbody>
78+
</table>,
79+
);
80+
}
81+
82+
describe('MemberRow device status', () => {
83+
beforeEach(() => {
84+
vi.clearAllMocks();
85+
});
86+
87+
it('shows "Not Installed" with red dot when deviceStatus is not-installed', () => {
88+
renderMemberRow('not-installed');
89+
expect(screen.getByText('Not Installed')).toBeInTheDocument();
90+
expect(screen.getByText('Not Installed').className).toContain('text-muted-foreground');
91+
});
92+
93+
it('shows "Not Installed" by default when deviceStatus is omitted', () => {
94+
renderMemberRow();
95+
expect(screen.getByText('Not Installed')).toBeInTheDocument();
96+
});
97+
98+
it('shows "Compliant" with green dot when deviceStatus is compliant', () => {
99+
renderMemberRow('compliant');
100+
expect(screen.getByText('Compliant')).toBeInTheDocument();
101+
expect(screen.getByText('Compliant').className).toContain('text-foreground');
102+
});
103+
104+
it('shows "Non-Compliant" with yellow dot when deviceStatus is non-compliant', () => {
105+
renderMemberRow('non-compliant');
106+
expect(screen.getByText('Non-Compliant')).toBeInTheDocument();
107+
expect(screen.getByText('Non-Compliant').className).toContain('text-foreground');
108+
});
109+
110+
it('does not show device status for platform admin', () => {
111+
const adminMember = {
112+
...baseMember,
113+
user: { ...baseMember.user, role: 'admin' as const },
114+
} as MemberWithUser;
115+
116+
render(
117+
<table>
118+
<tbody>
119+
<MemberRow
120+
member={adminMember}
121+
onRemove={noop}
122+
onRemoveDevice={noop}
123+
onUpdateRole={noop}
124+
onReactivate={noop}
125+
canEdit={false}
126+
isCurrentUserOwner={false}
127+
deviceStatus="compliant"
128+
/>
129+
</tbody>
130+
</table>,
131+
);
132+
133+
expect(screen.queryByText('Compliant')).not.toBeInTheDocument();
134+
expect(screen.queryByText('Non-Compliant')).not.toBeInTheDocument();
135+
expect(screen.queryByText('Not Installed')).not.toBeInTheDocument();
136+
});
137+
138+
it('does not show device status for deactivated member', () => {
139+
const deactivatedMember = {
140+
...baseMember,
141+
deactivated: true,
142+
} as MemberWithUser;
143+
144+
render(
145+
<table>
146+
<tbody>
147+
<MemberRow
148+
member={deactivatedMember}
149+
onRemove={noop}
150+
onRemoveDevice={noop}
151+
onUpdateRole={noop}
152+
onReactivate={noop}
153+
canEdit={false}
154+
isCurrentUserOwner={false}
155+
deviceStatus="non-compliant"
156+
/>
157+
</tbody>
158+
</table>,
159+
);
160+
161+
expect(screen.queryByText('Non-Compliant')).not.toBeInTheDocument();
162+
expect(screen.queryByText('Compliant')).not.toBeInTheDocument();
163+
});
164+
165+
it('renders correct dot colors for each status', () => {
166+
const { container, unmount } = renderMemberRow('compliant');
167+
const greenDot = container.querySelector('.bg-green-500');
168+
expect(greenDot).toBeInTheDocument();
169+
unmount();
170+
171+
const { container: c2, unmount: u2 } = renderMemberRow('non-compliant');
172+
const yellowDot = c2.querySelector('.bg-yellow-500');
173+
expect(yellowDot).toBeInTheDocument();
174+
u2();
175+
176+
const { container: c3 } = renderMemberRow('not-installed');
177+
const redDot = c3.querySelector('.bg-red-400');
178+
expect(redDot).toBeInTheDocument();
179+
});
180+
});

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

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ interface MemberRowProps {
5151
isCurrentUserOwner: boolean;
5252
customRoles?: CustomRoleOption[];
5353
taskCompletion?: TaskCompletion;
54-
hasDeviceAgentDevice?: boolean;
54+
deviceStatus?: 'compliant' | 'non-compliant' | 'not-installed';
5555
}
5656

5757
function getInitials(name?: string | null, email?: string | null): string {
@@ -95,7 +95,7 @@ export function MemberRow({
9595
isCurrentUserOwner,
9696
customRoles = [],
9797
taskCompletion,
98-
hasDeviceAgentDevice,
98+
deviceStatus = 'not-installed',
9999
}: MemberRowProps) {
100100
const { orgId } = useParams<{ orgId: string }>();
101101

@@ -231,7 +231,7 @@ export function MemberRow({
231231
</div>
232232
</TableCell>
233233

234-
{/* AGENT */}
234+
{/* DEVICE */}
235235
<TableCell>
236236
{isPlatformAdmin || isDeactivated ? (
237237
<Text size="sm" variant="muted">
@@ -241,11 +241,25 @@ export function MemberRow({
241241
<div className="flex items-center gap-2">
242242
<span
243243
className={`inline-block h-2 w-2 rounded-full ${
244-
hasDeviceAgentDevice ? 'bg-green-500' : 'bg-red-400'
244+
deviceStatus === 'compliant'
245+
? 'bg-green-500'
246+
: deviceStatus === 'non-compliant'
247+
? 'bg-yellow-500'
248+
: 'bg-red-400'
245249
}`}
246250
/>
247-
<span className={`text-sm ${hasDeviceAgentDevice ? 'text-foreground' : 'text-muted-foreground'}`}>
248-
{hasDeviceAgentDevice ? 'Installed' : 'Not Installed'}
251+
<span
252+
className={`text-sm ${
253+
deviceStatus === 'not-installed'
254+
? 'text-muted-foreground'
255+
: 'text-foreground'
256+
}`}
257+
>
258+
{deviceStatus === 'compliant'
259+
? 'Compliant'
260+
: deviceStatus === 'non-compliant'
261+
? 'Non-Compliant'
262+
: 'Not Installed'}
249263
</span>
250264
</div>
251265
)}
@@ -307,7 +321,7 @@ export function MemberRow({
307321
</DropdownMenuItem>
308322
)}
309323
{!isDeactivated &&
310-
(member.fleetDmLabelId || hasDeviceAgentDevice) &&
324+
(member.fleetDmLabelId || deviceStatus !== 'not-installed') &&
311325
isCurrentUserOwner && (
312326
<DropdownMenuItem
313327
onSelect={() => {

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

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,19 @@ export interface TaskCompletion {
2424
hipaa?: { completed: number; total: number };
2525
}
2626

27+
export type DeviceStatus = 'compliant' | 'non-compliant' | 'not-installed';
28+
2729
export interface TeamMembersProps {
2830
canManageMembers: boolean;
2931
canInviteUsers: boolean;
3032
isAuditor: boolean;
3133
isCurrentUserOwner: boolean;
3234
organizationId: string;
35+
deviceStatusMap: Record<string, DeviceStatus>;
3336
}
3437

3538
export async function TeamMembers(props: TeamMembersProps) {
36-
const { canManageMembers, canInviteUsers, isAuditor, isCurrentUserOwner, organizationId } = props;
39+
const { canManageMembers, canInviteUsers, isAuditor, isCurrentUserOwner, organizationId, deviceStatusMap } = props;
3740

3841
if (!organizationId) {
3942
return null;
@@ -64,19 +67,6 @@ export async function TeamMembers(props: TeamMembersProps) {
6467

6568
const employeeMembers = await filterComplianceMembers(members, organizationId);
6669

67-
// Build a set of member IDs that have device-agent devices
68-
const memberIds = members.map((m) => m.id);
69-
const devicesForMembers = await db.device.findMany({
70-
where: {
71-
organizationId,
72-
memberId: { in: memberIds },
73-
},
74-
select: { memberId: true },
75-
});
76-
const memberIdsWithDeviceAgent = [
77-
...new Set(devicesForMembers.map((d) => d.memberId)),
78-
];
79-
8070
if (employeeMembers.length > 0) {
8171
const [org, hipaaInstance] = await Promise.all([
8272
db.organization.findUnique({
@@ -156,7 +146,7 @@ export async function TeamMembers(props: TeamMembersProps) {
156146
isCurrentUserOwner={isCurrentUserOwner}
157147
employeeSyncData={employeeSyncData}
158148
taskCompletionMap={taskCompletionMap}
159-
memberIdsWithDeviceAgent={memberIdsWithDeviceAgent}
149+
deviceStatusMap={deviceStatusMap}
160150
/>
161151
);
162152
}

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ interface TeamMembersClientProps {
5353
isCurrentUserOwner: boolean;
5454
employeeSyncData: EmployeeSyncConnectionsData;
5555
taskCompletionMap: Record<string, TaskCompletion>;
56-
memberIdsWithDeviceAgent: string[];
56+
deviceStatusMap: Record<string, 'compliant' | 'non-compliant' | 'not-installed'>;
5757
}
5858

5959
export function TeamMembersClient({
@@ -65,7 +65,7 @@ export function TeamMembersClient({
6565
isCurrentUserOwner,
6666
employeeSyncData,
6767
taskCompletionMap,
68-
memberIdsWithDeviceAgent,
68+
deviceStatusMap,
6969
}: TeamMembersClientProps) {
7070
const router = useRouter();
7171
const [searchQuery, setSearchQuery] = useState('');
@@ -470,9 +470,7 @@ export function TeamMembersClient({
470470
isCurrentUserOwner={isCurrentUserOwner}
471471
customRoles={customRoles}
472472
taskCompletion={taskCompletionMap[(item as MemberWithUser).id]}
473-
hasDeviceAgentDevice={memberIdsWithDeviceAgent.includes(
474-
(item as MemberWithUser).id,
475-
)}
473+
deviceStatus={deviceStatusMap[(item as MemberWithUser).id] ?? 'not-installed'}
476474
/>
477475
) : (
478476
<PendingInvitationRow

0 commit comments

Comments
 (0)