diff --git a/apps/api/src/integration-platform/controllers/sync-available-providers.controller.spec.ts b/apps/api/src/integration-platform/controllers/sync-available-providers.controller.spec.ts new file mode 100644 index 0000000000..fef3ee3939 --- /dev/null +++ b/apps/api/src/integration-platform/controllers/sync-available-providers.controller.spec.ts @@ -0,0 +1,203 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SyncController } from './sync.controller'; +import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../../auth/permission.guard'; +import { ConnectionRepository } from '../repositories/connection.repository'; +import { CredentialVaultService } from '../services/credential-vault.service'; +import { OAuthCredentialsService } from '../services/oauth-credentials.service'; +import { IntegrationSyncLoggerService } from '../services/integration-sync-logger.service'; +import { GenericEmployeeSyncService } from '../services/generic-employee-sync.service'; +import { GenericDeviceSyncService } from '../services/generic-device-sync.service'; +import { DynamicIntegrationRepository } from '../repositories/dynamic-integration.repository'; +import { CheckRunRepository } from '../repositories/check-run.repository'; + +const mockConnectionFindMany = jest.fn(); +const mockGetActiveManifests = jest.fn(); + +jest.mock('@db', () => ({ + db: { + integrationConnection: { + findMany: (...args: unknown[]) => mockConnectionFindMany(...args), + }, + }, +})); + +jest.mock('../../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); + +jest.mock('@trycompai/auth', () => ({ + statement: { integration: ['create', 'read', 'update', 'delete'] }, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); + +jest.mock('@trycompai/integration-platform', () => { + const actual = jest.requireActual< + typeof import('@trycompai/integration-platform') + >('@trycompai/integration-platform'); + return { + ...actual, + registry: { + getActiveManifests: (...args: unknown[]) => + mockGetActiveManifests(...args), + }, + TASK_TEMPLATE_INFO: {}, + }; +}); + +describe('SyncController - getAvailableSyncProviders connection status', () => { + let controller: SyncController; + + const orgId = 'org_test123'; + const intuneManifest = { + id: 'intune', + name: 'Intune', + logoUrl: 'https://example.com/intune.png', + capabilities: ['checks', 'device_sync'], + }; + + beforeEach(async () => { + jest.clearAllMocks(); + mockGetActiveManifests.mockReturnValue([intuneManifest]); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [SyncController], + providers: [ + { + provide: ConnectionRepository, + useValue: { findById: jest.fn(), update: jest.fn() }, + }, + { + provide: CredentialVaultService, + useValue: { + getDecryptedCredentials: jest.fn(), + refreshOAuthTokens: jest.fn(), + }, + }, + { + provide: OAuthCredentialsService, + useValue: { getCredentials: jest.fn() }, + }, + { + provide: IntegrationSyncLoggerService, + useValue: { logSync: jest.fn() }, + }, + { provide: GenericEmployeeSyncService, useValue: {} }, + { provide: GenericDeviceSyncService, useValue: {} }, + { provide: DynamicIntegrationRepository, useValue: {} }, + { + provide: CheckRunRepository, + useValue: { create: jest.fn(), complete: jest.fn() }, + }, + ], + }) + .overrideGuard(HybridAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(PermissionGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(SyncController); + }); + + it('reports connected + connectionStatus active for an active connection', async () => { + mockConnectionFindMany.mockResolvedValue([ + { + id: 'icn_active', + status: 'active', + lastSyncAt: null, + nextSyncAt: null, + provider: { slug: 'intune' }, + }, + ]); + + const result = await controller.getAvailableSyncProviders(orgId, 'device'); + + expect(result.providers).toEqual([ + expect.objectContaining({ + slug: 'intune', + connected: true, + connectionStatus: 'active', + connectionId: 'icn_active', + }), + ]); + // ONE batched query for all providers — no per-provider point lookups. + expect(mockConnectionFindMany).toHaveBeenCalledTimes(1); + const args = mockConnectionFindMany.mock.calls[0][0] as { + where: { provider: { slug: { in: string[] } } }; + orderBy: { updatedAt: string }; + }; + expect(args.where.provider.slug.in).toEqual(['intune']); + expect(args.orderBy).toEqual({ updatedAt: 'desc' }); + }); + + it('reports connectionStatus error (not connected) when the latest connection is broken', async () => { + mockConnectionFindMany.mockResolvedValue([ + { id: 'icn_broken', status: 'error', lastSyncAt: null, nextSyncAt: null, provider: { slug: 'intune' } }, + ]); + + const result = await controller.getAvailableSyncProviders(orgId, 'device'); + + expect(result.providers).toEqual([ + expect.objectContaining({ + slug: 'intune', + connected: false, + connectionStatus: 'error', + connectionId: null, + }), + ]); + }); + + it('does not resurface a stale error when the latest connection is disconnected', async () => { + // Rows arrive newest-first (orderBy updatedAt desc): the user reconnected + // and later disconnected, leaving an older row stuck in 'error'. + mockConnectionFindMany.mockResolvedValue([ + { id: 'icn_new', status: 'disconnected', lastSyncAt: null, nextSyncAt: null, provider: { slug: 'intune' } }, + { id: 'icn_old', status: 'error', lastSyncAt: null, nextSyncAt: null, provider: { slug: 'intune' } }, + ]); + + const result = await controller.getAvailableSyncProviders(orgId, 'device'); + + expect(result.providers).toEqual([ + expect.objectContaining({ + slug: 'intune', + connected: false, + connectionStatus: null, + connectionId: null, + }), + ]); + }); + + it('prefers an active connection even when a newer non-active row exists', async () => { + mockConnectionFindMany.mockResolvedValue([ + { id: 'icn_new_err', status: 'error', lastSyncAt: null, nextSyncAt: null, provider: { slug: 'intune' } }, + { id: 'icn_active', status: 'active', lastSyncAt: null, nextSyncAt: null, provider: { slug: 'intune' } }, + ]); + + const result = await controller.getAvailableSyncProviders(orgId, 'device'); + + expect(result.providers).toEqual([ + expect.objectContaining({ + slug: 'intune', + connected: true, + connectionStatus: 'active', + connectionId: 'icn_active', + }), + ]); + }); + + it('reports connectionStatus null when the org has no connection for the provider', async () => { + mockConnectionFindMany.mockResolvedValue([]); + + const result = await controller.getAvailableSyncProviders(orgId, 'device'); + + expect(result.providers).toEqual([ + expect.objectContaining({ + slug: 'intune', + connected: false, + connectionStatus: null, + connectionId: null, + }), + ]); + }); +}); diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index 5386f0fd28..e510db91ff 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -1777,32 +1777,55 @@ export class SyncController { m.capabilities?.includes(capability), ); - const results = await Promise.all( - syncProviders.map(async (m) => { - const connection = await db.integrationConnection.findFirst({ - where: { - organizationId, - status: 'active', - provider: { slug: m.id }, - }, - select: { - id: true, - status: true, - lastSyncAt: true, - nextSyncAt: true, - }, - }); - return { - slug: m.id, - name: m.name, - logoUrl: m.logoUrl, - connected: !!connection, - connectionId: connection?.id ?? null, - lastSyncAt: connection?.lastSyncAt?.toISOString() ?? null, - nextSyncAt: connection?.nextSyncAt?.toISOString() ?? null, - }; - }), - ); + // One batched query for every provider's connections (newest first), then + // resolve per-provider status in memory — avoids N (to 2N) point lookups. + const orgConnections = await db.integrationConnection.findMany({ + where: { + organizationId, + provider: { slug: { in: syncProviders.map((m) => m.id) } }, + }, + select: { + id: true, + status: true, + lastSyncAt: true, + nextSyncAt: true, + provider: { select: { slug: true } }, + }, + orderBy: { updatedAt: 'desc' }, + }); + const connectionsBySlug = new Map(); + for (const conn of orgConnections) { + const slug = conn.provider.slug; + const list = connectionsBySlug.get(slug); + if (list) list.push(conn); + else connectionsBySlug.set(slug, [conn]); + } + + const results = syncProviders.map((m) => { + const conns = connectionsBySlug.get(m.id) ?? []; + const connection = conns.find((c) => c.status === 'active'); + // No active connection: surface a broken (errored) one so the UI can + // prompt a reconnect instead of silently hiding sync for the provider. + // Multiple rows can exist per (org, provider) — reconnects create new + // rows — so the LATEST row decides: a newer 'disconnected' row means + // the user chose to disconnect, and a stale older 'error' row must + // not resurface a reconnect hint. + const latestConnection = connection ? null : (conns[0] ?? null); + return { + slug: m.id, + name: m.name, + logoUrl: m.logoUrl, + connected: !!connection, + connectionStatus: connection + ? ('active' as const) + : latestConnection?.status === 'error' + ? ('error' as const) + : null, + connectionId: connection?.id ?? null, + lastSyncAt: connection?.lastSyncAt?.toISOString() ?? null, + nextSyncAt: connection?.nextSyncAt?.toISOString() ?? null, + }; + }); return { providers: results }; } diff --git a/apps/api/src/integration-platform/services/generic-device-sync.service.spec.ts b/apps/api/src/integration-platform/services/generic-device-sync.service.spec.ts index 6aff923564..5272463c04 100644 --- a/apps/api/src/integration-platform/services/generic-device-sync.service.spec.ts +++ b/apps/api/src/integration-platform/services/generic-device-sync.service.spec.ts @@ -466,4 +466,121 @@ describe('GenericDeviceSyncService', () => { expect(result.removed).toBe(0); }); }); + // ======================================================================== + // Source-reported compliance (sourceCompliance) + provider last-seen + // ======================================================================== + + describe('Source-reported compliance', () => { + it('persists the provider verdict, checks, and lastSeenAt on create', async () => { + const lastSeenAt = '2026-07-09T18:00:00.000Z'; + await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [ + baseDevice({ + isCompliant: true, + checks: [ + { id: 'disk_encryption', label: 'Disk Encryption', passed: true }, + { id: 'firewall', label: 'Firewall', passed: false }, + ], + lastSeenAt, + }), + ], + }); + + expect(mockDeviceCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + sourceCompliance: { + isCompliant: true, + checks: [ + { id: 'disk_encryption', label: 'Disk Encryption', passed: true }, + { id: 'firewall', label: 'Firewall', passed: false }, + ], + }, + lastCheckIn: new Date(lastSeenAt), + }), + }), + ); + }); + + it('persists checks without a verdict (provider reports checks only)', async () => { + await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [ + baseDevice({ + checks: [{ id: 'os_up_to_date', label: 'OS up to date', passed: true }], + }), + ], + }); + + const data = mockDeviceCreate.mock.calls[0][0].data; + expect(data.sourceCompliance).toEqual({ + checks: [{ id: 'os_up_to_date', label: 'OS up to date', passed: true }], + }); + expect(data.sourceCompliance.isCompliant).toBeUndefined(); + }); + + it('writes JsonNull when the provider reports no compliance (clears stale values on update)', async () => { + // Existing integration device — update path. + mockDeviceFindFirst.mockResolvedValue({ id: 'dev_1', source: 'integration' }); + + await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [baseDevice()], + }); + + expect(mockDeviceUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + sourceCompliance: Prisma.JsonNull, + }), + }), + ); + }); + + it('treats an explicit false verdict as reported (not as absent)', async () => { + await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [baseDevice({ isCompliant: false })], + }); + + const data = mockDeviceCreate.mock.calls[0][0].data; + expect(data.sourceCompliance).toEqual({ isCompliant: false }); + }); + + it('clamps a future lastSeenAt to now (provider clock skew must not pin the device Online)', async () => { + const future = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(); + const before = Date.now(); + await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [baseDevice({ lastSeenAt: future })], + }); + const after = Date.now(); + + const data = mockDeviceCreate.mock.calls[0][0].data; + const ts = (data.lastCheckIn as Date).getTime(); + expect(ts).toBeGreaterThanOrEqual(before); + expect(ts).toBeLessThanOrEqual(after); + }); + + it('falls back to sync time for lastCheckIn when lastSeenAt is absent', async () => { + const before = Date.now(); + await service.processDevices({ + organizationId: ORG_ID, + connectionId: CONN_ID, + devices: [baseDevice()], + }); + const after = Date.now(); + + const data = mockDeviceCreate.mock.calls[0][0].data; + const ts = (data.lastCheckIn as Date).getTime(); + expect(ts).toBeGreaterThanOrEqual(before); + expect(ts).toBeLessThanOrEqual(after); + }); + }); }); diff --git a/apps/api/src/integration-platform/services/generic-device-sync.service.ts b/apps/api/src/integration-platform/services/generic-device-sync.service.ts index 370fc4f71e..a216f3de34 100644 --- a/apps/api/src/integration-platform/services/generic-device-sync.service.ts +++ b/apps/api/src/integration-platform/services/generic-device-sync.service.ts @@ -160,16 +160,43 @@ export class GenericDeviceSyncService { }); } + // Compliance as reported by the source, if any. Written on EVERY sync + // (JsonNull when absent) so stale values are cleared if the provider + // stops reporting — never freeze an outdated verdict on the row. + const hasSourceCompliance = + device.isCompliant !== undefined || + (device.checks !== undefined && device.checks.length > 0); + const sourceCompliance: Prisma.InputJsonValue | typeof Prisma.JsonNull = + hasSourceCompliance + ? { + ...(device.isCompliant !== undefined + ? { isCompliant: device.isCompliant } + : {}), + ...(device.checks !== undefined && device.checks.length > 0 + ? { checks: device.checks } + : {}), + } + : Prisma.JsonNull; + const updateData = { name: device.name, hostname: device.hostname ?? device.name, platform: device.platform, osVersion: device.osVersion ?? 'Unknown', hardwareModel: device.hardwareModel, - lastCheckIn: new Date(), + // Prefer the provider's own last-contact timestamp (validated ISO + // string) so "Last seen" reflects the device, not our sync cadence. + // Clamped to now: a future timestamp (provider clock skew / bad + // mapping) would otherwise pin the device "Online" for days. + lastCheckIn: device.lastSeenAt + ? new Date( + Math.min(new Date(device.lastSeenAt).getTime(), Date.now()), + ) + : new Date(), source: 'integration' as const, integrationConnectionId: connectionId, externalDeviceId: device.externalId, + sourceCompliance, // Backfill the serial on updates too, so an externalId-matched row // becomes serial-linkable once the provider reports one. When the // device has no serial, Prisma omits `undefined` and leaves it as-is. diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx index 704fe6ef39..08b133a7ab 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx @@ -227,11 +227,84 @@ describe('DeviceAgentDevicesList — integration-imported devices', () => { ).toBeInTheDocument(); }); - it('does not present an imported device as Online or Offline', () => { - render(); - // Imported devices get no live-status dot at all (a spacer, no title). - expect(screen.queryByTitle('Online')).not.toBeInTheDocument(); - expect(screen.queryByTitle('Offline')).not.toBeInTheDocument(); + it('presents an imported device as Online when the provider saw it recently', () => { + // lastCheckIn carries the PROVIDER's last-contact timestamp for imported + // devices (device sync lastSeenAt), so the live dot applies honestly. + render( + , + ); + expect(screen.getByTitle('Online')).toBeInTheDocument(); + }); + + it('presents an imported device as Offline when the provider has not seen it recently', () => { + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(); + render( + , + ); + expect(screen.getByTitle('Offline')).toBeInTheDocument(); + }); + + it('shows the SOURCE-reported verdict and named checks when the provider reports them', () => { + render( + , + ); + // Verdict shown instead of "Not tracked", with provider attribution. + expect(screen.getByText('Yes')).toBeInTheDocument(); + expect(screen.queryByText('Not tracked')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Where does this compliance status come from\?/i }), + ).toBeInTheDocument(); + // Provider-named check badges, pass and fail. + expect(screen.getByText('Disk Encryption')).toBeInTheDocument(); + expect(screen.getByText('Firewall')).toBeInTheDocument(); + }); + + it('shows "No" when the source reports non-compliant', () => { + render( + , + ); + expect(screen.getByText('No')).toBeInTheDocument(); + expect(screen.queryByText('Not tracked')).not.toBeInTheDocument(); + }); + + it('keeps "Not tracked" when the source reports checks but no verdict', () => { + render( + , + ); + // Checks render, but with no overall verdict the compliance column stays honest. + expect(screen.getByText('OS up to date')).toBeInTheDocument(); + expect(screen.getByText('Not tracked')).toBeInTheDocument(); }); it('renders a source filter only when more than one source is present', () => { diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx index 5b5e0f059b..2e3069e764 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx @@ -219,4 +219,79 @@ describe('DeviceComplianceChart', () => { // Array.every on empty array returns true expect(screen.getByText('Compliant (1)')).toBeInTheDocument(); }); + + it('does not show a Not Tracked legend entry when every device has a verdict', () => { + render( + , + ); + expect(screen.queryByText(/Not Tracked/)).not.toBeInTheDocument(); + }); +}); + +function makeIntegrationDevice( + overrides: Partial = {}, +): DeviceWithChecks { + return makeAgentDevice({ + source: 'integration', + integrationProvider: { slug: 'intune', name: 'Microsoft Intune' }, + // Server derives non_compliant for imported rows; the chart must use the + // source verdict (or Not Tracked), never this derived status. + isCompliant: false, + complianceStatus: 'non_compliant', + agentVersion: null, + ...overrides, + }); +} + +describe('DeviceComplianceChart — integration-imported devices', () => { + it('counts an imported device with a source verdict of compliant as Compliant', () => { + render( + , + ); + expect(screen.getByText('Compliant (1)')).toBeInTheDocument(); + expect(screen.getByText('Non-Compliant (1)')).toBeInTheDocument(); + expect(screen.queryByText(/Not Tracked/)).not.toBeInTheDocument(); + }); + + it('counts an imported device with a source verdict of non-compliant as Non-Compliant', () => { + render( + , + ); + expect(screen.getByText('Non-Compliant (1)')).toBeInTheDocument(); + }); + + it('puts an imported device with no source verdict into a Not Tracked segment', () => { + render( + , + ); + expect(screen.getByText('Compliant (1)')).toBeInTheDocument(); + expect(screen.getByText('Non-Compliant (0)')).toBeInTheDocument(); + expect(screen.getByText('Not Tracked (1)')).toBeInTheDocument(); + }); + + it('renders the chart (not the empty state) for an org with ONLY imported devices', () => { + render( + , + ); + expect(screen.queryByText(/No device data available/)).not.toBeInTheDocument(); + 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 32b6a59d6a..0345049698 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 @@ -16,20 +16,22 @@ interface DeviceComplianceChartProps { agentDevices: DeviceWithChecks[]; } +// Design-system tokens (full oklch values — no hsl() wrapper). The previous +// `hsl(var(--chart-positive))` tokens came from the legacy @trycompai/ui +// stylesheet the app no longer loads, so every segment silently rendered as +// SVG-default black. const CHART_COLORS = { - compliant: 'hsl(var(--chart-positive))', - nonCompliant: 'hsl(var(--chart-destructive))', + compliant: 'var(--primary)', // brand green + nonCompliant: 'var(--destructive)', // red + notTracked: 'var(--muted-foreground)', // gray — no compliance data available }; export function DeviceComplianceChart({ fleetDevices, agentDevices }: DeviceComplianceChartProps) { - // Integration-imported devices carry no compliance data, so they must not be - // counted as non-compliant here — this chart reflects compliance-tracked - // devices only (agent + Fleet). - const trackedAgentDevices = React.useMemo( - () => (agentDevices ?? []).filter((d) => d.source === 'device_agent'), - [agentDevices], - ); - const devices = [...trackedAgentDevices, ...(fleetDevices ?? [])]; + // ALL devices count — the chart total must match the table so the page never + // looks self-contradictory. Imported devices use the SOURCE's verdict when + // it reports one; imported devices with no verdict land in a gray + // "Not tracked" segment rather than being fabricated into a verdict. + const devices = [...(agentDevices ?? []), ...(fleetDevices ?? [])]; const { pieDisplayData, legendDisplayData } = React.useMemo(() => { if (devices.length === 0) { @@ -37,10 +39,19 @@ export function DeviceComplianceChart({ fleetDevices, agentDevices }: DeviceComp } let compliantCount = 0; let nonCompliantCount = 0; + let notTrackedCount = 0; - // Count device-agent devices. Stale devices (no check-in for >= 7 days) - // count as non-compliant to match the table's three-state rendering. - for (const device of trackedAgentDevices) { + for (const device of agentDevices ?? []) { + if (device.source === 'integration') { + // Source-reported verdict (e.g. Intune complianceState), if any. + const verdict = device.sourceCompliance?.isCompliant; + if (verdict === true) compliantCount++; + else if (verdict === false) nonCompliantCount++; + else notTrackedCount++; + continue; + } + // Device-agent devices. Stale devices (no check-in for >= 7 days) + // count as non-compliant to match the table's three-state rendering. if (device.complianceStatus === 'compliant') { compliantCount++; } else { @@ -69,12 +80,21 @@ export function DeviceComplianceChart({ fleetDevices, agentDevices }: DeviceComp value: nonCompliantCount, fill: CHART_COLORS.nonCompliant, }, + { + name: 'Not Tracked', + value: notTrackedCount, + fill: CHART_COLORS.notTracked, + }, ]; return { pieDisplayData: allItems.filter((item) => item.value > 0), - legendDisplayData: allItems, + // "Not Tracked" only appears in the legend when it exists — orgs with no + // verdict-less imported devices keep the familiar two-entry legend. + legendDisplayData: allItems.filter( + (item) => item.name !== 'Not Tracked' || item.value > 0, + ), }; - }, [trackedAgentDevices, fleetDevices]); + }, [agentDevices, fleetDevices]); const totalDevices = devices.length; 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 536229aaa2..a52874cde2 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 @@ -22,6 +22,9 @@ import { CHECK_FIELDS, PLATFORM_LABELS, isDeviceOnline, + sourceChecks, + sourceReportedTooltipCopy, + sourceVerdict, staleLabel, staleTooltipCopy, } from '../lib/device-source'; @@ -30,7 +33,37 @@ import { RevokeAgentAccessDialog } from './RevokeAgentAccessDialog'; function DeviceComplianceBadge({ device }: { device: DeviceWithChecks }) { if (device.source === 'integration') { - return ; + // Show the SOURCE's own verdict when it reports one (attributed via the + // same tooltip pattern as the stale/not-tracked badges, so the provenance + // is reachable by keyboard/touch); otherwise untracked, as before. + const verdict = sourceVerdict(device); + if (verdict === undefined) { + return ; + } + return ( +
+ + {verdict ? 'Compliant' : 'Non-Compliant'} + + + + + + + + {sourceReportedTooltipCopy(device)} + + + +
+ ); } if (device.complianceStatus === 'stale') { return ( @@ -82,8 +115,11 @@ export const DeviceDetails = ({ device, onClose }: DeviceDetailsProps) => {
- {/* Live online/offline status only applies to agent devices. */} - {device.source === 'device_agent' && ( + {/* Live status: agent devices report directly; imported devices + carry the provider's last-contact timestamp (lastSeenAt), so + the same rule applies — and stays consistent with the list. */} + {(device.source === 'device_agent' || + device.source === 'integration') && ( { {device.name} - {device.source === 'device_agent' && ( + {(device.source === 'device_agent' || + device.source === 'integration') && ( {isDeviceOnline(device.lastCheckIn) ? 'Online' : 'Offline'} @@ -191,7 +228,35 @@ export const DeviceDetails = ({ device, onClose }: DeviceDetailsProps) => { - {CHECK_FIELDS.map(({ key, dbKey, label }) => { + {/* Imported device with SOURCE-reported checks: show those (provider + vocabulary) instead of CompAI's fixed agent checks. */} + {device.source === 'integration' && sourceChecks(device).length > 0 && + sourceChecks(device).map((check) => ( + + + + {check.label} + + + + + Reported by {device.integrationProvider?.name ?? 'the integration'} + + + + + {check.passed ? 'Pass' : 'Fail'} + + + + + — + + + + ))} + {!(device.source === 'integration' && sourceChecks(device).length > 0) && + CHECK_FIELDS.map(({ key, dbKey, label }) => { const isIntegration = device.source === 'integration'; const isFleetUnsupported = device.source === 'fleet' && key !== 'diskEncryptionEnabled'; diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx index 74db9d1380..9e744dbcac 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx @@ -30,7 +30,10 @@ import { isComplianceTracked, isDeviceOnline, notTrackedTooltipCopy, + sourceChecks, sourceLabel, + sourceReportedTooltipCopy, + sourceVerdict, staleLabel, staleTooltipCopy, } from '../lib/device-source'; @@ -108,11 +111,26 @@ export function NotTrackedBadge({ device }: { device: DeviceWithChecks }) { } export function CompliantBadge({ device }: { device: DeviceWithChecks }) { - // Integration-imported devices are inventory records, not compliance records — - // CompAI never ran security checks on them, so showing "No" (red) would be a - // false negative. Present them as untracked instead. + // Integration-imported devices: CompAI never ran security checks on them, so + // a red "No" from OUR checks would be a false negative. But when the SOURCE + // reports its own verdict (e.g. Intune complianceState), show that — clearly + // attributed to the provider. No verdict → untracked, as before. if (!isComplianceTracked(device)) { - return ; + const verdict = sourceVerdict(device); + if (verdict === undefined) { + return ; + } + return ( +
+ + {verdict ? 'Yes' : 'No'} + + +
+ ); } if (device.complianceStatus === 'stale') { @@ -133,6 +151,26 @@ export function CompliantBadge({ device }: { device: DeviceWithChecks }) { } export function CheckBadges({ device }: { device: DeviceWithChecks }) { + // Imported devices: render whatever checks the SOURCE reported, in the + // provider's own naming. Nothing reported → placeholder dashes, as before. + if (!isComplianceTracked(device)) { + const checks = sourceChecks(device); + if (checks.length > 0) { + return ( +
+ {checks.map((check) => ( + + {check.label} + + ))} +
+ ); + } + } if (!isComplianceTracked(device) || device.complianceStatus === 'stale') { const reason = !isComplianceTracked(device) ? 'not collected for imported devices' @@ -174,14 +212,16 @@ export function DeviceTableRow({ onRequestRemove, }: DeviceTableRowProps) { const isAgent = device.source === 'device_agent'; - const showOnline = isAgent && isDeviceOnline(device.lastCheckIn); + // Imported devices carry the PROVIDER's last-contact timestamp in + // lastCheckIn (see device sync lastSeenAt), so the same online rule applies + // honestly to them too. Fleet rows keep the spacer. + const showsLiveDot = isAgent || device.source === 'integration'; + const showOnline = showsLiveDot && isDeviceOnline(device.lastCheckIn); return ( onSelect(device)} style={{ cursor: 'pointer' }}>
- {/* Live status only applies to agent devices; imported/fleet rows get a - spacer so they aren't visually labeled as an offline agent. */} - {isAgent ? ( + {showsLiveDot ? ( { - vi.clearAllMocks(); - mockUseDeviceSync.mockReturnValue({ - selectedProvider: 'jamf', +function mockHook( + overrides: Partial> = {}, +) { + mockUseDeviceSync.mockReturnValue({ ...buildHookReturn(), ...overrides }); +} + +function buildHookReturn() { + return { + selectedProvider: 'jamf' as string | null, isSyncing: false, isLoading: false, availableProviders: [provider], @@ -43,7 +50,12 @@ beforeEach(() => { getProviderName: (slug: string) => (slug === 'jamf' ? 'Jamf' : slug), getProviderLogo: () => provider.logoUrl, hasAnyConnection: true, - }); + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockHook(); }); describe('DeviceSyncProviderSelector — RBAC gating', () => { @@ -55,16 +67,36 @@ describe('DeviceSyncProviderSelector — RBAC gating', () => { render(); + expect( + screen.getByRole('combobox', { name: /Sync devices from/i }), + ).toBeInTheDocument(); expect( screen.getByRole('button', { name: /Sync now/i }), ).toBeInTheDocument(); - expect(screen.getByText('Jamf')).toBeInTheDocument(); + // Trigger (and the inline option list) show the selected provider name. + expect(screen.getAllByText('Jamf').length).toBeGreaterThan(0); // Hook is enabled (and therefore allowed to hit the device-sync APIs). expect(mockUseDeviceSync).toHaveBeenCalledWith( expect.objectContaining({ enabled: true }), ); }); + it('marks the saved provider as selected in the open dropdown (controlled select)', async () => { + const user = userEvent.setup(); + mockHasPermission.mockImplementation( + (resource: string, action: string) => + resource === 'integration' && action === 'update', + ); + + render(); + + await user.click( + screen.getByRole('combobox', { name: /Sync devices from/i }), + ); + const jamfOption = await screen.findByRole('option', { name: /Jamf/i }); + expect(jamfOption).toHaveAttribute('aria-selected', 'true'); + }); + it('renders nothing for a user without integration:update and disables the hook', () => { mockHasPermission.mockReturnValue(false); @@ -80,28 +112,34 @@ describe('DeviceSyncProviderSelector — RBAC gating', () => { ); }); - it('shows the provider picker when the saved provider is no longer connected', () => { + it('shows the provider picker when the saved provider is no longer connected', async () => { + const user = userEvent.setup(); mockHasPermission.mockImplementation( (resource: string, action: string) => resource === 'integration' && action === 'update', ); - mockUseDeviceSync.mockReturnValue({ + mockHook({ selectedProvider: 'jamf', // saved, but no longer in the connected list - isSyncing: false, - isLoading: false, availableProviders: [{ ...provider, slug: 'kandji', name: 'Kandji' }], - syncDevices: vi.fn(), - setSyncProvider: vi.fn(), - getProviderName: (slug: string) => (slug === 'kandji' ? 'Kandji' : slug), - getProviderLogo: () => provider.logoUrl, - hasAnyConnection: true, }); render(); // The picker must be available so the user can switch to a connected provider. - expect(screen.getByRole('combobox')).toBeInTheDocument(); - expect(screen.getByRole('option', { name: 'Kandji' })).toBeInTheDocument(); + const trigger = screen.getByRole('combobox', { name: /Sync devices from/i }); + expect(screen.getByText('Not syncing')).toBeInTheDocument(); + await user.click(trigger); + expect( + await screen.findByRole('option', { name: /Kandji/i }), + ).toBeInTheDocument(); + // The saved provider is still the user's choice — "Don't auto-sync" must + // NOT be marked Active just because the choice can't be resolved to a + // connected provider. + expect(screen.queryByText('Active')).not.toBeInTheDocument(); + // No Sync now button without a connected selected provider. + expect( + screen.queryByRole('button', { name: /Sync now/i }), + ).not.toBeInTheDocument(); }); it('does not render for read-only integration access (integration:read only)', () => { @@ -115,3 +153,127 @@ describe('DeviceSyncProviderSelector — RBAC gating', () => { expect(container).toBeEmptyDOMElement(); }); }); + +describe('DeviceSyncProviderSelector — last synced text', () => { + beforeEach(() => { + mockHasPermission.mockImplementation( + (resource: string, action: string) => + resource === 'integration' && action === 'update', + ); + }); + + it('shows no inline synced text — sync times live inside the dropdown info block', () => { + const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); + mockHook({ + availableProviders: [{ ...provider, lastSyncAt: threeHoursAgo }], + }); + + render(); + + expect(screen.queryByText(/^Synced /)).not.toBeInTheDocument(); + }); +}); + +describe('DeviceSyncProviderSelector — connection states', () => { + beforeEach(() => { + mockHasPermission.mockImplementation( + (resource: string, action: string) => + resource === 'integration' && action === 'update', + ); + }); + + it('shows a labeled connect slot when no device-sync integration has a connection', () => { + mockHook({ + selectedProvider: null, + availableProviders: [ + { ...provider, connected: false, connectionStatus: null, connectionId: null }, + ], + hasAnyConnection: false, + }); + + render(); + + expect(screen.getByText('Device sync')).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /Connect an integration/i }), + ).toHaveAttribute('href', '/org_1/integrations'); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); + + it('lists a broken connection as a disabled option marked Reconnect', async () => { + const user = userEvent.setup(); + mockHook({ + selectedProvider: null, + availableProviders: [ + { + ...provider, + slug: 'intune', + name: 'Intune', + connected: false, + connectionStatus: 'error', + connectionId: null, + }, + ], + hasAnyConnection: false, + }); + + render(); + + // The select renders (not the connect slot): the org HAS a connection, + // it just needs a reconnect — and the closed trigger says so. + const trigger = screen.getByRole('combobox', { name: /Sync devices from/i }); + expect(screen.getByText('Needs reconnection')).toBeInTheDocument(); + await user.click(trigger); + const intuneOption = await screen.findByRole('option', { name: /Intune/i }); + expect(intuneOption).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByText('Reconnect')).toBeInTheDocument(); + // Not selectable as a sync source, so no Sync now button either. + expect( + screen.queryByRole('button', { name: /Sync now/i }), + ).not.toBeInTheDocument(); + }); + + it('shows Needs reconnection when the SAVED provider is errored even if another provider is connected', () => { + mockHook({ + selectedProvider: 'intune', // the chosen sync source — its connection broke + availableProviders: [ + { ...provider, slug: 'kandji', name: 'Kandji' }, // still connected + { + ...provider, + slug: 'intune', + name: 'Intune', + connected: false, + connectionStatus: 'error', + connectionId: null, + }, + ], + hasAnyConnection: true, + }); + + render(); + + // The daily sync is failing — the closed trigger must say so, not the + // bland "Not syncing". + expect(screen.getByText('Needs reconnection')).toBeInTheDocument(); + expect(screen.queryByText('Not syncing')).not.toBeInTheDocument(); + }); + + it('falls back to the connect slot when the API omits connectionStatus (older API response)', () => { + mockHook({ + selectedProvider: null, + availableProviders: [ + // Explicitly undefined — the base fixture carries 'active', which + // would not represent an older API response lacking the field. + { ...provider, connected: false, connectionStatus: undefined, connectionId: null }, + ], + hasAnyConnection: false, + }); + + render(); + + expect( + screen.getByRole('link', { name: /Connect an integration/i }), + ).toBeInTheDocument(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.tsx index 1d899a7019..23954bd40a 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceSyncProviderSelector.tsx @@ -1,11 +1,31 @@ 'use client'; +import Image from 'next/image'; +import Link from 'next/link'; import { useParams } from 'next/navigation'; -import { Button, Skeleton } from '@trycompai/design-system'; -import { Renew } from '@trycompai/design-system/icons'; +import { + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, + Separator, + Skeleton, +} from '@trycompai/design-system'; +import { InProgress, Renew } from '@trycompai/design-system/icons'; import { usePermissions } from '@/hooks/use-permissions'; import { useDeviceSync } from '../hooks/useDeviceSync'; +const NO_SYNC_VALUE = '__no_sync__'; + +/** + * Picks which connected integration supplies device inventory for this org — + * the device-sync counterpart of the People tab's sync source selects + * (TwoFactorSourceSelector / people sync). Always visible for users with + * integration:update: shows a "Connect an integration" slot when the org has + * no device-sync-capable connection, and lists broken (errored) connections + * as disabled options marked "Reconnect". + */ export function DeviceSyncProviderSelector() { const { orgId } = useParams<{ orgId: string }>(); const { hasPermission } = usePermissions(); @@ -20,9 +40,6 @@ export function DeviceSyncProviderSelector() { availableProviders, syncDevices, setSyncProvider, - getProviderName, - getProviderLogo, - hasAnyConnection, } = useDeviceSync({ organizationId: orgId, enabled: canManageDeviceSync }); if (!canManageDeviceSync) { @@ -33,92 +50,200 @@ export function DeviceSyncProviderSelector() { return ; } - if (!hasAnyConnection) { - return null; - } - const connectedProviders = availableProviders.filter((p) => p.connected); + // Broken connections (e.g. expired OAuth) — shown as disabled options so the + // user knows device sync exists and a reconnect brings it back. + const erroredProviders = availableProviders.filter( + (p) => !p.connected && p.connectionStatus === 'error', + ); + const selected = connectedProviders.find((p) => p.slug === selectedProvider); + // The saved sync source's own connection is broken — the daily sync is + // failing, which the closed trigger must surface even when other providers + // are still connected. + const selectedIsErrored = erroredProviders.some( + (p) => p.slug === selectedProvider, + ); + + // Empty slot instead of nothing: the labeled placeholder shows exactly what + // this setting is and how to unlock it (mirrors TwoFactorSourceSelector). + // Right-aligned like the populated control so the slot doesn't jump sides. + if (connectedProviders.length === 0 && erroredProviders.length === 0) { + return ( +
+
+ Device sync + + Connect an integration + + +
+
+ ); + } - const handleSync = async () => { - if (!selectedProvider) return; - await syncDevices(selectedProvider); + const handleValueChange = (value: string | null) => { + if (!value) return; + if (value === NO_SYNC_VALUE) { + void setSyncProvider(null); + return; + } + void syncDevices(value); }; - const handleProviderChange = (e: React.ChangeEvent) => { - void setSyncProvider(e.target.value || null); + const handleSyncNow = async () => { + if (!selected) return; + await syncDevices(selected.slug); }; return ( -
-
- {selectedProvider ? ( - <> - -
-
- {getProviderName(selectedProvider)} + // Right-aligned inline row: [provider select] · [↻]. The provider name + + // refresh affordance say what this is; last/next sync times live inside + // the dropdown's info block. +
+
+ {/* Controlled: unlike the popover-hosted people-sync selects (which + stay uncontrolled to survive popover dismissal), this renders + directly on the page — passing value keeps the saved selection + checked in the open dropdown for keyboard and screen-reader users. + Guarded to values that exist as items: a saved provider that is no + longer listed would otherwise break the select. */} + - + {connectedProviders.map((p) => ( - + +
+ {p.logoUrl && ( + + )} + {p.name} + {selectedProvider === p.slug && ( + Active + )} +
+
))} - - ) : null} - - {selectedProvider && ( - - )} + {erroredProviders.map((p) => ( + +
+ {p.logoUrl && ( + + )} + {p.name} + + Reconnect + +
+
+ ))} + + +
+ Don't auto-sync + {/* Keyed on the saved slug, not the resolved connected + provider: a saved provider whose connection broke is still + the user's choice — auto-sync was never disabled. */} + {!selectedProvider && ( + Active + )} +
+
+ +
+ + {selected && ( + // Icon-only re-sync, neutral outline — a lone refresh icon doesn't + // warrant primary emphasis. aria-label + title keep it accessible. + + )}
); } diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useDeviceSync.ts b/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useDeviceSync.ts index 20b4e8e532..54cca80f1e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useDeviceSync.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useDeviceSync.ts @@ -10,6 +10,12 @@ export interface DeviceSyncProviderInfo { name: string; logoUrl: string; connected: boolean; + /** + * 'error' means the org has a connection for this provider but it is broken + * (e.g. expired OAuth) and needs a reconnect. Optional so the UI tolerates + * API responses from before this field existed. + */ + connectionStatus?: 'active' | 'error' | null; connectionId: string | null; lastSyncAt: string | null; nextSyncAt: string | null; diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts b/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts index 5658cae312..4cbc938f55 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts @@ -1,4 +1,4 @@ -import type { DeviceWithChecks } from '../types'; +import type { DeviceWithChecks, SourceComplianceCheck } from '../types'; /** * Human label for where a device came from. Shared by the devices table, the @@ -33,6 +33,30 @@ export function isComplianceTracked(device: DeviceWithChecks): boolean { return device.source !== 'integration'; } +/** + * The SOURCE integration's own compliance verdict for an imported device, or + * undefined when the provider doesn't report one (renders "Not tracked"). + */ +export function sourceVerdict(device: DeviceWithChecks): boolean | undefined { + if (device.source !== 'integration') return undefined; + return device.sourceCompliance?.isCompliant; +} + +/** + * The SOURCE integration's own named checks for an imported device (empty when + * the provider reports none). Provider vocabulary — rendered as-is. + */ +export function sourceChecks(device: DeviceWithChecks): SourceComplianceCheck[] { + if (device.source !== 'integration') return []; + return device.sourceCompliance?.checks ?? []; +} + +/** Tooltip copy for compliance values reported by the source integration. */ +export function sourceReportedTooltipCopy(device: DeviceWithChecks): string { + const provider = device.integrationProvider?.name ?? 'the integration'; + return `Reported by ${provider}. CompAI didn't run these checks itself — install the CompAI agent for measured compliance.`; +} + // --------------------------------------------------------------------------- // Shared device presentation helpers (used by both the list and details views // so copy/labels/thresholds can't drift between them). diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts index 638ab9aaf9..81ab47c0e5 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts @@ -1,5 +1,5 @@ import type { DeviceWithChecks } from '../types'; -import { isComplianceTracked, sourceLabel } from './device-source'; +import { isComplianceTracked, sourceLabel, sourceVerdict } from './device-source'; export const DEVICES_CSV_HEADER = [ 'Device Name', @@ -38,10 +38,18 @@ function yesNo(value: boolean): 'yes' | 'no' { export function buildDevicesCsv(devices: DeviceWithChecks[]): string { const rows = devices.map((d) => { - // Integration-imported devices are inventory-only; agent + Fleet carry real - // compliance. Use the shared helper so the CSV matches the rest of the UI. + // Integration-imported devices are inventory-only for OUR checks; agent + + // Fleet carry measured compliance. When the source integration reports its + // own verdict, export it with explicit attribution. const tracked = isComplianceTracked(d); - const status = tracked ? d.complianceStatus : 'not_tracked'; + const verdict = sourceVerdict(d); + const status = tracked + ? d.complianceStatus + : verdict === undefined + ? 'not_tracked' + : verdict + ? 'compliant (source-reported)' + : 'non_compliant (source-reported)'; const check = (value: boolean) => (tracked ? yesNo(value) : 'n/a'); return [ escapeCell(d.name), diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts b/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts index 3ebb9834f8..4ea5648d4c 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts @@ -11,6 +11,23 @@ export type CheckDetailEntry = { export type CheckDetails = Record; +/** A compliance check as reported by the source integration (provider naming). */ +export type SourceComplianceCheck = { + id: string; + label: string; + passed: boolean; +}; + +/** + * Compliance reported by the SOURCE integration for imported devices. Both + * fields optional — providers report what they know. null/absent = the source + * reports no compliance (UI shows "Not tracked"). + */ +export type SourceCompliance = { + isCompliant?: boolean; + checks?: SourceComplianceCheck[]; +}; + export interface DeviceWithChecks { id: string; name: string; @@ -46,6 +63,11 @@ export interface DeviceWithChecks { name: string; logoUrl?: string; }; + /** + * Set only when `source === 'integration'` and the provider reports + * compliance: the source's verdict and/or its own named checks. + */ + sourceCompliance?: SourceCompliance | null; /** Derived on the server; 'stale' = no check-in for >= 7 days. */ complianceStatus: DeviceComplianceStatus; /** Whole days since last check-in, or null when never synced. */ diff --git a/apps/app/src/app/api/people/agent-devices/route.ts b/apps/app/src/app/api/people/agent-devices/route.ts index c44fa85ae4..8e5d3aa872 100644 --- a/apps/app/src/app/api/people/agent-devices/route.ts +++ b/apps/app/src/app/api/people/agent-devices/route.ts @@ -5,7 +5,11 @@ import { daysSinceCheckIn, getDeviceComplianceStatus, } from '@trycompai/utils/devices'; -import type { CheckDetails, DeviceWithChecks } from '@/app/(app)/[orgId]/people/devices/types'; +import type { + CheckDetails, + DeviceWithChecks, + SourceCompliance, +} from '@/app/(app)/[orgId]/people/devices/types'; /** Maps the DB `DeviceSource` enum to the frontend source discriminant. */ function mapSource(source: string): DeviceWithChecks['source'] { @@ -126,6 +130,12 @@ export async function GET(req: Request) { }, source, ...(provider ? { integrationProvider: provider } : {}), + // Source-reported compliance for imported devices (null when the + // provider reports none). The stored JSON was validated against + // SyncDeviceSchema at sync time. + ...(source === 'integration' + ? { sourceCompliance: (device.sourceCompliance as SourceCompliance) ?? null } + : {}), complianceStatus, daysSinceLastCheckIn: daysSinceCheckIn(device.lastCheckIn), hasActiveAgentSession: diff --git a/packages/db/prisma/migrations/20260710015503_add_device_source_compliance/migration.sql b/packages/db/prisma/migrations/20260710015503_add_device_source_compliance/migration.sql new file mode 100644 index 0000000000..98c740e1d6 --- /dev/null +++ b/packages/db/prisma/migrations/20260710015503_add_device_source_compliance/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Device" ADD COLUMN "sourceCompliance" JSONB; + +-- AlterTable +ALTER TABLE "FrameworkEditorFrameworkFamily" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP; diff --git a/packages/db/prisma/schema/device.prisma b/packages/db/prisma/schema/device.prisma index 8c1b93fe7a..76655d8a4f 100644 --- a/packages/db/prisma/schema/device.prisma +++ b/packages/db/prisma/schema/device.prisma @@ -33,6 +33,12 @@ model Device { integrationConnectionId String? externalDeviceId String? + // Compliance as reported by the SOURCE integration (device sync), kept + // separate from the agent's measured columns above so provenance never + // blurs. Shape: { isCompliant?: boolean, checks?: [{id,label,passed}] }. + // NULL = the source reports no compliance (renders "Not tracked"). + sourceCompliance Json? + @@unique([serialNumber, organizationId]) // Prevents duplicate integration-sourced devices for the same connection and // also serves the device-sync fallback lookup (externalDeviceId + connection). diff --git a/packages/integration-platform/src/dsl/__tests__/interpreter.test.ts b/packages/integration-platform/src/dsl/__tests__/interpreter.test.ts index aed2a4fb34..44fce68346 100644 --- a/packages/integration-platform/src/dsl/__tests__/interpreter.test.ts +++ b/packages/integration-platform/src/dsl/__tests__/interpreter.test.ts @@ -1129,6 +1129,65 @@ describe('interpretDeclarativeDeviceSync', () => { expect(devices[0]!.name).toBe('Good'); }); + it('keeps source-reported compliance fields (verdict, named checks, lastSeenAt)', async () => { + const runner = interpretDeclarativeDeviceSync({ + definition: deviceDef(`scope.devices = [ + { + name: 'PC', platform: 'windows', userEmail: 'a@x.com', status: 'active', + externalId: 'ext-1', + isCompliant: true, + checks: [ + { id: 'disk_encryption', label: 'Disk Encryption', passed: true }, + { id: 'firewall', label: 'Firewall', passed: false }, + ], + lastSeenAt: '2026-07-09T18:00:00.000Z', + }, + ];`), + }); + + const devices = await runner.run(createMockContext()); + + expect(devices).toHaveLength(1); + expect(devices[0]).toMatchObject({ + isCompliant: true, + checks: [ + { id: 'disk_encryption', label: 'Disk Encryption', passed: true }, + { id: 'firewall', label: 'Firewall', passed: false }, + ], + lastSeenAt: '2026-07-09T18:00:00.000Z', + }); + }); + + it('accepts a lastSeenAt with a timezone offset (providers do not always report UTC)', async () => { + const runner = interpretDeclarativeDeviceSync({ + definition: deviceDef(`scope.devices = [ + { name: 'PC', platform: 'windows', userEmail: 'a@x.com', status: 'active', externalId: 'e1', lastSeenAt: '2026-07-09T18:00:00+02:00' }, + ];`), + }); + + const devices = await runner.run(createMockContext()); + + expect(devices).toHaveLength(1); + expect(devices[0]!.lastSeenAt).toBe('2026-07-09T18:00:00+02:00'); + }); + + it('drops a device with an invalid lastSeenAt or an oversized checks list, keeps valid ones', async () => { + const runner = interpretDeclarativeDeviceSync({ + definition: deviceDef(` + const tooMany = Array.from({ length: 51 }, (_, i) => ({ id: 'c' + i, label: 'C' + i, passed: true })); + scope.devices = [ + { name: 'Good', platform: 'macos', userEmail: 'a@x.com', status: 'active', externalId: 'e1' }, + { name: 'Bad date', platform: 'macos', userEmail: 'b@x.com', status: 'active', externalId: 'e2', lastSeenAt: 'yesterday' }, + { name: 'Too many checks', platform: 'macos', userEmail: 'c@x.com', status: 'active', externalId: 'e3', checks: tooMany }, + ];`), + }); + + const devices = await runner.run(createMockContext()); + + expect(devices).toHaveLength(1); + expect(devices[0]!.name).toBe('Good'); + }); + it('reads from a custom devicesPath', async () => { const runner = interpretDeclarativeDeviceSync({ definition: deviceDef( diff --git a/packages/integration-platform/src/dsl/types.ts b/packages/integration-platform/src/dsl/types.ts index b3d6a52168..884a934247 100644 --- a/packages/integration-platform/src/dsl/types.ts +++ b/packages/integration-platform/src/dsl/types.ts @@ -318,6 +318,21 @@ export type SyncDefinition = z.infer; // Sync Device Schema (for dynamic device sync) // ============================================================================ +/** + * A single security/compliance check as reported by the SOURCE (the MDM / + * provider), in the provider's own vocabulary. Universal on purpose: every + * provider reports a different subset (or none), so nothing here is assumed. + */ +export const SyncDeviceCheckSchema = z.object({ + /** Stable slug from the provider, e.g. 'disk_encryption', 'firewall'. */ + id: z.string().min(1).max(64), + /** Display name in the provider's own wording, e.g. 'BitLocker'. */ + label: z.string().min(1).max(120), + passed: z.boolean(), +}); + +export type SyncDeviceCheck = z.infer; + export const SyncDeviceSchema = z.object({ name: z.string(), platform: z.enum(['macos', 'windows', 'linux']), @@ -328,6 +343,21 @@ export const SyncDeviceSchema = z.object({ userEmail: z.string(), status: z.enum(['active', 'inactive']), externalId: z.string().optional(), + /** + * Provider-reported compliance — all optional because providers differ in + * what (if anything) they report. Omitted fields render as "Not tracked" in + * the UI; only what the source actually knows is shown. + */ + isCompliant: z.boolean().optional(), + /** Capped so a buggy definition can't stuff megabytes into device rows. */ + checks: z.array(SyncDeviceCheckSchema).max(50).optional(), + /** + * When the device last contacted the PROVIDER (e.g. Intune lastSyncDateTime), + * ISO 8601. Feeds the device list's "Last seen" column and online indicator. + * offset:true — providers commonly return timezone offsets (+02:00), and a + * rejected timestamp would drop the whole device from the sync. + */ + lastSeenAt: z.string().datetime({ offset: true }).optional(), metadata: z.record(z.string(), z.unknown()).optional(), });