diff --git a/apps/app/src/app/(app)/[orgId]/auditor/(overview)/components/AuditorView.tsx b/apps/app/src/app/(app)/[orgId]/auditor/(overview)/components/AuditorView.tsx index 9b0026709c..0438f5044d 100644 --- a/apps/app/src/app/(app)/[orgId]/auditor/(overview)/components/AuditorView.tsx +++ b/apps/app/src/app/(app)/[orgId]/auditor/(overview)/components/AuditorView.tsx @@ -1,22 +1,6 @@ -'use client'; - -import { downloadAllEvidenceZip } from '@/lib/evidence-download'; -import { - Button, - Popover, - PopoverContent, - PopoverDescription, - PopoverHeader, - PopoverTitle, - PopoverTrigger, - Switch, -} from '@trycompai/design-system'; -import { ArrowDown } from '@trycompai/design-system/icons'; +import { Section, Stack } from '@trycompai/design-system'; import { Download } from 'lucide-react'; import Image from 'next/image'; -import { useParams } from 'next/navigation'; -import { useState } from 'react'; -import { toast } from 'sonner'; interface AuditorViewProps { initialContent: Record; @@ -35,84 +19,34 @@ export function AuditorView({ cSuite, reportSignatory, }: AuditorViewProps) { - const params = useParams(); - const orgId = params.orgId as string; - const [isDownloading, setIsDownloading] = useState(false); - const [includeJson, setIncludeJson] = useState(false); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const handleDownloadAllEvidence = async () => { - setIsDownloading(true); - try { - await downloadAllEvidenceZip({ - organizationName, - includeJson, - }); - toast.success('Evidence package downloaded successfully'); - setIsPopoverOpen(false); - } catch (err) { - toast.error('Failed to download evidence. Please try again.'); - console.error('Evidence download error:', err); - } finally { - setIsDownloading(false); - } - }; - return ( -
- {/* Header */} -
-
+ +
+
{logoUrl && ( - - {`${organizationName} -
- -
-
+ + {`${organizationName} +
+ +
+ + } + /> )} -
-

- {organizationName} -

-

Company Overview

-
-
- - {/* Download All Evidence Button */} - - - - - - - Export Options - Download all task evidence as ZIP - -
- Include raw JSON files - setIncludeJson(checked)} /> -
- -
-
-
- - {/* Company Information */} -
-
{reportSignatory.fullName} - - {reportSignatory.jobTitle} - -
-
- {reportSignatory.email} + {reportSignatory.jobTitle}
+
{reportSignatory.email}
) : ( '—' @@ -141,7 +71,6 @@ export function AuditorView({ /> 0 ? (
@@ -160,9 +89,8 @@ export function AuditorView({
- {/* Business Overview */}
-
+ -
+
- {/* System Architecture */}
- {/* Third Party Dependencies */}
@@ -190,20 +116,7 @@ export function AuditorView({ />
-
- ); -} - -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-
-

- {title} -

-
- {children} -
+ ); } @@ -217,7 +130,7 @@ function InfoCell({ className?: string; }) { return ( -
+
{label}
@@ -233,9 +146,7 @@ function ContentRow({ title, content }: { title: string; content?: string }) {

{title}

{hasContent ? ( -

- {content} -

+

{content}

) : (

Not yet available

)} diff --git a/apps/app/src/app/(app)/[orgId]/auditor/(overview)/components/ExportEvidenceButton.tsx b/apps/app/src/app/(app)/[orgId]/auditor/(overview)/components/ExportEvidenceButton.tsx new file mode 100644 index 0000000000..cd2794e370 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/auditor/(overview)/components/ExportEvidenceButton.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { downloadAllEvidenceZip } from '@/lib/evidence-download'; +import { + Button, + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, + Switch, +} from '@trycompai/design-system'; +import { ArrowDown } from '@trycompai/design-system/icons'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +interface ExportEvidenceButtonProps { + organizationName: string; +} + +export function ExportEvidenceButton({ organizationName }: ExportEvidenceButtonProps) { + const [isDownloading, setIsDownloading] = useState(false); + const [includeJson, setIncludeJson] = useState(false); + const [isOpen, setIsOpen] = useState(false); + + const handleDownload = async () => { + setIsDownloading(true); + try { + await downloadAllEvidenceZip({ organizationName, includeJson }); + toast.success('Evidence package downloaded successfully'); + setIsOpen(false); + } catch (err) { + toast.error('Failed to download evidence. Please try again.'); + console.error('Evidence download error:', err); + } finally { + setIsDownloading(false); + } + }; + + return ( + + Export All Evidence} /> + + + Export Options + Download all task evidence as ZIP + +
+ Include raw JSON files + +
+ +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/auditor/(overview)/layout.tsx b/apps/app/src/app/(app)/[orgId]/auditor/(overview)/layout.tsx deleted file mode 100644 index b63d721191..0000000000 --- a/apps/app/src/app/(app)/[orgId]/auditor/(overview)/layout.tsx +++ /dev/null @@ -1,4 +0,0 @@ -export default function Layout({ children }: { children: React.ReactNode }) { - return
{children}
; -} - diff --git a/apps/app/src/app/(app)/[orgId]/auditor/(overview)/page.tsx b/apps/app/src/app/(app)/[orgId]/auditor/(overview)/page.tsx index 892a0b72e4..297b5e66f4 100644 --- a/apps/app/src/app/(app)/[orgId]/auditor/(overview)/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/auditor/(overview)/page.tsx @@ -1,10 +1,11 @@ -import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb'; import { serverApi } from '@/lib/api-server'; import { parseRolesString } from '@/lib/permissions'; +import { PageHeader, PageLayout } from '@trycompai/design-system'; import { Role } from '@db'; import type { Metadata } from 'next'; import { notFound, redirect } from 'next/navigation'; import { AuditorView } from './components/AuditorView'; +import { ExportEvidenceButton } from './components/ExportEvidenceButton'; interface PeopleMember { userId: string; @@ -53,7 +54,7 @@ export default async function AuditorPage({ }: { params: Promise<{ orgId: string }>; }) { - const { orgId: organizationId } = await params; + await params; const [membersRes, orgRes, contextRes] = await Promise.all([ serverApi.get('/v1/people'), @@ -121,25 +122,22 @@ export default async function AuditorPage({ } return ( - } + /> + } > - + ); } diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx index c070aaefad..21098d2921 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx @@ -55,17 +55,7 @@ function findingHref(finding: FindingWithTask, organizationId: string): string { return `/${organizationId}/documents/${finding.evidenceSubmission.formType}?tab=findings`; } if (finding.scope) { - const peopleTab = - finding.scope === FindingScope.people - ? 'people' - : finding.scope === FindingScope.people_tasks - ? 'tasks' - : finding.scope === FindingScope.people_devices - ? 'devices' - : finding.scope === FindingScope.people_chart - ? 'chart' - : 'people'; - return `/${organizationId}/people?tab=${peopleTab}`; + return `/${organizationId}/people?tab=findings`; } return `/${organizationId}/overview`; } 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 655b5b6aef..1fb8d5af17 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 @@ -28,6 +28,7 @@ import { Badge, HStack, Label, + Skeleton, TableCell, TableRow, Text, @@ -52,6 +53,7 @@ interface MemberRowProps { customRoles?: CustomRoleOption[]; taskCompletion?: TaskCompletion; deviceStatus?: 'compliant' | 'non-compliant' | 'not-installed'; + isDeviceStatusLoading?: boolean; } function getInitials(name?: string | null, email?: string | null): string { @@ -96,6 +98,7 @@ export function MemberRow({ customRoles = [], taskCompletion, deviceStatus, + isDeviceStatusLoading = false, }: MemberRowProps) { const { orgId } = useParams<{ orgId: string }>(); @@ -233,7 +236,15 @@ export function MemberRow({ {/* DEVICE */} - {isPlatformAdmin || isDeactivated || !deviceStatus ? ( + {isPlatformAdmin || isDeactivated ? ( + + — + + ) : isDeviceStatusLoading ? ( +
+ +
+ ) : !deviceStatus ? ( diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindings.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindings.tsx deleted file mode 100644 index ed87e59bef..0000000000 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindings.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client'; - -import type { ComponentProps } from 'react'; -import { useState } from 'react'; - -import { FindingScope } from '@db'; - -import { FindingHistoryPanel } from '../../../tasks/[taskId]/components/findings/FindingHistoryPanel'; -import { PeopleFindingsList } from './PeopleFindingsList'; - -type PeopleFindingsScope = ComponentProps['scope']; - -export interface PeopleFindingsProps { - isAuditor: boolean; - isPlatformAdmin: boolean; - isAdminOrOwner: boolean; - /** Defaults to the main People directory scope */ - scope?: PeopleFindingsScope; -} - -export function PeopleFindings({ - isAuditor, - isPlatformAdmin, - isAdminOrOwner, - scope = FindingScope.people, -}: PeopleFindingsProps) { - const [selectedFindingIdForHistory, setSelectedFindingIdForHistory] = useState( - null, - ); - - return ( - <> - - {selectedFindingIdForHistory && ( - setSelectedFindingIdForHistory(null)} - /> - )} - - ); -} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.test.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.test.tsx deleted file mode 100644 index 792149a489..0000000000 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { UserPermissions } from '@/lib/permissions'; - -let mockPermissionsState: UserPermissions = {}; - -const mockHasPermission = vi.fn((resource: string, action: string): boolean => { - return mockPermissionsState[resource]?.includes(action) ?? false; -}); - -function setMockPermissions(permissions: UserPermissions): void { - mockPermissionsState = permissions; - mockHasPermission.mockImplementation((resource: string, action: string) => { - return mockPermissionsState[resource]?.includes(action) ?? false; - }); -} - -vi.mock('@/hooks/use-permissions', () => ({ - usePermissions: () => ({ - permissions: {}, - hasPermission: mockHasPermission, - }), -})); - -vi.mock('sonner', () => ({ - toast: { success: vi.fn(), error: vi.fn() }, -})); - -const mockFindings = [ - { - id: 'finding_1', - status: 'open', - type: 'soc2', - content: 'People finding', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-02T00:00:00Z', - }, -]; - -const mockMutate = vi.fn(); - -vi.mock('@/hooks/use-findings-api', () => ({ - useScopeFindings: () => ({ - data: { data: mockFindings }, - isLoading: false, - error: null, - mutate: mockMutate, - }), - useFindingActions: () => ({ - updateFinding: vi.fn(), - deleteFinding: vi.fn(), - }), -})); - -vi.mock('@db', () => ({ - FindingStatus: { - open: 'open', - needs_revision: 'needs_revision', - ready_for_review: 'ready_for_review', - closed: 'closed', - }, -})); - - -vi.mock('../../../tasks/[taskId]/components/findings/CreateFindingButton', () => ({ - CreateFindingButton: () => , -})); - -vi.mock('../../../tasks/[taskId]/components/findings/FindingItem', () => ({ - FindingItem: ({ finding }: { finding: { id: string; content: string } }) => ( -
{finding.content}
- ), -})); - -import { PeopleFindingsList } from './PeopleFindingsList'; - -const defaultProps = { - scope: 'people' as const, - isAuditor: false, - isPlatformAdmin: false, - isAdminOrOwner: false, - onViewHistory: vi.fn(), -}; - -describe('PeopleFindingsList permission gating', () => { - beforeEach(() => { - setMockPermissions({}); - vi.clearAllMocks(); - }); - - it('shows Create Finding for platform admin with finding:create', () => { - setMockPermissions({ finding: ['create', 'read'] }); - - render(); - - expect(screen.getByTestId('create-finding-btn')).toBeInTheDocument(); - }); - - it('shows Create Finding for auditor with finding:create', () => { - setMockPermissions({ finding: ['create', 'read'] }); - - render(); - - expect(screen.getByTestId('create-finding-btn')).toBeInTheDocument(); - }); - - it('hides Create Finding when user lacks finding:create even if auditor', () => { - setMockPermissions({ finding: ['read', 'update'] }); - - render(); - - expect(screen.queryByTestId('create-finding-btn')).not.toBeInTheDocument(); - }); -}); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx deleted file mode 100644 index bd398a815f..0000000000 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx +++ /dev/null @@ -1,224 +0,0 @@ -'use client'; - -import { - useFindingActions, - useScopeFindings, - type Finding, -} from '@/hooks/use-findings-api'; -import { usePermissions } from '@/hooks/use-permissions'; -import { Button } from '@trycompai/design-system'; -import type { FindingScope } from '@db'; -import { FindingStatus } from '@db'; -import { - ChevronDown, - ChevronUp, - WarningAlt, - WarningAltFilled, -} from '@trycompai/design-system/icons'; -import { useCallback, useMemo, useState } from 'react'; -import { toast } from 'sonner'; -import { CreateFindingButton } from '../../../tasks/[taskId]/components/findings/CreateFindingButton'; -import { FindingItem } from '../../../tasks/[taskId]/components/findings/FindingItem'; - -const INITIAL_DISPLAY_COUNT = 5; - -interface PeopleFindingsListProps { - scope: FindingScope; - isAuditor: boolean; - isPlatformAdmin: boolean; - isAdminOrOwner: boolean; - onViewHistory?: (findingId: string) => void; -} - -const STATUS_ORDER: Record = { - [FindingStatus.open]: 0, - [FindingStatus.needs_revision]: 1, - [FindingStatus.ready_for_review]: 2, - [FindingStatus.closed]: 3, -}; - -export function PeopleFindingsList({ - scope, - isAuditor, - isPlatformAdmin, - isAdminOrOwner, - onViewHistory, -}: PeopleFindingsListProps) { - const { data, isLoading, error, mutate } = useScopeFindings(scope); - const { updateFinding, deleteFinding } = useFindingActions(); - const { hasPermission } = usePermissions(); - const [expandedId, setExpandedId] = useState(null); - const [showAll, setShowAll] = useState(false); - - const rawFindings = data?.data || []; - - const sortedFindings = useMemo(() => { - return [...rawFindings].sort((a: Finding, b: Finding) => { - const statusDiff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status]; - if (statusDiff !== 0) return statusDiff; - return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); - }); - }, [rawFindings]); - - const visibleFindings = showAll ? sortedFindings : sortedFindings.slice(0, INITIAL_DISPLAY_COUNT); - const hiddenCount = sortedFindings.length - visibleFindings.length; - - const canCreateFinding = hasPermission('finding', 'create') && (isAuditor || isPlatformAdmin); - const canUpdateFinding = hasPermission('finding', 'update'); - const canDeleteFinding = hasPermission('finding', 'delete'); - const canChangeStatus = canUpdateFinding || isAuditor || isPlatformAdmin || isAdminOrOwner; - const canSetRestrictedStatus = isAuditor || isPlatformAdmin; - - const handleStatusChange = useCallback( - async (findingId: string, status: FindingStatus, revisionNote?: string) => { - try { - const updateData: { status: FindingStatus; revisionNote?: string | null } = { status }; - - if (status === FindingStatus.needs_revision && revisionNote) { - updateData.revisionNote = revisionNote; - } - - await updateFinding(findingId, updateData); - toast.success( - revisionNote ? 'Finding marked for revision with note' : 'Finding status updated', - ); - mutate(); - } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to update status'); - } - }, - [updateFinding, mutate], - ); - - const handleDelete = useCallback( - async (findingId: string) => { - if (!confirm('Are you sure you want to delete this finding?')) { - return; - } - - try { - await deleteFinding(findingId); - toast.success('Finding deleted'); - mutate(); - } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to delete finding'); - } - }, - [deleteFinding, mutate], - ); - - const openFindingsCount = sortedFindings.filter( - (f: Finding) => f.status === FindingStatus.open || f.status === FindingStatus.needs_revision, - ).length; - - if (isLoading) { - return ( -
-
-
- ); - } - - if (error) { - return ( -
- - Failed to load findings -
- ); - } - - return ( -
-
-
-
-
- -
-
-

Findings

-

- Audit findings and issues requiring attention -

-
-
- -
- {openFindingsCount > 0 && ( -
-
- - {openFindingsCount}{' '} - {openFindingsCount === 1 ? 'requires' : 'require'} action - -
- )} - - {canCreateFinding && ( - mutate()} /> - )} -
-
-
- -
- {sortedFindings.length === 0 ? ( -
- -

No findings for this area

- {canCreateFinding && ( -

Create a finding to flag an issue

- )} -
- ) : ( -
- {visibleFindings.map((finding: Finding) => ( - setExpandedId(expandedId === finding.id ? null : finding.id)} - onStatusChange={(status, revisionNote) => - handleStatusChange(finding.id, status, revisionNote) - } - onDelete={() => handleDelete(finding.id)} - onViewHistory={onViewHistory ? () => onViewHistory(finding.id) : undefined} - /> - ))} - - {sortedFindings.length > INITIAL_DISPLAY_COUNT && ( -
- -
- )} -
- )} -
-
- ); -} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsUnifiedList.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsUnifiedList.tsx new file mode 100644 index 0000000000..b05c272b63 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsUnifiedList.tsx @@ -0,0 +1,296 @@ +'use client'; + +import { + useFindingActions, + useScopeFindings, + type Finding, +} from '@/hooks/use-findings-api'; +import { usePermissions } from '@/hooks/use-permissions'; +import { Button } from '@trycompai/design-system'; +import { + ChevronDown, + ChevronUp, + WarningAlt, + WarningAltFilled, +} from '@trycompai/design-system/icons'; +import { FindingScope, FindingStatus } from '@db'; +import { Plus } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { + CreateFindingSheet, + type FindingScopeOption, +} from '../../../tasks/[taskId]/components/findings/CreateFindingSheet'; +import { FindingHistoryPanel } from '../../../tasks/[taskId]/components/findings/FindingHistoryPanel'; +import { FindingItem } from '../../../tasks/[taskId]/components/findings/FindingItem'; + +const INITIAL_DISPLAY_COUNT = 5; + +const PEOPLE_SCOPES: FindingScope[] = [ + FindingScope.people, + FindingScope.people_tasks, + FindingScope.people_devices, + FindingScope.people_chart, +]; + +const SCOPE_LABELS: Record = { + [FindingScope.people]: 'Directory', + [FindingScope.people_tasks]: 'Tasks', + [FindingScope.people_devices]: 'Devices', + [FindingScope.people_chart]: 'Chart', +}; + +const STATUS_ORDER: Record = { + [FindingStatus.open]: 0, + [FindingStatus.needs_revision]: 1, + [FindingStatus.ready_for_review]: 2, + [FindingStatus.closed]: 3, +}; + +interface PeopleFindingsUnifiedListProps { + isAuditor: boolean; + isPlatformAdmin: boolean; + isAdminOrOwner: boolean; + showTasksScope: boolean; +} + +export function PeopleFindingsUnifiedList({ + isAuditor, + isPlatformAdmin, + isAdminOrOwner, + showTasksScope, +}: PeopleFindingsUnifiedListProps) { + const directory = useScopeFindings(FindingScope.people); + const tasks = useScopeFindings(showTasksScope ? FindingScope.people_tasks : null); + const devices = useScopeFindings(FindingScope.people_devices); + const chart = useScopeFindings(FindingScope.people_chart); + + const { updateFinding, deleteFinding } = useFindingActions(); + const { hasPermission } = usePermissions(); + const [expandedId, setExpandedId] = useState(null); + const [showAll, setShowAll] = useState(false); + const [historyFindingId, setHistoryFindingId] = useState(null); + const [isCreateOpen, setIsCreateOpen] = useState(false); + + const revalidate = useCallback(() => { + directory.mutate(); + tasks.mutate(); + devices.mutate(); + chart.mutate(); + }, [directory, tasks, devices, chart]); + + const isLoading = + directory.isLoading || + (showTasksScope && tasks.isLoading) || + devices.isLoading || + chart.isLoading; + const error = directory.error || tasks.error || devices.error || chart.error; + + const allFindings = useMemo(() => { + const lists = [directory.data?.data, tasks.data?.data, devices.data?.data, chart.data?.data]; + return lists.flatMap((l) => (Array.isArray(l) ? l : [])); + }, [directory.data, tasks.data, devices.data, chart.data]); + + const sortedFindings = useMemo(() => { + return [...allFindings].sort((a: Finding, b: Finding) => { + const statusDiff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status]; + if (statusDiff !== 0) return statusDiff; + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }); + }, [allFindings]); + + const visibleFindings = showAll + ? sortedFindings + : sortedFindings.slice(0, INITIAL_DISPLAY_COUNT); + const hiddenCount = sortedFindings.length - visibleFindings.length; + + const openFindingsCount = sortedFindings.filter( + (f: Finding) => f.status === FindingStatus.open || f.status === FindingStatus.needs_revision, + ).length; + + const canCreateFinding = hasPermission('finding', 'create') && (isAuditor || isPlatformAdmin); + const canUpdateFinding = hasPermission('finding', 'update'); + const canDeleteFinding = hasPermission('finding', 'delete'); + const canChangeStatus = canUpdateFinding || isAuditor || isPlatformAdmin || isAdminOrOwner; + const canSetRestrictedStatus = isAuditor || isPlatformAdmin; + + const createScopeOptions: FindingScopeOption[] = PEOPLE_SCOPES.filter( + (s) => s !== FindingScope.people_tasks || showTasksScope, + ).map((s) => ({ value: s, label: SCOPE_LABELS[s] })); + + const handleStatusChange = useCallback( + async (findingId: string, status: FindingStatus, revisionNote?: string) => { + try { + const updateData: { status: FindingStatus; revisionNote?: string | null } = { status }; + if (status === FindingStatus.needs_revision && revisionNote) { + updateData.revisionNote = revisionNote; + } + await updateFinding(findingId, updateData); + toast.success( + revisionNote ? 'Finding marked for revision with note' : 'Finding status updated', + ); + revalidate(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to update status'); + } + }, + [updateFinding, revalidate], + ); + + const handleDelete = useCallback( + async (findingId: string) => { + if (!confirm('Are you sure you want to delete this finding?')) return; + try { + await deleteFinding(findingId); + toast.success('Finding deleted'); + revalidate(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to delete finding'); + } + }, + [deleteFinding, revalidate], + ); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ + Failed to load findings +
+ ); + } + + return ( + <> +
+
+
+
+
+ +
+
+

Findings

+

+ Audit findings across the people area +

+
+
+ +
+ {openFindingsCount > 0 && ( +
+
+ + {openFindingsCount} {openFindingsCount === 1 ? 'requires' : 'require'} action + +
+ )} + + {canCreateFinding && ( + + )} +
+
+
+ +
+ {sortedFindings.length === 0 ? ( +
+ +

No findings for the people area

+ {canCreateFinding && ( +

Create a finding to flag an issue

+ )} +
+ ) : ( +
+ {visibleFindings.map((finding: Finding) => ( +
+ {finding.scope && ( + + {SCOPE_LABELS[finding.scope] ?? finding.scope} + + )} + + setExpandedId(expandedId === finding.id ? null : finding.id) + } + onStatusChange={(status, revisionNote) => + handleStatusChange(finding.id, status, revisionNote) + } + onDelete={() => handleDelete(finding.id)} + onViewHistory={() => setHistoryFindingId(finding.id)} + /> +
+ ))} + + {sortedFindings.length > INITIAL_DISPLAY_COUNT && ( +
+ +
+ )} +
+ )} +
+
+ + + + + {historyFindingId && ( + setHistoryFindingId(null)} + /> + )} + + ); +} 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 53f18fdb5b..f019ec6672 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 @@ -29,22 +29,18 @@ export type DeviceStatus = 'compliant' | 'non-compliant' | 'not-installed'; export interface TeamMembersProps { canManageMembers: boolean; canInviteUsers: boolean; - isAuditor: boolean; - isPlatformAdmin: boolean; isCurrentUserOwner: boolean; organizationId: string; - deviceStatusMap: Record; + complianceMemberIds: string[]; } export async function TeamMembers(props: TeamMembersProps) { const { canManageMembers, canInviteUsers, - isAuditor, - isPlatformAdmin, isCurrentUserOwner, organizationId, - deviceStatusMap, + complianceMemberIds, } = props; if (!organizationId) { @@ -151,12 +147,10 @@ export async function TeamMembers(props: TeamMembersProps) { organizationId={organizationId} canManageMembers={canManageMembers} canInviteUsers={canInviteUsers} - isAuditor={isAuditor} isCurrentUserOwner={isCurrentUserOwner} employeeSyncData={employeeSyncData} taskCompletionMap={taskCompletionMap} - deviceStatusMap={deviceStatusMap} - isPlatformAdmin={isPlatformAdmin} + complianceMemberIds={complianceMemberIds} /> ); } 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 2916f1a9e8..8722da9725 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 @@ -36,6 +36,9 @@ import { import { InProgress, Search } from '@trycompai/design-system/icons'; import { apiClient } from '@/lib/api-client'; +import { useMemo } from 'react'; +import { useAgentDevices } from '../../devices/hooks/useAgentDevices'; +import { useFleetHosts } from '../../devices/hooks/useFleetHosts'; import { buildDisplayItems, filterDisplayItems } from './filter-members'; import { MemberRow } from './MemberRow'; import { PendingInvitationRow } from './PendingInvitationRow'; @@ -43,19 +46,16 @@ import type { MemberWithUser, TaskCompletion, TeamMembersData } from './TeamMemb import type { EmployeeSyncConnectionsData } from '../data/queries'; import { useEmployeeSync } from '../hooks/useEmployeeSync'; -import { PeopleFindings } from './PeopleFindings'; interface TeamMembersClientProps { data: TeamMembersData; organizationId: string; canManageMembers: boolean; canInviteUsers: boolean; - isAuditor: boolean; - isPlatformAdmin: boolean; isCurrentUserOwner: boolean; employeeSyncData: EmployeeSyncConnectionsData; taskCompletionMap: Record; - deviceStatusMap: Record; + complianceMemberIds: string[]; } export function TeamMembersClient({ @@ -63,13 +63,44 @@ export function TeamMembersClient({ organizationId, canManageMembers, canInviteUsers, - isAuditor, - isPlatformAdmin, isCurrentUserOwner, employeeSyncData, taskCompletionMap, - deviceStatusMap, + complianceMemberIds, }: TeamMembersClientProps) { + const { agentDevices, isLoading: isAgentDevicesLoading } = useAgentDevices(); + const { fleetHosts, isLoading: isFleetHostsLoading } = useFleetHosts(); + const isDeviceStatusLoading = isAgentDevicesLoading || isFleetHostsLoading; + + const deviceStatusMap = useMemo(() => { + const map: Record = + {}; + const complianceSet = new Set(complianceMemberIds); + for (const id of complianceSet) { + map[id] = 'not-installed'; + } + + const agentComplianceByMember = new Map(); + for (const d of agentDevices) { + if (!d.memberId || !complianceSet.has(d.memberId)) continue; + const prev = agentComplianceByMember.get(d.memberId); + agentComplianceByMember.set(d.memberId, (prev ?? true) && d.isCompliant); + } + for (const [memberId, allCompliant] of agentComplianceByMember) { + map[memberId] = allCompliant ? 'compliant' : 'non-compliant'; + } + + for (const host of fleetHosts) { + if (!host.member_id || !complianceSet.has(host.member_id)) continue; + if (agentComplianceByMember.has(host.member_id)) continue; + const isCompliant = host.policies.every((p) => p.response === 'pass'); + if (map[host.member_id] !== 'non-compliant') { + map[host.member_id] = isCompliant ? 'compliant' : 'non-compliant'; + } + } + + return map; + }, [agentDevices, fleetHosts, complianceMemberIds]); const router = useRouter(); const [searchQuery, setSearchQuery] = useState(''); const [roleFilter, setRoleFilter] = useState(''); @@ -474,6 +505,7 @@ export function TeamMembersClient({ customRoles={customRoles} taskCompletion={taskCompletionMap[(item as MemberWithUser).id]} deviceStatus={deviceStatusMap[(item as MemberWithUser).id]} + isDeviceStatusLoading={isDeviceStatusLoading} /> ) : ( )} - - ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx index cd15f6547c..d0503767a8 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -20,6 +20,7 @@ interface PeoplePageTabsProps { employeeTasksContent: ReactNode | null; devicesContent: ReactNode; orgChartContent: ReactNode; + findingsContent: ReactNode; roleMappingContent: ReactNode | null; showRoleMapping: boolean; showEmployeeTasks: boolean; @@ -46,6 +47,9 @@ function tabParamToInternal( if (tabParam === 'chart') { return 'org-chart'; } + if (tabParam === 'findings') { + return 'findings'; + } if (tabParam === 'role-mapping') { return showRoleMapping ? 'role-mapping' : 'people'; } @@ -61,6 +65,7 @@ function internalValueToTabParam(value: string): string { return 'chart'; case 'people': case 'devices': + case 'findings': case 'role-mapping': return value; default: @@ -73,6 +78,7 @@ export function PeoplePageTabs({ employeeTasksContent, devicesContent, orgChartContent, + findingsContent, roleMappingContent, showRoleMapping, showEmployeeTasks, @@ -118,6 +124,7 @@ export function PeoplePageTabs({ {showEmployeeTasks && Tasks} Devices Chart + Findings {showRoleMapping && Role Mapping} } @@ -139,6 +146,7 @@ export function PeoplePageTabs({ )} {devicesContent} {orgChartContent} + {findingsContent} {showRoleMapping && ( {roleMappingContent} )} diff --git a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx index e75f7f35bf..61f128c55f 100644 --- a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx @@ -5,16 +5,8 @@ import { auth } from '@/utils/auth'; import type { EmployeeTrainingVideoCompletion, Member, Organization, Policy, User } from '@db'; import { db } from '@db/server'; import { headers } from 'next/headers'; -import { FindingScope } from '@db'; -import { PeopleFindings } from '../../all/components/PeopleFindings'; import { EmployeeCompletionChart } from './EmployeeCompletionChart'; -interface EmployeesOverviewProps { - isAuditor: boolean; - isPlatformAdmin: boolean; - isAdminOrOwner: boolean; -} - // Define EmployeeWithUser type similar to EmployeesList interface EmployeeWithUser extends Member { user: User; @@ -35,11 +27,7 @@ interface ProcessedTrainingVideo { }; } -export async function EmployeesOverview({ - isAuditor, - isPlatformAdmin, - isAdminOrOwner, -}: EmployeesOverviewProps) { +export async function EmployeesOverview() { const session = await auth.api.getSession({ headers: await headers(), }); @@ -135,12 +123,6 @@ export async function EmployeesOverview({ hasHipaaFramework={hasHipaaFramework} hipaaCompletions={hipaaCompletions} /> -
); } diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DevicesTabContent.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DevicesTabContent.tsx new file mode 100644 index 0000000000..c8a1e7b8fc --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DevicesTabContent.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { Skeleton } from '@trycompai/design-system'; +import { useMemo } from 'react'; +import { useAgentDevices } from '../hooks/useAgentDevices'; +import { useFleetHosts } from '../hooks/useFleetHosts'; +import { DeviceAgentDevicesList } from './DeviceAgentDevicesList'; +import { DeviceComplianceChart } from './DeviceComplianceChart'; +import { EmployeeDevicesList } from './EmployeeDevicesList'; + +interface DevicesTabContentProps { + isCurrentUserOwner: boolean; +} + +export function DevicesTabContent({ isCurrentUserOwner }: DevicesTabContentProps) { + const { + agentDevices, + isLoading: isAgentLoading, + error: agentError, + } = useAgentDevices(); + const { + fleetHosts, + isLoading: isFleetLoading, + error: fleetError, + } = useFleetHosts(); + + // Filter out Fleet hosts for members who already have device-agent devices. + // Device agent takes priority over Fleet. + const filteredFleetDevices = useMemo(() => { + const memberIdsWithAgent = new Set( + agentDevices.map((d) => d.memberId).filter(Boolean), + ); + return fleetHosts.filter( + (host) => !host.member_id || !memberIdsWithAgent.has(host.member_id), + ); + }, [agentDevices, fleetHosts]); + + const isLoading = isAgentLoading || isFleetLoading; + const error = agentError ?? fleetError; + + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + if (error) { + return ( +
+ Failed to load device data. Please try again. +
+ ); + } + + return ( +
+ + + {agentDevices.length > 0 && ( + + )} + + {filteredFleetDevices.length > 0 && ( + + )} +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts b/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts deleted file mode 100644 index b14004a382..0000000000 --- a/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts +++ /dev/null @@ -1,166 +0,0 @@ -'use server'; - -import { getFleetInstance } from '@/lib/fleet'; -import { auth } from '@/utils/auth'; -import { db } from '@db/server'; -import { headers } from 'next/headers'; -import type { CheckDetails, DeviceWithChecks } from '../types'; -import type { Host } from '../types'; - -const MDM_POLICY_ID = -9999; - -/** - * Fetches device-agent devices from the DB for the current organization. - */ -export const getEmployeeDevicesFromDB: () => Promise = async () => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - return []; - } - - const devices = await db.device.findMany({ - where: { - organizationId, - // Exclude devices for users whose User.role is platform admin (not org Member.role). - NOT: { - member: { - user: { - role: 'admin', - }, - }, - }, - }, - include: { - member: { - include: { - user: { - select: { name: true, email: true }, - }, - }, - }, - }, - orderBy: { installedAt: 'desc' }, - }); - - return devices.map((device) => ({ - id: device.id, - name: device.name, - hostname: device.hostname, - platform: device.platform as 'macos' | 'windows' | 'linux', - osVersion: device.osVersion, - serialNumber: device.serialNumber, - hardwareModel: device.hardwareModel, - isCompliant: device.isCompliant, - diskEncryptionEnabled: device.diskEncryptionEnabled, - antivirusEnabled: device.antivirusEnabled, - passwordPolicySet: device.passwordPolicySet, - screenLockEnabled: device.screenLockEnabled, - checkDetails: (device.checkDetails as CheckDetails) ?? null, - lastCheckIn: device.lastCheckIn?.toISOString() ?? null, - agentVersion: device.agentVersion, - installedAt: device.installedAt.toISOString(), - memberId: device.memberId, - user: { - name: device.member.user.name, - email: device.member.user.email, - }, - source: 'device_agent' as const, - })); -}; - -/** - * Fetches Fleet (legacy) devices for the current organization. - * Excludes members whose User.role is platform admin (same as getEmployeeDevicesFromDB). - */ -export const getFleetHosts: () => Promise = async () => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const fleet = await getFleetInstance(); - - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - return null; - } - - // Members with Fleet labels (exclude platform admins — same filter as device-agent path). - const employees = await db.member.findMany({ - where: { - organizationId, - deactivated: false, - NOT: { - user: { - role: 'admin', - }, - }, - }, - include: { - user: true, - }, - }); - - const labelIdsResponses = await Promise.all( - employees - .filter((employee) => employee.fleetDmLabelId) - .map(async (employee) => ({ - userId: employee.userId, - userName: employee.user?.name, - memberId: employee.id, - response: await fleet.get(`/labels/${employee.fleetDmLabelId}/hosts`), - })), - ); - - const hostRequests = labelIdsResponses.flatMap((entry) => - entry.response.data.hosts.map((host: { id: number }) => ({ - userId: entry.userId, - hostId: host.id, - memberId: entry.memberId, - userName: entry.userName, - })), - ); - - // Get all devices by id in parallel - const devices = await Promise.all(hostRequests.map(({ hostId }) => fleet.get(`/hosts/${hostId}`))); - const userIds = hostRequests.map(({ userId }) => userId); - const memberIds = hostRequests.map(({ memberId }) => memberId); - const userNames = hostRequests.map(({ userName }) => userName); - - const results = await db.fleetPolicyResult.findMany({ - where: { organizationId }, - orderBy: { createdAt: 'desc' }, - }); - - return devices.map((device: { data: { host: Host } }, index: number) => { - const host = device.data.host; - const platform = host.platform?.toLowerCase(); - const osVersion = host.os_version?.toLowerCase(); - const isMacOS = - platform === 'darwin' || - platform === 'macos' || - platform === 'osx' || - osVersion?.includes('mac'); - return { - ...host, - user_name: userNames[index], - member_id: memberIds[index], - policies: [ - ...(host.policies || []), - ...(isMacOS ? [{ id: MDM_POLICY_ID, name: 'MDM Enabled', response: host.mdm.connected_to_fleet ? 'pass' : 'fail' }] : []), - ].map((policy) => { - const policyResult = results.find((result) => result.fleetPolicyId === policy.id && result.userId === userIds[index]); - return { - ...policy, - response: policy.response === 'pass' || policyResult?.fleetPolicyResponse === 'pass' ? 'pass' : 'fail', - attachments: policyResult?.attachments || [], - }; - }), - }; - }); -}; diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useAgentDevices.ts b/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useAgentDevices.ts new file mode 100644 index 0000000000..10544bea8a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useAgentDevices.ts @@ -0,0 +1,36 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import useSWR from 'swr'; +import type { DeviceWithChecks } from '../types'; + +interface AgentDevicesResponse { + data: DeviceWithChecks[]; +} + +async function fetchAgentDevices(): Promise { + const res = await fetch('/api/people/agent-devices', { + credentials: 'include', + }); + if (!res.ok) { + throw new Error(`Failed to fetch agent devices: ${res.status}`); + } + const body = (await res.json()) as AgentDevicesResponse; + return Array.isArray(body.data) ? body.data : []; +} + +export function useAgentDevices() { + const { orgId } = useParams<{ orgId: string }>(); + const { data, error, isLoading, mutate } = useSWR( + orgId ? ['people-agent-devices', orgId] : null, + fetchAgentDevices, + { revalidateOnFocus: false }, + ); + + return { + agentDevices: Array.isArray(data) ? data : [], + isLoading, + error, + mutate, + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useFleetHosts.ts b/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useFleetHosts.ts new file mode 100644 index 0000000000..82dc1e7da7 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useFleetHosts.ts @@ -0,0 +1,36 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import useSWR from 'swr'; +import type { Host } from '../types'; + +interface FleetHostsResponse { + data: Host[]; +} + +async function fetchFleetHosts(): Promise { + const res = await fetch('/api/people/fleet-hosts', { + credentials: 'include', + }); + if (!res.ok) { + throw new Error(`Failed to fetch fleet hosts: ${res.status}`); + } + const body = (await res.json()) as FleetHostsResponse; + return Array.isArray(body.data) ? body.data : []; +} + +export function useFleetHosts() { + const { orgId } = useParams<{ orgId: string }>(); + const { data, error, isLoading, mutate } = useSWR( + orgId ? ['people-fleet-hosts', orgId] : null, + fetchFleetHosts, + { revalidateOnFocus: false }, + ); + + return { + fleetHosts: Array.isArray(data) ? data : [], + isLoading, + error, + mutate, + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx index f17e2fff6e..107e837068 100644 --- a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx @@ -1,9 +1,7 @@ 'use client'; import type { Edge, Node } from '@xyflow/react'; -import { FindingScope } from '@db'; -import { PeopleFindings } from '../../all/components/PeopleFindings'; import { OrgChartEditor } from './OrgChartEditor'; import { OrgChartEmptyState } from './OrgChartEmptyState'; import { OrgChartImageView } from './OrgChartImageView'; @@ -22,79 +20,43 @@ interface OrgChartData { interface OrgChartContentProps { chartData: OrgChartData | null; members: OrgChartMember[]; - isAuditor: boolean; - isPlatformAdmin: boolean; - isAdminOrOwner: boolean; } -export function OrgChartContent({ - chartData, - members, - isAuditor, - isPlatformAdmin, - isAdminOrOwner, -}: OrgChartContentProps) { - const findingsSection = ( - - ); - - // No chart exists yet - show empty state +export function OrgChartContent({ chartData, members }: OrgChartContentProps) { if (!chartData) { - return ( -
- - {findingsSection} -
- ); + return ; } - // Uploaded image mode if (chartData.type === 'uploaded' && chartData.signedImageUrl) { return ( -
- - {findingsSection} -
+ ); } - // Uploaded chart but image could not be loaded (e.g. S3 unavailable) if (chartData.type === 'uploaded') { return ( -
-
-
-

- The uploaded org chart image could not be loaded. -

-

- Please try again later or re-upload the image. -

-
+
+
+

+ The uploaded org chart image could not be loaded. +

+

+ Please try again later or re-upload the image. +

- {findingsSection}
); } - // Interactive mode return ( -
- - {findingsSection} -
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartTabContent.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartTabContent.tsx new file mode 100644 index 0000000000..a0cc7f7129 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartTabContent.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { useTeamMembers } from '../../all/hooks/useTeamMembers'; +import { useOrgChart } from '../hooks/useOrgChart'; +import { OrgChartContent } from './OrgChartContent'; +import type { OrgChartMember } from '../types'; + +interface OrgChartTabContentProps { + organizationId: string; +} + +export function OrgChartTabContent({ organizationId }: OrgChartTabContentProps) { + const { orgChart } = useOrgChart(); + const { members } = useTeamMembers({ organizationId }); + + const chartMembers: OrgChartMember[] = members + .filter((m) => !m.deactivated && m.isActive) + .map((m) => ({ + id: m.id, + user: { + name: m.user.name ?? '', + email: m.user.email ?? '', + }, + role: m.role ?? '', + jobTitle: m.jobTitle ?? null, + })); + + return ; +} diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/hooks/useOrgChart.ts b/apps/app/src/app/(app)/[orgId]/people/org-chart/hooks/useOrgChart.ts new file mode 100644 index 0000000000..0454ea3ea9 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/hooks/useOrgChart.ts @@ -0,0 +1,84 @@ +'use client'; + +import { apiClient } from '@/lib/api-client'; +import type { Edge, Node } from '@xyflow/react'; +import { useParams } from 'next/navigation'; +import useSWR from 'swr'; + +export interface OrgChartData { + id: string; + type: string; + name: string; + nodes: Node[]; + edges: Edge[]; + updatedAt: string; + signedImageUrl: string | null; +} + +interface OrgChartApiResponse { + id: string; + type: string; + name?: string | null; + nodes?: unknown; + edges?: unknown; + updatedAt: string; + signedImageUrl: string | null; +} + +function sanitize(raw: OrgChartApiResponse | null): OrgChartData | null { + if (!raw) return null; + + const rawNodes = Array.isArray(raw.nodes) ? raw.nodes : []; + const rawEdges = Array.isArray(raw.edges) ? raw.edges : []; + + const nodes = (rawNodes as Record[]) + .filter((n) => n && typeof n === 'object' && n.id) + .map((n) => ({ + ...n, + position: + n.position && + typeof (n.position as Record).x === 'number' && + typeof (n.position as Record).y === 'number' + ? n.position + : { x: 0, y: 0 }, + })) as unknown as Node[]; + + const edges = (rawEdges as Record[]) + .filter((e) => e && typeof e === 'object' && e.source && e.target) + .map((e, i) => ({ + ...e, + id: e.id || `edge-${e.source}-${e.target}-${i}`, + })) as unknown as Edge[]; + + return { + id: raw.id, + type: raw.type, + name: raw.name ?? '', + nodes, + edges, + updatedAt: raw.updatedAt, + signedImageUrl: raw.signedImageUrl ?? null, + }; +} + +async function fetchOrgChart(): Promise { + const res = await apiClient.get('/v1/org-chart'); + if (res.error) throw new Error(res.error); + return sanitize(res.data ?? null); +} + +export function useOrgChart() { + const { orgId } = useParams<{ orgId: string }>(); + const { data, error, isLoading, mutate } = useSWR( + orgId ? ['people-org-chart', orgId] : null, + fetchOrgChart, + { revalidateOnFocus: false }, + ); + + return { + orgChart: data ?? null, + isLoading, + error, + mutate, + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index b1dea19c9e..7abc47bd3b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -1,25 +1,15 @@ import { filterComplianceMembers } from '@/lib/compliance'; import { auth } from '@/utils/auth'; -import { s3Client, BUCKET_NAME } from '@/app/s3'; -import { GetObjectCommand } from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@/lib/s3-presigner'; -import { FindingScope } from '@db'; import { db } from '@db/server'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; -import { PeopleFindings } from './all/components/PeopleFindings'; +import { PeopleFindingsUnifiedList } from './all/components/PeopleFindingsUnifiedList'; import { TeamMembers } from './all/components/TeamMembers'; -import { getEmployeeSyncConnections } from './all/data/queries'; import { PeoplePageTabs } from './components/PeoplePageTabs'; import { EmployeesOverview } from './dashboard/components/EmployeesOverview'; -import { DeviceComplianceChart } from './devices/components/DeviceComplianceChart'; -import { DeviceAgentDevicesList } from './devices/components/DeviceAgentDevicesList'; -import { EmployeeDevicesList } from './devices/components/EmployeeDevicesList'; -import { getEmployeeDevicesFromDB, getFleetHosts } from './devices/data'; -import type { DeviceWithChecks } from './devices/types'; -import type { Host } from './devices/types'; -import { OrgChartContent } from './org-chart/components/OrgChartContent'; +import { DevicesTabContent } from './devices/components/DevicesTabContent'; +import { OrgChartTabContent } from './org-chart/components/OrgChartTabContent'; export default async function PeoplePage({ params }: { params: Promise<{ orgId: string }> }) { const { orgId } = await params; @@ -45,213 +35,44 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: const isCurrentUserOwner = currentUserRoles.includes('owner'); const isPlatformAdmin = session.user.role === 'admin'; - // Fetch members with user info (used for both employee check and org chart) - const membersWithUsers = await db.member.findMany({ + // Only fetch what page-level logic needs: the set of members with compliance + // obligations. Used to (a) decide whether the Tasks tab shows, and (b) tell + // the People tab which members to include in the device compliance map. + const complianceMembers = await db.member.findMany({ where: { organizationId: orgId, deactivated: false, isActive: true, }, include: { - user: { - select: { - name: true, - email: true, - role: true, - }, - }, + user: { select: { role: true } }, }, }); - // Check if there are members with compliance obligations - const employees = await filterComplianceMembers(membersWithUsers, orgId); - + const employees = await filterComplianceMembers(complianceMembers, orgId); + const complianceMemberIds = employees.map((m) => m.id); const showEmployeeTasks = employees.length > 0; - // Fetch org chart data directly via Prisma - const orgChart = await db.organizationChart.findUnique({ - where: { organizationId: orgId }, - }); - - // Generate a signed URL for uploaded images - let orgChartData = null; - if (orgChart) { - let signedImageUrl: string | null = null; - if ( - orgChart.type === 'uploaded' && - orgChart.uploadedImageUrl && - s3Client && - BUCKET_NAME - ) { - try { - const cmd = new GetObjectCommand({ - Bucket: BUCKET_NAME, - Key: orgChart.uploadedImageUrl, - }); - signedImageUrl = await getSignedUrl(s3Client, cmd, { expiresIn: 900 }); - } catch { - // Signed URL generation failed; image won't render - } - } - - // Sanitize nodes/edges from JSON to ensure valid React Flow structures - const rawNodes = Array.isArray(orgChart.nodes) ? orgChart.nodes : []; - const rawEdges = Array.isArray(orgChart.edges) ? orgChart.edges : []; - - const sanitizedNodes = (rawNodes as Record[]) - .filter((n) => n && typeof n === 'object' && n.id) - .map((n) => ({ - ...n, - position: n.position && typeof (n.position as Record).x === 'number' - ? n.position - : { x: 0, y: 0 }, - })); - - const sanitizedEdges = (rawEdges as Record[]) - .filter((e) => e && typeof e === 'object' && e.source && e.target) - .map((e, i) => ({ - ...e, - id: e.id || `edge-${e.source}-${e.target}-${i}`, - })); - - orgChartData = { - ...orgChart, - nodes: sanitizedNodes, - edges: sanitizedEdges, - updatedAt: orgChart.updatedAt.toISOString(), - signedImageUrl, - }; - } - - // Fetch devices from both sources independently — one failing shouldn't break the other - let agentDevices: DeviceWithChecks[] = []; - let fleetDevices: Host[] = []; - - const [agentResult, fleetResult, employeeSyncData] = await Promise.allSettled([ - getEmployeeDevicesFromDB(), - getFleetHosts(), - getEmployeeSyncConnections(orgId), - ]); - - if (agentResult.status === 'fulfilled') { - agentDevices = agentResult.value; - } else { - console.error('Error fetching device agent devices:', agentResult.reason); - } - - if (fleetResult.status === 'fulfilled') { - fleetDevices = fleetResult.value || []; - } else { - console.error('Error fetching Fleet devices:', fleetResult.reason); - } - - const syncConnections = employeeSyncData.status === 'fulfilled' - ? employeeSyncData.value - : null; - - // Filter out Fleet hosts for members who already have device-agent devices - // Device agent takes priority over Fleet - const memberIdsWithAgent = new Set( - agentDevices.map((d) => d.memberId).filter(Boolean), - ); - const filteredFleetDevices = fleetDevices.filter( - (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. - // Only members with compliance obligations are checked (uses RBAC obligations, - // not hardcoded role names). Members without compliance obligations (e.g. - // auditor-only, or custom roles without compliance: true) are omitted and - // will show "—" in the DEVICE column. - const deviceStatusMap: Record = {}; - const complianceMemberIds = new Set(employees.map((m) => m.id)); - - // Default all compliance members to "not-installed" — device data below overrides - for (const id of complianceMemberIds) { - deviceStatusMap[id] = 'not-installed'; - } - - // 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 || !complianceMemberIds.has(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 || !complianceMemberIds.has(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. - // Once non-compliant, a later compliant device cannot upgrade it back. - if (deviceStatusMap[host.member_id] !== 'non-compliant') { - 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) — only for members without the newer device agent */} - {filteredFleetDevices.length > 0 && ( - - )} - - -
- } - orgChartContent={ - : null} + devicesContent={} + orgChartContent={} + findingsContent={ + } showRoleMapping={false} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.test.tsx index 85aaa4b60a..c3bb899c72 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.test.tsx @@ -56,6 +56,12 @@ vi.mock('@db', () => ({ soc2: 'soc2', iso27001: 'iso27001', }, + FindingScope: { + people: 'people', + people_tasks: 'people_tasks', + people_devices: 'people_devices', + people_chart: 'people_chart', + }, })); // Mock @trycompai/ui components diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.tsx index 523dc529bc..6948545cd7 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.tsx @@ -46,13 +46,25 @@ const createFindingSchema = z.object({ content: z.string().min(1, { message: 'Finding content is required', }), + scope: z.nativeEnum(FindingScope).optional(), }); +export interface FindingScopeOption { + value: FindingScope; + label: string; +} + interface CreateFindingSheetProps { taskId?: string; evidenceSubmissionId?: string; evidenceFormType?: EvidenceFormType; scope?: FindingScope; + /** + * When provided, renders a scope picker inside the form instead of using + * the static `scope` prop. The first option is the default. + */ + scopeOptions?: FindingScopeOption[]; + scopeLabel?: string; open: boolean; onOpenChange: (open: boolean) => void; onSuccess?: () => void; @@ -63,6 +75,8 @@ export function CreateFindingSheet({ evidenceSubmissionId, evidenceFormType, scope, + scopeOptions, + scopeLabel = 'Area', open, onOpenChange, onSuccess, @@ -82,6 +96,7 @@ export function CreateFindingSheet({ type: FindingType.soc2, templateId: null, content: '', + scope: scopeOptions?.[0]?.value ?? scope, }, }); @@ -117,7 +132,7 @@ export function CreateFindingSheet({ taskId, evidenceSubmissionId, evidenceFormType, - scope, + scope: data.scope ?? scope, type: data.type, templateId: templateId || undefined, content: data.content, @@ -158,9 +173,45 @@ export function CreateFindingSheet({ }, {}); }, [templates]); + const scopeLabelMap = useMemo(() => { + const map: Partial> = {}; + for (const opt of scopeOptions ?? []) map[opt.value] = opt.label; + return map; + }, [scopeOptions]); + const findingForm = (
+ {scopeOptions && scopeOptions.length > 0 && ( + ( + + {scopeLabel} + + + + )} + /> + )} + ({ + id: device.id, + name: device.name, + hostname: device.hostname, + platform: device.platform as 'macos' | 'windows' | 'linux', + osVersion: device.osVersion, + serialNumber: device.serialNumber, + hardwareModel: device.hardwareModel, + isCompliant: device.isCompliant, + diskEncryptionEnabled: device.diskEncryptionEnabled, + antivirusEnabled: device.antivirusEnabled, + passwordPolicySet: device.passwordPolicySet, + screenLockEnabled: device.screenLockEnabled, + checkDetails: (device.checkDetails as CheckDetails) ?? null, + lastCheckIn: device.lastCheckIn?.toISOString() ?? null, + agentVersion: device.agentVersion, + installedAt: device.installedAt.toISOString(), + memberId: device.memberId, + user: { + name: device.member.user.name, + email: device.member.user.email, + }, + source: 'device_agent' as const, + })); + + return NextResponse.json({ data }); +} diff --git a/apps/app/src/app/api/people/fleet-hosts/route.ts b/apps/app/src/app/api/people/fleet-hosts/route.ts new file mode 100644 index 0000000000..4d65c52961 --- /dev/null +++ b/apps/app/src/app/api/people/fleet-hosts/route.ts @@ -0,0 +1,119 @@ +import { auth } from '@/utils/auth'; +import { getFleetInstance } from '@/lib/fleet'; +import { db } from '@db/server'; +import { headers } from 'next/headers'; +import { NextResponse } from 'next/server'; +import type { Host } from '@/app/(app)/[orgId]/people/devices/types'; + +const MDM_POLICY_ID = -9999; + +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }); + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const fleet = await getFleetInstance(); + + const employees = await db.member.findMany({ + where: { + organizationId, + deactivated: false, + NOT: { user: { role: 'admin' } }, + }, + include: { user: true }, + }); + + const labelIdsResponses = await Promise.all( + employees + .filter((employee) => employee.fleetDmLabelId) + .map(async (employee) => ({ + userId: employee.userId, + userName: employee.user?.name, + memberId: employee.id, + response: await fleet.get(`/labels/${employee.fleetDmLabelId}/hosts`), + })), + ); + + const hostRequests = labelIdsResponses.flatMap((entry) => + entry.response.data.hosts.map((host: { id: number }) => ({ + userId: entry.userId, + hostId: host.id, + memberId: entry.memberId, + userName: entry.userName, + })), + ); + + const CONCURRENCY_LIMIT = 10; + const devices: { data: { host: Host } }[] = []; + for (let i = 0; i < hostRequests.length; i += CONCURRENCY_LIMIT) { + const batch = hostRequests.slice(i, i + CONCURRENCY_LIMIT); + const batchResults = await Promise.all( + batch.map(({ hostId }) => fleet.get(`/hosts/${hostId}`)), + ); + devices.push(...batchResults); + } + const userIds = hostRequests.map(({ userId }) => userId); + const memberIds = hostRequests.map(({ memberId }) => memberId); + const userNames = hostRequests.map(({ userName }) => userName); + + const results = await db.fleetPolicyResult.findMany({ + where: { organizationId }, + orderBy: { createdAt: 'desc' }, + }); + + const resultIndex = new Map(); + for (const result of results) { + const key = `${result.userId}:${result.fleetPolicyId}`; + if (!resultIndex.has(key)) { + resultIndex.set(key, result); + } + } + + const data: Host[] = devices.map( + (device: { data: { host: Host } }, index: number) => { + const host = device.data.host; + const platform = host.platform?.toLowerCase(); + const osVersion = host.os_version?.toLowerCase(); + const isMacOS = + platform === 'darwin' || + platform === 'macos' || + platform === 'osx' || + osVersion?.includes('mac'); + return { + ...host, + user_name: userNames[index], + member_id: memberIds[index], + policies: [ + ...(host.policies || []), + ...(isMacOS + ? [ + { + id: MDM_POLICY_ID, + name: 'MDM Enabled', + response: host.mdm?.connected_to_fleet ? 'pass' : 'fail', + }, + ] + : []), + ].map((policy) => { + const policyResult = resultIndex.get( + `${userIds[index]}:${policy.id}`, + ); + return { + ...policy, + response: + policy.response === 'pass' || + policyResult?.fleetPolicyResponse === 'pass' + ? 'pass' + : 'fail', + attachments: policyResult?.attachments || [], + }; + }), + }; + }, + ); + + return NextResponse.json({ data }); +}