diff --git a/apps/api/trigger.config.ts b/apps/api/trigger.config.ts index c700d132b1..8c6e1b7700 100644 --- a/apps/api/trigger.config.ts +++ b/apps/api/trigger.config.ts @@ -1,4 +1,3 @@ -import { syncVercelEnvVars } from '@trigger.dev/build/extensions/core'; import { defineConfig } from '@trigger.dev/sdk'; import { prismaExtension } from './customPrismaExtension'; import { emailExtension } from './emailExtension'; @@ -17,7 +16,6 @@ export default defineConfig({ }), integrationPlatformExtension(), emailExtension(), - syncVercelEnvVars(), ], }, retries: { diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx new file mode 100644 index 0000000000..ea25585a7f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx @@ -0,0 +1,180 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MemberWithUser } from './TeamMembers'; + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useParams: () => ({ orgId: 'org_123' }), +})); + +// Mock next/link +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +// Mock sonner +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +// Mock child components that aren't relevant +vi.mock('./MultiRoleCombobox', () => ({ + MultiRoleCombobox: () => null, +})); +vi.mock('./RemoveDeviceAlert', () => ({ + RemoveDeviceAlert: () => null, +})); +vi.mock('./RemoveMemberAlert', () => ({ + RemoveMemberAlert: () => null, +})); + +import { MemberRow } from './MemberRow'; + +const baseMember = { + id: 'mem_1', + userId: 'usr_1', + organizationId: 'org_123', + 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 MemberWithUser; + +const noop = vi.fn(); + +function renderMemberRow(deviceStatus?: 'compliant' | 'non-compliant' | 'not-installed') { + return render( + + + + +
, + ); +} + +describe('MemberRow device status', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows "Not Installed" with red dot when deviceStatus is not-installed', () => { + renderMemberRow('not-installed'); + expect(screen.getByText('Not Installed')).toBeInTheDocument(); + expect(screen.getByText('Not Installed').className).toContain('text-muted-foreground'); + }); + + it('shows "Not Installed" by default when deviceStatus is omitted', () => { + renderMemberRow(); + expect(screen.getByText('Not Installed')).toBeInTheDocument(); + }); + + it('shows "Compliant" with green dot when deviceStatus is compliant', () => { + renderMemberRow('compliant'); + expect(screen.getByText('Compliant')).toBeInTheDocument(); + expect(screen.getByText('Compliant').className).toContain('text-foreground'); + }); + + it('shows "Non-Compliant" with yellow dot when deviceStatus is non-compliant', () => { + renderMemberRow('non-compliant'); + expect(screen.getByText('Non-Compliant')).toBeInTheDocument(); + expect(screen.getByText('Non-Compliant').className).toContain('text-foreground'); + }); + + it('does not show device status for platform admin', () => { + const adminMember = { + ...baseMember, + user: { ...baseMember.user, role: 'admin' as const }, + } as MemberWithUser; + + render( + + + + +
, + ); + + expect(screen.queryByText('Compliant')).not.toBeInTheDocument(); + expect(screen.queryByText('Non-Compliant')).not.toBeInTheDocument(); + expect(screen.queryByText('Not Installed')).not.toBeInTheDocument(); + }); + + it('does not show device status for deactivated member', () => { + const deactivatedMember = { + ...baseMember, + deactivated: true, + } as MemberWithUser; + + render( + + + + +
, + ); + + expect(screen.queryByText('Non-Compliant')).not.toBeInTheDocument(); + expect(screen.queryByText('Compliant')).not.toBeInTheDocument(); + }); + + it('renders correct dot colors for each status', () => { + const { container, unmount } = renderMemberRow('compliant'); + const greenDot = container.querySelector('.bg-green-500'); + expect(greenDot).toBeInTheDocument(); + unmount(); + + const { container: c2, unmount: u2 } = renderMemberRow('non-compliant'); + const yellowDot = c2.querySelector('.bg-yellow-500'); + expect(yellowDot).toBeInTheDocument(); + u2(); + + const { container: c3 } = renderMemberRow('not-installed'); + const redDot = c3.querySelector('.bg-red-400'); + expect(redDot).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx index cc041cd4be..72fef1ac15 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx @@ -51,7 +51,7 @@ interface MemberRowProps { isCurrentUserOwner: boolean; customRoles?: CustomRoleOption[]; taskCompletion?: TaskCompletion; - hasDeviceAgentDevice?: boolean; + deviceStatus?: 'compliant' | 'non-compliant' | 'not-installed'; } function getInitials(name?: string | null, email?: string | null): string { @@ -95,7 +95,7 @@ export function MemberRow({ isCurrentUserOwner, customRoles = [], taskCompletion, - hasDeviceAgentDevice, + deviceStatus = 'not-installed', }: MemberRowProps) { const { orgId } = useParams<{ orgId: string }>(); @@ -231,7 +231,7 @@ export function MemberRow({ - {/* AGENT */} + {/* DEVICE */} {isPlatformAdmin || isDeactivated ? ( @@ -241,11 +241,25 @@ export function MemberRow({
- - {hasDeviceAgentDevice ? 'Installed' : 'Not Installed'} + + {deviceStatus === 'compliant' + ? 'Compliant' + : deviceStatus === 'non-compliant' + ? 'Non-Compliant' + : 'Not Installed'}
)} @@ -307,7 +321,7 @@ export function MemberRow({ )} {!isDeactivated && - (member.fleetDmLabelId || hasDeviceAgentDevice) && + (member.fleetDmLabelId || deviceStatus !== 'not-installed') && isCurrentUserOwner && ( { diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx index 8fa34be45d..67c36a96ad 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx @@ -24,16 +24,19 @@ export interface TaskCompletion { hipaa?: { completed: number; total: number }; } +export type DeviceStatus = 'compliant' | 'non-compliant' | 'not-installed'; + export interface TeamMembersProps { canManageMembers: boolean; canInviteUsers: boolean; isAuditor: boolean; isCurrentUserOwner: boolean; organizationId: string; + deviceStatusMap: Record; } export async function TeamMembers(props: TeamMembersProps) { - const { canManageMembers, canInviteUsers, isAuditor, isCurrentUserOwner, organizationId } = props; + const { canManageMembers, canInviteUsers, isAuditor, isCurrentUserOwner, organizationId, deviceStatusMap } = props; if (!organizationId) { return null; @@ -64,19 +67,6 @@ export async function TeamMembers(props: TeamMembersProps) { const employeeMembers = await filterComplianceMembers(members, organizationId); - // Build a set of member IDs that have device-agent devices - const memberIds = members.map((m) => m.id); - const devicesForMembers = await db.device.findMany({ - where: { - organizationId, - memberId: { in: memberIds }, - }, - select: { memberId: true }, - }); - const memberIdsWithDeviceAgent = [ - ...new Set(devicesForMembers.map((d) => d.memberId)), - ]; - if (employeeMembers.length > 0) { const [org, hipaaInstance] = await Promise.all([ db.organization.findUnique({ @@ -156,7 +146,7 @@ export async function TeamMembers(props: TeamMembersProps) { isCurrentUserOwner={isCurrentUserOwner} employeeSyncData={employeeSyncData} taskCompletionMap={taskCompletionMap} - memberIdsWithDeviceAgent={memberIdsWithDeviceAgent} + deviceStatusMap={deviceStatusMap} /> ); } 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 f0e6268373..64d62ef879 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 @@ -53,7 +53,7 @@ interface TeamMembersClientProps { isCurrentUserOwner: boolean; employeeSyncData: EmployeeSyncConnectionsData; taskCompletionMap: Record; - memberIdsWithDeviceAgent: string[]; + deviceStatusMap: Record; } export function TeamMembersClient({ @@ -65,7 +65,7 @@ export function TeamMembersClient({ isCurrentUserOwner, employeeSyncData, taskCompletionMap, - memberIdsWithDeviceAgent, + deviceStatusMap, }: TeamMembersClientProps) { const router = useRouter(); const [searchQuery, setSearchQuery] = useState(''); @@ -470,9 +470,7 @@ export function TeamMembersClient({ isCurrentUserOwner={isCurrentUserOwner} customRoles={customRoles} taskCompletion={taskCompletionMap[(item as MemberWithUser).id]} - hasDeviceAgentDevice={memberIdsWithDeviceAgent.includes( - (item as MemberWithUser).id, - )} + deviceStatus={deviceStatusMap[(item as MemberWithUser).id] ?? 'not-installed'} /> ) : ( ({ + PieChart: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Pie: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Cell: () =>
, + Label: () => null, + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +// Mock @trycompai/ui chart components +vi.mock('@trycompai/ui/chart', () => ({ + ChartContainer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ChartTooltip: () => null, + ChartTooltipContent: () => null, +})); + +import { DeviceComplianceChart } from './DeviceComplianceChart'; + +function makeAgentDevice(overrides: Partial = {}): DeviceWithChecks { + return { + id: `dev_${Math.random().toString(36).slice(2)}`, + name: 'MacBook Pro', + hostname: 'macbook-pro', + platform: 'macos', + osVersion: '14.0', + serialNumber: 'SN123', + hardwareModel: 'MacBookPro18,1', + isCompliant: true, + diskEncryptionEnabled: true, + antivirusEnabled: true, + passwordPolicySet: true, + screenLockEnabled: true, + checkDetails: null, + lastCheckIn: new Date().toISOString(), + agentVersion: '1.0.0', + installedAt: new Date().toISOString(), + user: { name: 'Test User', email: 'test@example.com' }, + source: 'device_agent', + ...overrides, + }; +} + +function makeFleetDevice(overrides: Partial = {}): Host { + return { + id: Math.floor(Math.random() * 10000), + hostname: 'fleet-host', + computer_name: 'Fleet Device', + platform: 'darwin', + os_version: '14.0', + hardware_serial: 'FSN123', + hardware_model: 'MacBookPro18,1', + status: 'online', + seen_time: new Date().toISOString(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + disk_encryption_enabled: true, + display_name: 'Fleet Device', + display_text: 'Fleet Device', + software: [], + software_updated_at: '', + detail_updated_at: '', + label_updated_at: '', + policy_updated_at: '', + last_enrolled_at: '', + refetch_requested: false, + uuid: '', + osquery_version: '', + orbit_version: '', + fleet_desktop_version: '', + scripts_enabled: false, + build: '', + platform_like: '', + code_name: '', + uptime: 0, + memory: 0, + cpu_type: '', + cpu_subtype: '', + cpu_brand: '', + cpu_physical_cores: 0, + cpu_logical_cores: 0, + hardware_vendor: '', + hardware_version: '', + public_ip: '', + primary_ip: '', + primary_mac: '', + distributed_interval: 0, + config_tls_refresh: 0, + logger_tls_period: 0, + team_id: null, + pack_stats: [], + team_name: null, + users: [], + gigs_disk_space_available: 0, + percent_disk_space_available: 0, + gigs_total_disk_space: 0, + issues: {}, + mdm: { connected_to_fleet: false, dep_profile_error: false, encryption_key_available: false, enrollment_status: '' }, + refetch_critical_queries_until: null, + last_restarted_at: '', + labels: [], + packs: [], + batteries: [], + end_users: [], + last_mdm_enrolled_at: '', + last_mdm_checked_in_at: '', + policies: [{ id: 1, name: 'Disk Encryption', response: 'pass', attachments: [] }], + ...overrides, + } as Host; +} + +describe('DeviceComplianceChart', () => { + it('shows empty state when no devices from either source', () => { + render(); + expect(screen.getByText('No device data available. Please make sure your employees access the portal and install the device agent.')).toBeInTheDocument(); + }); + + it('counts agent devices correctly — all compliant', () => { + const devices = [ + makeAgentDevice({ isCompliant: true }), + makeAgentDevice({ isCompliant: true }), + ]; + + render(); + expect(screen.getByText('Compliant (2)')).toBeInTheDocument(); + expect(screen.getByText('Non-Compliant (0)')).toBeInTheDocument(); + }); + + it('counts agent devices correctly — mixed compliance', () => { + const devices = [ + makeAgentDevice({ isCompliant: true }), + makeAgentDevice({ isCompliant: false }), + ]; + + render(); + expect(screen.getByText('Compliant (1)')).toBeInTheDocument(); + expect(screen.getByText('Non-Compliant (1)')).toBeInTheDocument(); + }); + + it('counts fleet devices correctly — all policies pass', () => { + const devices = [ + makeFleetDevice({ + policies: [ + { id: 1, name: 'Encryption', response: 'pass' }, + { id: 2, name: 'Antivirus', response: 'pass' }, + ], + }), + ]; + + render(); + expect(screen.getByText('Compliant (1)')).toBeInTheDocument(); + expect(screen.getByText('Non-Compliant (0)')).toBeInTheDocument(); + }); + + it('counts fleet devices correctly — some policies fail', () => { + const devices = [ + makeFleetDevice({ + policies: [ + { id: 1, name: 'Encryption', response: 'pass' }, + { id: 2, name: 'Antivirus', response: 'fail' }, + ], + }), + ]; + + render(); + expect(screen.getByText('Compliant (0)')).toBeInTheDocument(); + expect(screen.getByText('Non-Compliant (1)')).toBeInTheDocument(); + }); + + it('combines agent and fleet devices in total count', () => { + const agentDevices = [ + makeAgentDevice({ isCompliant: true }), + makeAgentDevice({ isCompliant: false }), + ]; + const fleetDevices = [ + makeFleetDevice({ + policies: [{ id: 1, name: 'Encryption', response: 'pass' }], + }), + ]; + + render(); + // 1 compliant agent + 1 compliant fleet = 2, 1 non-compliant agent = 1 + expect(screen.getByText('Compliant (2)')).toBeInTheDocument(); + expect(screen.getByText('Non-Compliant (1)')).toBeInTheDocument(); + }); + + it('treats fleet device with no policies as compliant', () => { + const devices = [makeFleetDevice({ policies: [] })]; + + render(); + // Array.every on empty array returns true + expect(screen.getByText('Compliant (1)')).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx index 827f6a4272..c9eeb4d0d5 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx @@ -9,10 +9,11 @@ import { } from '@trycompai/ui/chart'; import * as React from 'react'; import { Cell, Label, Pie, PieChart } from 'recharts'; -import type { Host } from '../types'; +import type { DeviceWithChecks, Host } from '../types'; interface DeviceComplianceChartProps { - devices: Host[]; + fleetDevices: Host[]; + agentDevices: DeviceWithChecks[]; } const CHART_COLORS = { @@ -20,15 +21,27 @@ const CHART_COLORS = { nonCompliant: 'hsl(var(--chart-destructive))', }; -export function DeviceComplianceChart({ devices }: DeviceComplianceChartProps) { +export function DeviceComplianceChart({ fleetDevices, agentDevices }: DeviceComplianceChartProps) { + const devices = [...(agentDevices ?? []), ...(fleetDevices ?? [])]; + const { pieDisplayData, legendDisplayData } = React.useMemo(() => { - if (!devices || devices.length === 0) { + if (devices.length === 0) { return { pieDisplayData: [], legendDisplayData: [] }; } let compliantCount = 0; let nonCompliantCount = 0; - for (const device of devices) { + // Count device-agent devices + for (const device of agentDevices ?? []) { + if (device.isCompliant) { + compliantCount++; + } else { + nonCompliantCount++; + } + } + + // Count fleet devices + for (const device of fleetDevices ?? []) { const isCompliant = device.policies.every((policy) => policy.response === 'pass'); if (isCompliant) { compliantCount++; @@ -36,6 +49,7 @@ export function DeviceComplianceChart({ devices }: DeviceComplianceChartProps) { nonCompliantCount++; } } + const allItems = [ { name: 'Compliant', @@ -52,11 +66,9 @@ export function DeviceComplianceChart({ devices }: DeviceComplianceChartProps) { pieDisplayData: allItems.filter((item) => item.value > 0), legendDisplayData: allItems, }; - }, [devices]); + }, [agentDevices, fleetDevices]); - const totalDevices = React.useMemo(() => { - return devices?.length || 0; - }, [devices]); + const totalDevices = devices.length; const chartConfig = { devices: { diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index 6a2fcb9cf5..0bbbe94c1b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -155,6 +155,34 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: (host) => !host.member_id || !memberIdsWithAgent.has(host.member_id), ); + // Build unified device status map from the SAME data both tabs use. + // This ensures the member list and compliance chart agree on compliance. + const deviceStatusMap: Record = {}; + + // Device-agent devices: compliant only if ALL of a member's devices pass + const agentComplianceByMember = new Map(); + for (const d of agentDevices) { + if (!d.memberId) continue; + const prev = agentComplianceByMember.get(d.memberId); + agentComplianceByMember.set(d.memberId, (prev ?? true) && d.isCompliant); + } + for (const [memberId, allCompliant] of agentComplianceByMember) { + deviceStatusMap[memberId] = allCompliant ? 'compliant' : 'non-compliant'; + } + + // Fleet-only devices: use the same merged policy data the chart uses + // (Fleet API automated checks + DB manual overrides, already combined by getFleetHosts) + for (const host of filteredFleetDevices) { + if (!host.member_id) continue; + // If already set by device-agent, skip (agent takes priority) + if (agentComplianceByMember.has(host.member_id)) continue; + const isCompliant = host.policies.every((p) => p.response === 'pass'); + // If multiple fleet devices for same member, non-compliant if ANY device fails + if (!isCompliant || !deviceStatusMap[host.member_id]) { + deviceStatusMap[host.member_id] = isCompliant ? 'compliant' : 'non-compliant'; + } + } + return ( } employeeTasksContent={showEmployeeTasks ? : null} devicesContent={
+ {/* Unified compliance chart covering both device-agent and fleet devices */} + + {/* Device Agent devices (new system) */} {agentDevices.length > 0 && ( )} - {/* Fleet devices (legacy) — shown exactly as main branch */} - - + {/* Fleet devices (legacy) — only for members without the newer device agent */} + {filteredFleetDevices.length > 0 && ( + + )}
} orgChartContent={ diff --git a/apps/app/trigger.config.ts b/apps/app/trigger.config.ts index fb66682b30..38626c313e 100644 --- a/apps/app/trigger.config.ts +++ b/apps/app/trigger.config.ts @@ -1,5 +1,4 @@ import { PrismaInstrumentation } from '@prisma/instrumentation'; -import { syncVercelEnvVars } from '@trigger.dev/build/extensions/core'; import { puppeteer } from '@trigger.dev/build/extensions/puppeteer'; import { defineConfig } from '@trigger.dev/sdk'; import { prismaExtension } from './customPrismaExtension'; @@ -17,7 +16,6 @@ export default defineConfig({ dbPackageVersion: '^2.0.0', }), puppeteer(), - syncVercelEnvVars(), ], }, retries: { diff --git a/packages/email/emails/all-policy-notification.tsx b/packages/email/emails/all-policy-notification.tsx index 1472d65ef6..d794bc9014 100644 --- a/packages/email/emails/all-policy-notification.tsx +++ b/packages/email/emails/all-policy-notification.tsx @@ -2,7 +2,6 @@ import { Body, Button, Container, - Font, Heading, Html, Link, @@ -35,31 +34,7 @@ export const AllPolicyNotificationEmail = ({ return ( - - - - - - - {subjectText} +{subjectText} - - - - - - - You've been invited to the Comp AI Portal +You've been invited to the Comp AI Portal { return ( - - - - - - You've been invited to join Comp AI +You've been invited to join Comp AI { return ( - - - - - - Login Link for Comp AI +Login Link for Comp AI { return ( - - - - - - - Get started with Comp AI +Get started with Comp AI { return ( - - - - - - - One-Time Password for Comp AI +One-Time Password for Comp AI - - - - - - - {subjectText} +{subjectText} return ( - - - - - - - Comp AI - Task Reminder +Comp AI - Task Reminder - - - - - - - + Task "{taskName}" {statusLabel} - {organizationName} diff --git a/packages/email/emails/reminders/weekly-task-digest.tsx b/packages/email/emails/reminders/weekly-task-digest.tsx index aa90fe17f5..7f1f18f942 100644 --- a/packages/email/emails/reminders/weekly-task-digest.tsx +++ b/packages/email/emails/reminders/weekly-task-digest.tsx @@ -2,7 +2,6 @@ import { Body, Button, Container, - Font, Heading, Html, Link, @@ -46,31 +45,7 @@ export const WeeklyTaskDigestEmail = ({ return ( - - - - - - - {taskCountMessage} +{taskCountMessage} - - - - - - - Congratulations! You've completed your Security Awareness Training +Congratulations! You've completed your Security Awareness Training - - - - - - - Member removed - items require reassignment +Member removed - items require reassignment { - const confirmationUrl = `https://trycomp.ai/api/waitlist?email=${email}`; - - return ( - - - - - - - - Confirm your email to join the Comp AI waitlist - - - - - - Just one more step - - - - To claim your spot on the Comp AI waitlist, please confirm your email. - -
- -
- - - or copy and paste this URL into your browser{' '} - - {confirmationUrl} - - - -
-
- - This email was intended for{' '} - {email}. If you did not request - this, please ignore this email. - -
- -
- -