From 6ab00a5afb3316321c6ce86bc1280076a6988395 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:00:58 -0400 Subject: [PATCH 01/13] feat(integrations): prompt user to import after Google Workspace connection (#2383) Resolves ENG-114 Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Mariano Fuentes --- .../components/PlatformIntegrations.test.tsx | 129 +++++++++++++++++- .../components/PlatformIntegrations.tsx | 36 ++++- 2 files changed, 163 insertions(+), 2 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.test.tsx b/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.test.tsx index 31a2f3ceea..b10e3ff0f9 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.test.tsx @@ -92,6 +92,7 @@ vi.mock('next/link', () => ({ // Mock next/navigation vi.mock('next/navigation', () => ({ useParams: () => ({ orgId: 'org-1' }), + useRouter: () => ({ push: vi.fn() }), useSearchParams: () => new URLSearchParams(), })); @@ -143,9 +144,10 @@ vi.mock('lucide-react', () => ({ // Mock sonner vi.mock('sonner', () => ({ - toast: { success: vi.fn(), error: vi.fn() }, + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, })); +import { toast } from 'sonner'; import { PlatformIntegrations } from './PlatformIntegrations'; const defaultProps = { @@ -202,4 +204,129 @@ describe('PlatformIntegrations', () => { expect(screen.getByTestId('search-input')).toBeInTheDocument(); }); }); + + describe('Employee sync import prompt', () => { + it('shows import prompt toast after Google Workspace OAuth callback', async () => { + // Override mocks for this test to simulate OAuth callback + const { useIntegrationProviders, useIntegrationConnections } = vi.mocked( + await import('@/hooks/use-integration-platform'), + ); + + vi.mocked(useIntegrationProviders).mockReturnValue({ + providers: [ + { + id: 'google-workspace', + name: 'Google Workspace', + description: 'Google Workspace admin', + category: 'Identity & Access', + logoUrl: '/google.png', + authType: 'oauth2', + oauthConfigured: true, + isActive: true, + requiredVariables: [], + mappedTasks: [], + supportsMultipleConnections: false, + }, + ] as any, + isLoading: false, + error: undefined, + refresh: vi.fn(), + }); + + vi.mocked(useIntegrationConnections).mockReturnValue({ + connections: [ + { + id: 'conn-1', + providerSlug: 'google-workspace', + status: 'active', + variables: null, + }, + ] as any, + isLoading: false, + error: undefined, + refresh: vi.fn(), + }); + + // Mock useSearchParams to simulate OAuth callback + const { useSearchParams: mockUseSearchParams } = vi.mocked( + await import('next/navigation'), + ); + vi.mocked(mockUseSearchParams).mockReturnValue( + new URLSearchParams('success=true&provider=google-workspace') as any, + ); + + setMockPermissions(ADMIN_PERMISSIONS); + + render(); + + expect(toast.success).toHaveBeenCalledWith( + 'Google Workspace connected successfully!', + ); + expect(toast.info).toHaveBeenCalledWith( + 'Import your Google Workspace users', + expect.objectContaining({ + description: 'Go to People to import and sync your team members.', + action: expect.objectContaining({ label: 'Go to People' }), + }), + ); + }); + + it('does not show import prompt for non-sync providers', async () => { + // Override mocks for this test to simulate OAuth callback for a non-sync provider + const { useIntegrationProviders, useIntegrationConnections } = vi.mocked( + await import('@/hooks/use-integration-platform'), + ); + + vi.mocked(useIntegrationProviders).mockReturnValue({ + providers: [ + { + id: 'github', + name: 'GitHub', + description: 'Code hosting', + category: 'Development', + logoUrl: '/github.png', + authType: 'oauth2', + oauthConfigured: true, + isActive: true, + requiredVariables: [], + mappedTasks: [], + supportsMultipleConnections: false, + }, + ] as any, + isLoading: false, + error: undefined, + refresh: vi.fn(), + }); + + vi.mocked(useIntegrationConnections).mockReturnValue({ + connections: [ + { + id: 'conn-2', + providerSlug: 'github', + status: 'active', + variables: null, + }, + ] as any, + isLoading: false, + error: undefined, + refresh: vi.fn(), + }); + + const { useSearchParams: mockUseSearchParams } = vi.mocked( + await import('next/navigation'), + ); + vi.mocked(mockUseSearchParams).mockReturnValue( + new URLSearchParams('success=true&provider=github') as any, + ); + + setMockPermissions(ADMIN_PERMISSIONS); + + render(); + + expect(toast.success).toHaveBeenCalledWith( + 'GitHub connected successfully!', + ); + expect(toast.info).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx b/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx index 594f8d3426..700704c3ea 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx +++ b/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx @@ -34,7 +34,7 @@ import { } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; -import { useParams, useSearchParams } from 'next/navigation'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { @@ -48,6 +48,14 @@ import { TaskCard, TaskCardSkeleton } from './TaskCard'; const LOGO_TOKEN = 'pk_AZatYxV5QDSfWpRDaBxzRQ'; +// Providers that support employee sync via People > All +const EMPLOYEE_SYNC_PROVIDERS = new Set([ + 'google-workspace', + 'rippling', + 'jumpcloud', + 'ramp', +]); + // Check if a provider needs variable configuration based on manifest's required variables const providerNeedsConfiguration = ( requiredVariables: string[] | undefined, @@ -78,6 +86,7 @@ interface PlatformIntegrationsProps { export function PlatformIntegrations({ className, taskTemplates }: PlatformIntegrationsProps) { const { orgId } = useParams<{ orgId: string }>(); + const router = useRouter(); const searchParams = useSearchParams(); const { providers, isLoading: loadingProviders } = useIntegrationProviders(true); const { @@ -138,6 +147,18 @@ export function PlatformIntegrations({ className, taskTemplates }: PlatformInteg }; const handleConnectDialogSuccess = () => { + // Prompt user to import employees for providers that support sync + if (connectingProviderInfo && EMPLOYEE_SYNC_PROVIDERS.has(connectingProviderInfo.id)) { + toast.info(`Import your ${connectingProviderInfo.name} users`, { + description: + 'Go to People to import and sync your team members.', + duration: 15000, + action: { + label: 'Go to People', + onClick: () => router.push(`/${orgId}/people/all`), + }, + }); + } refreshConnections(); setConnectDialogOpen(false); setConnectingProviderInfo(null); @@ -264,6 +285,19 @@ export function PlatformIntegrations({ className, taskTemplates }: PlatformInteg if (connection && provider) { toast.success(`${provider.name} connected successfully!`); + // Prompt user to import employees for providers that support sync + if (EMPLOYEE_SYNC_PROVIDERS.has(providerSlug)) { + toast.info(`Import your ${provider.name} users`, { + description: + 'Go to People to import and sync your team members.', + duration: 15000, + action: { + label: 'Go to People', + onClick: () => router.push(`/${orgId}/people/all`), + }, + }); + } + // Set state first setSelectedConnection(connection); setSelectedProvider(provider); From 0324329270e8969582ace7f572dab426635aa73f Mon Sep 17 00:00:00 2001 From: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:05:14 -0400 Subject: [PATCH 02/13] fix(policies): preserve entityId when re-generating a policy (#2382) Resolves ENG-90 Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Mariano Fuentes --- apps/api/src/trigger/policies/update-policy-helpers.ts | 5 +++++ .../src/trigger/tasks/onboarding/update-policies-helpers.ts | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/api/src/trigger/policies/update-policy-helpers.ts b/apps/api/src/trigger/policies/update-policy-helpers.ts index dbf7c5595b..0739ad3e3b 100644 --- a/apps/api/src/trigger/policies/update-policy-helpers.ts +++ b/apps/api/src/trigger/policies/update-policy-helpers.ts @@ -251,7 +251,12 @@ export async function updatePolicyInDatabase( } await db.$transaction(async (tx) => { + // Clear version references first to avoid FK constraint issues during deletion if (policy.versions.length > 0) { + await tx.policy.update({ + where: { id: policyId }, + data: { currentVersionId: null, pendingVersionId: null }, + }); await tx.policyVersion.deleteMany({ where: { policyId } }); } diff --git a/apps/app/src/trigger/tasks/onboarding/update-policies-helpers.ts b/apps/app/src/trigger/tasks/onboarding/update-policies-helpers.ts index 31582ab537..899b02c074 100644 --- a/apps/app/src/trigger/tasks/onboarding/update-policies-helpers.ts +++ b/apps/app/src/trigger/tasks/onboarding/update-policies-helpers.ts @@ -561,8 +561,12 @@ export async function updatePolicyInDatabase( // Use transaction to ensure atomicity - if any step fails, all are rolled back await db.$transaction(async (tx) => { - // Delete all existing versions + // Clear version references first to avoid FK constraint issues during deletion if (policy.versions.length > 0) { + await tx.policy.update({ + where: { id: policyId }, + data: { currentVersionId: null, pendingVersionId: null }, + }); await tx.policyVersion.deleteMany({ where: { policyId }, }); From 2fa21e4bf03db4e3881644a507c57f68740a0d53 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:07:24 -0400 Subject: [PATCH 03/13] feat(tasks): add filter for automated vs manual evidence tasks (#2380) * feat(tasks): add filter for automated vs manual evidence tasks Resolves SALE-2 Co-Authored-By: Claude Opus 4.6 (1M context) * fix(tests): resolve duplicate testid collisions in TaskList test Use getAllBy* selectors to handle multiple Select components rendering with overlapping testids in the mocked UI. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Mariano Fuentes --- .../tasks/components/TaskList.test.tsx | 220 ++++++++++++++++++ .../[orgId]/tasks/components/TaskList.tsx | 43 +++- 2 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.test.tsx diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.test.tsx new file mode 100644 index 0000000000..e061d10903 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.test.tsx @@ -0,0 +1,220 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Track automation status filter state for assertions +let automationStatusValue: string | null = null; +const mockSetAutomationStatus = vi.fn((val: string | null) => { + automationStatusValue = val; +}); + +// Mock nuqs +vi.mock('nuqs', () => ({ + useQueryState: (key: string) => { + if (key === 'automationStatus') return [automationStatusValue, mockSetAutomationStatus]; + return [null, vi.fn()]; + }, +})); + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useParams: () => ({ orgId: 'org_123' }), +})); + +// Mock child components +vi.mock('./ModernTaskList', () => ({ + ModernTaskList: ({ tasks }: { tasks: { id: string }[] }) => ( +
+ {tasks.map((t) => ( +
+ ))} +
+ ), +})); + +vi.mock('./TasksByCategory', () => ({ + TasksByCategory: ({ tasks }: { tasks: { id: string }[] }) => ( +
+ {tasks.map((t) => ( +
+ ))} +
+ ), +})); + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + Check: () => , + Circle: () => , + FolderTree: () => , + List: () => , + Search: () => , + XCircle: () => , +})); + +// Mock design-system components +vi.mock('@trycompai/design-system', () => ({ + Avatar: ({ children }: { children: React.ReactNode }) =>
{children}
, + AvatarFallback: ({ children }: { children: React.ReactNode }) => {children}, + AvatarImage: () => , + HStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + InputGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + InputGroupAddon: ({ children }: { children: React.ReactNode }) =>
{children}
, + InputGroupInput: (props: Record) => , + Select: ({ + children, + value, + onValueChange, + }: { + children: React.ReactNode; + value: string; + onValueChange: (v: string) => void; + }) => ( +
+ {children} + +
+ ), + SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectItem: ({ + children, + value, + }: { + children: React.ReactNode; + value: string; + }) => ( + + ), + SelectTrigger: ({ + children, + }: { + children: React.ReactNode; + size?: string; + disabled?: boolean; + }) =>
{children}
, + SelectValue: ({ + children, + }: { + children: React.ReactNode; + placeholder?: string; + }) =>
{children}
, + Separator: () =>
, + Stack: ({ children }: { children: React.ReactNode }) =>
{children}
, + Tabs: ({ + children, + }: { + children: React.ReactNode; + value?: string; + onValueChange?: (v: string) => void; + }) =>
{children}
, + TabsContent: ({ children }: { children: React.ReactNode; value?: string }) => ( +
{children}
+ ), + TabsList: ({ children }: { children: React.ReactNode; variant?: string }) => ( +
{children}
+ ), + TabsTrigger: ({ children }: { children: React.ReactNode; value?: string }) => ( +
{children}
+ ), + Text: ({ children }: { children: React.ReactNode }) => {children}, +})); + +import { TaskList } from './TaskList'; + +const baseMockTask = { + description: 'Test', + status: 'todo' as const, + frequency: null, + department: null, + assigneeId: null, + organizationId: 'org_123', + createdAt: new Date(), + updatedAt: new Date(), + order: 0, + taskTemplateId: null, + reviewDate: null, + approvalStatus: null, + approverId: null, + approvedAt: null, + approvalComment: null, + controls: [] as { id: string; name: string }[], +}; + +const automatedTask = { + ...baseMockTask, + id: 'task_auto_1', + title: 'Automated Task', + automationStatus: 'AUTOMATED' as const, +}; + +const manualTask = { + ...baseMockTask, + id: 'task_manual_1', + title: 'Manual Task', + automationStatus: 'MANUAL' as const, +}; + +const defaultProps = { + tasks: [automatedTask, manualTask], + members: [], + frameworkInstances: [], + activeTab: 'list' as const, + evidenceApprovalEnabled: false, +}; + +describe('TaskList automation status filter', () => { + beforeEach(() => { + vi.clearAllMocks(); + automationStatusValue = null; + }); + + it('renders the automation status filter dropdown', () => { + render(); + expect(screen.getAllByText('All types').length).toBeGreaterThan(0); + }); + + it('shows all tasks when no automation status filter is active', () => { + render(); + expect(screen.getByTestId('task-task_auto_1')).toBeInTheDocument(); + expect(screen.getByTestId('task-task_manual_1')).toBeInTheDocument(); + }); + + it('shows only automated tasks when AUTOMATED filter is active', () => { + automationStatusValue = 'AUTOMATED'; + render(); + expect(screen.getByTestId('task-task_auto_1')).toBeInTheDocument(); + expect(screen.queryByTestId('task-task_manual_1')).not.toBeInTheDocument(); + }); + + it('shows only manual tasks when MANUAL filter is active', () => { + automationStatusValue = 'MANUAL'; + render(); + expect(screen.queryByTestId('task-task_auto_1')).not.toBeInTheDocument(); + expect(screen.getByTestId('task-task_manual_1')).toBeInTheDocument(); + }); + + it('displays result count when automation status filter is active', () => { + automationStatusValue = 'AUTOMATED'; + render(); + expect(screen.getByText('1 result')).toBeInTheDocument(); + }); + + it('renders Automated and Manual options in the dropdown', () => { + render(); + expect(screen.getAllByTestId('select-item-AUTOMATED')).toHaveLength(1); + expect(screen.getAllByTestId('select-item-MANUAL')).toHaveLength(1); + }); + + it('renders All types text in the dropdown', () => { + render(); + expect(screen.getAllByText('All types').length).toBeGreaterThan(0); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.tsx index a19a9c558f..c49e6556ca 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.tsx @@ -79,6 +79,8 @@ export function TaskList({ const [statusFilter, setStatusFilter] = useQueryState('status'); const [assigneeFilter, setAssigneeFilter] = useQueryState('assignee'); const [frameworkFilter, setFrameworkFilter] = useQueryState('framework'); + const [automationStatusFilter, setAutomationStatusFilter] = + useQueryState('automationStatus'); const [currentTab, setCurrentTab] = useState<'categories' | 'list'>(activeTab); // Sync activeTab prop with state when it changes @@ -154,7 +156,16 @@ export function TaskList({ return task.controls.some((c) => fwControlIds.has(c.id)); })(); - return matchesSearch && matchesStatus && matchesAssignee && matchesFramework; + const matchesAutomationStatus = + !automationStatusFilter || task.automationStatus === automationStatusFilter; + + return ( + matchesSearch && + matchesStatus && + matchesAssignee && + matchesFramework && + matchesAutomationStatus + ); }); // Calculate overall stats from all tasks (not filtered) @@ -719,9 +730,37 @@ export function TaskList({ ))} + +
{/* Result Count */} - {(searchQuery || statusFilter || assigneeFilter || frameworkFilter) && ( + {(searchQuery || statusFilter || assigneeFilter || frameworkFilter || automationStatusFilter) && (
{filteredTasks.length} {filteredTasks.length === 1 ? 'result' : 'results'}
From c41e5c5dee571875aca808c2f675a6a0e457eeff Mon Sep 17 00:00:00 2001 From: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:11:52 -0400 Subject: [PATCH 04/13] feat(people): add status filter to team members page (#2379) - Add `deactivated` field to API response (MEMBER_SELECT + PeopleResponseDto) so the client can properly distinguish deactivated vs inactive members - Extract filtering logic from TeamMembersClient into testable pure functions (buildDisplayItems, filterDisplayItems) in filter-members.ts - Replace lucide-react Loader2 with InProgress from @trycompai/design-system/icons - Add deactivated field to client-side PeopleResponseDto - Add API test for includeDeactivated query parameter - Add 17 unit tests covering status/search/role filter combinations Resolves SALE-6 Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Mariano Fuentes --- .../src/people/dto/people-responses.dto.ts | 6 + apps/api/src/people/people.controller.spec.ts | 11 + apps/api/src/people/utils/member-queries.ts | 1 + .../all/components/TeamMembersClient.tsx | 83 +----- .../all/components/filter-members.test.ts | 272 ++++++++++++++++++ .../people/all/components/filter-members.ts | 89 ++++++ apps/app/src/hooks/use-people-api.ts | 1 + 7 files changed, 389 insertions(+), 74 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/people/all/components/filter-members.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/people/all/components/filter-members.ts diff --git a/apps/api/src/people/dto/people-responses.dto.ts b/apps/api/src/people/dto/people-responses.dto.ts index a87ab60e33..1b3d206935 100644 --- a/apps/api/src/people/dto/people-responses.dto.ts +++ b/apps/api/src/people/dto/people-responses.dto.ts @@ -111,6 +111,12 @@ export class PeopleResponseDto { }) isActive: boolean; + @ApiProperty({ + description: 'Whether member is deactivated', + example: false, + }) + deactivated: boolean; + @ApiProperty({ description: 'FleetDM label ID for member devices', example: 123, diff --git a/apps/api/src/people/people.controller.spec.ts b/apps/api/src/people/people.controller.spec.ts index 11f3769910..c7111a8b53 100644 --- a/apps/api/src/people/people.controller.spec.ts +++ b/apps/api/src/people/people.controller.spec.ts @@ -103,6 +103,17 @@ describe('PeopleController', () => { ); }); + it('should pass includeDeactivated=true to the service', async () => { + mockPeopleService.findAllByOrganization.mockResolvedValue([]); + + await controller.getAllPeople('org_123', mockAuthContext, 'true'); + + expect(peopleService.findAllByOrganization).toHaveBeenCalledWith( + 'org_123', + true, + ); + }); + it('should not include authenticatedUser when userId is missing', async () => { const apiKeyContext: AuthContext = { ...mockAuthContext, diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts index 09fc99c605..a6683a3974 100644 --- a/apps/api/src/people/utils/member-queries.ts +++ b/apps/api/src/people/utils/member-queries.ts @@ -19,6 +19,7 @@ export class MemberQueries { department: true, jobTitle: true, isActive: true, + deactivated: true, fleetDmLabelId: true, user: { select: { 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 7432ccb2c4..b6657d5624 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 @@ -1,6 +1,5 @@ 'use client'; -import { Loader2 } from 'lucide-react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; @@ -34,9 +33,10 @@ import { TableRow, Button, } from '@trycompai/design-system'; -import { Search } from '@trycompai/design-system/icons'; +import { InProgress, Search } from '@trycompai/design-system/icons'; import { apiClient } from '@/lib/api-client'; +import { buildDisplayItems, filterDisplayItems } from './filter-members'; import { MemberRow } from './MemberRow'; import { PendingInvitationRow } from './PendingInvitationRow'; import type { MemberWithUser, TeamMembersData } from './TeamMembers'; @@ -56,18 +56,6 @@ interface TeamMembersClientProps { memberIdsWithDeviceAgent: string[]; } -// Define a simplified type for merged list items -interface DisplayItem extends Partial, Partial { - type: 'member' | 'invitation'; - displayName: string; - displayEmail: string; - displayRole: string | string[]; // Simplified role display, could be comma-separated - displayStatus: 'active' | 'pending' | 'deactivated'; - displayId: string; // Use member.id or invitation.id - processedRoles: string[]; - isDeactivated?: boolean; -} - export function TeamMembersClient({ data, organizationId, @@ -129,65 +117,12 @@ export function TeamMembersClient({ } }; - // Combine and type members and invitations for filtering/display - const allItems: DisplayItem[] = [ - ...data.members.map((member) => { - // Process the role to handle comma-separated values - const roles = parseRolesString(member.role); - - const isInactive = member.deactivated || !member.isActive; - - return { - ...member, - type: 'member' as const, - displayName: member.user.name || member.user.email || '', - displayEmail: member.user.email || '', - displayRole: member.role, // Keep original for filtering - displayStatus: isInactive ? ('deactivated' as const) : ('active' as const), - displayId: member.id, - // Add processed roles for rendering - processedRoles: roles, - isDeactivated: isInactive, - }; - }), - ...data.pendingInvitations.map((invitation) => { - // Process the role to handle comma-separated values - const roles = parseRolesString(invitation.role); - - return { - ...invitation, - type: 'invitation' as const, - displayName: invitation.email.split('@')[0], // Or just email - displayEmail: invitation.email, - displayRole: invitation.role, // Keep original for filtering - displayStatus: 'pending' as const, - displayId: invitation.id, - // Add processed roles for rendering - processedRoles: roles, - }; - }), - ]; - - const filteredItems = allItems.filter((item) => { - const matchesSearch = - item.displayName.toLowerCase().includes(searchQuery.toLowerCase()) || - item.displayEmail.toLowerCase().includes(searchQuery.toLowerCase()); - - // Check if the role filter matches any of the member's roles - const matchesRole = !roleFilter || item.processedRoles.includes(roleFilter); - - // Status filter: by default (no filter), hide deactivated members - // 'active' explicitly shows non-deactivated members + pending invitations - // 'deactivated' shows only deactivated members - // 'all' shows everything - const matchesStatus = - (statusFilter === 'all') || - (statusFilter === 'deactivated' && item.displayStatus === 'deactivated') || - (statusFilter === 'pending' && item.displayStatus === 'pending') || - (!statusFilter && item.displayStatus !== 'deactivated') || - (statusFilter === 'active' && item.displayStatus === 'active'); - - return matchesSearch && matchesRole && matchesStatus; + const allItems = buildDisplayItems(data); + const filteredItems = filterDisplayItems({ + items: allItems, + searchQuery, + roleFilter, + statusFilter, }); const activeMembers = filteredItems.filter((item) => item.type === 'member'); @@ -356,7 +291,7 @@ export function TeamMembersClient({ {isSyncing ? ( <> - + Syncing... ) : selectedProvider ? ( diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/filter-members.test.ts b/apps/app/src/app/(app)/[orgId]/people/all/components/filter-members.test.ts new file mode 100644 index 0000000000..c23731abc9 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/filter-members.test.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DisplayItem } from './filter-members'; +import type { MemberWithUser } from './TeamMembers'; +import type { Invitation } from '@db'; + +// Mock @/lib/permissions to avoid resolving @trycompai/auth +vi.mock('@/lib/permissions', () => ({ + parseRolesString: (rolesStr: string | null | undefined): string[] => { + if (!rolesStr) return []; + return rolesStr + .split(',') + .map((r) => r.trim()) + .filter((r) => r.length > 0); + }, +})); + +// Import after mock setup +const { buildDisplayItems, filterDisplayItems } = await import('./filter-members'); + +// Minimal member factory for testing +function makeMember(overrides: Partial & { id: string; role: string }): MemberWithUser { + return { + organizationId: 'org_1', + userId: `usr_${overrides.id}`, + createdAt: new Date(), + department: 'none' as never, + jobTitle: null, + isActive: true, + deactivated: false, + externalUserId: null, + externalUserSource: null, + fleetDmLabelId: null, + user: { + id: `usr_${overrides.id}`, + name: `User ${overrides.id}`, + email: `user-${overrides.id}@test.com`, + emailVerified: true, + image: null, + createdAt: new Date(), + updatedAt: new Date(), + lastLogin: null, + role: 'user', + banned: false, + banReason: null, + banExpires: null, + twoFactorEnabled: false, + }, + ...overrides, + } as MemberWithUser; +} + +function makeInvitation(overrides: Partial & { id: string; email: string; role: string }): Invitation { + return { + organizationId: 'org_1', + inviterId: 'usr_inv', + teamId: null, + status: 'pending', + expiresAt: new Date(), + ...overrides, + } as Invitation; +} + +const activeMember = makeMember({ id: 'mem_1', role: 'employee', isActive: true, deactivated: false }); +const deactivatedMember = makeMember({ id: 'mem_2', role: 'admin', isActive: false, deactivated: true }); +const inactiveMember = makeMember({ id: 'mem_3', role: 'employee', isActive: false, deactivated: false }); +const pendingInvite = makeInvitation({ id: 'inv_1', email: 'pending@test.com', role: 'employee' }); + +describe('buildDisplayItems', () => { + it('should mark active members as active', () => { + const items = buildDisplayItems({ members: [activeMember], pendingInvitations: [] }); + + expect(items).toHaveLength(1); + expect(items[0].displayStatus).toBe('active'); + expect(items[0].type).toBe('member'); + expect(items[0].isDeactivated).toBe(false); + }); + + it('should mark deactivated members as deactivated', () => { + const items = buildDisplayItems({ members: [deactivatedMember], pendingInvitations: [] }); + + expect(items).toHaveLength(1); + expect(items[0].displayStatus).toBe('deactivated'); + expect(items[0].isDeactivated).toBe(true); + }); + + it('should mark inactive (isActive=false) members as deactivated', () => { + const items = buildDisplayItems({ members: [inactiveMember], pendingInvitations: [] }); + + expect(items).toHaveLength(1); + expect(items[0].displayStatus).toBe('deactivated'); + expect(items[0].isDeactivated).toBe(true); + }); + + it('should mark pending invitations as pending', () => { + const items = buildDisplayItems({ members: [], pendingInvitations: [pendingInvite] }); + + expect(items).toHaveLength(1); + expect(items[0].displayStatus).toBe('pending'); + expect(items[0].type).toBe('invitation'); + }); + + it('should parse roles from comma-separated string', () => { + const multiRoleMember = makeMember({ id: 'mem_multi', role: 'admin,employee' }); + const items = buildDisplayItems({ members: [multiRoleMember], pendingInvitations: [] }); + + expect(items[0].processedRoles).toEqual(['admin', 'employee']); + }); +}); + +describe('filterDisplayItems', () => { + let allItems: DisplayItem[]; + + const buildAll = () => + buildDisplayItems({ + members: [activeMember, deactivatedMember, inactiveMember], + pendingInvitations: [pendingInvite], + }); + + beforeEach(() => { + allItems = buildAll(); + }); + + describe('status filter', () => { + it('should hide deactivated members by default (no status filter)', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: '', + roleFilter: '', + statusFilter: '', + }); + + expect(result.some((i) => i.displayStatus === 'deactivated')).toBe(false); + expect(result.some((i) => i.displayStatus === 'active')).toBe(true); + expect(result.some((i) => i.displayStatus === 'pending')).toBe(true); + }); + + it('should show only active members when status is "active"', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: '', + roleFilter: '', + statusFilter: 'active', + }); + + expect(result.every((i) => i.displayStatus === 'active')).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + + it('should show only deactivated members when status is "deactivated"', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: '', + roleFilter: '', + statusFilter: 'deactivated', + }); + + expect(result.every((i) => i.displayStatus === 'deactivated')).toBe(true); + // Both deactivatedMember and inactiveMember should appear + expect(result).toHaveLength(2); + }); + + it('should show only pending invitations when status is "pending"', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: '', + roleFilter: '', + statusFilter: 'pending', + }); + + expect(result.every((i) => i.displayStatus === 'pending')).toBe(true); + expect(result).toHaveLength(1); + }); + + it('should show everything when status is "all"', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: '', + roleFilter: '', + statusFilter: 'all', + }); + + expect(result).toHaveLength(allItems.length); + }); + }); + + describe('search filter', () => { + it('should filter by name', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: 'User mem_1', + roleFilter: '', + statusFilter: 'all', + }); + + expect(result).toHaveLength(1); + expect(result[0].displayId).toBe('mem_1'); + }); + + it('should filter by email', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: 'pending@test.com', + roleFilter: '', + statusFilter: 'all', + }); + + expect(result).toHaveLength(1); + expect(result[0].displayId).toBe('inv_1'); + }); + + it('should be case-insensitive', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: 'USER MEM_1', + roleFilter: '', + statusFilter: 'all', + }); + + expect(result).toHaveLength(1); + }); + }); + + describe('role filter', () => { + it('should filter by role', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: '', + roleFilter: 'admin', + statusFilter: 'all', + }); + + expect(result.every((i) => i.processedRoles.includes('admin'))).toBe(true); + }); + + it('should show all when no role filter set', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: '', + roleFilter: '', + statusFilter: 'all', + }); + + expect(result).toHaveLength(allItems.length); + }); + }); + + describe('combined filters', () => { + it('should apply search and status filters together', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: 'user-mem', + roleFilter: '', + statusFilter: 'deactivated', + }); + + // Only deactivated members whose name or email contains "user-mem" + expect(result.every((i) => i.displayStatus === 'deactivated')).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + + it('should return empty when no items match all filters', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: 'nonexistent', + roleFilter: 'owner', + statusFilter: 'pending', + }); + + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/filter-members.ts b/apps/app/src/app/(app)/[orgId]/people/all/components/filter-members.ts new file mode 100644 index 0000000000..0ed79226b6 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/filter-members.ts @@ -0,0 +1,89 @@ +import { parseRolesString } from '@/lib/permissions'; +import type { Invitation } from '@db'; +import type { MemberWithUser } from './TeamMembers'; + +export interface DisplayItem extends Partial, Partial { + type: 'member' | 'invitation'; + displayName: string; + displayEmail: string; + displayRole: string | string[]; + displayStatus: 'active' | 'pending' | 'deactivated'; + displayId: string; + processedRoles: string[]; + isDeactivated?: boolean; +} + +export function buildDisplayItems({ + members, + pendingInvitations, +}: { + members: MemberWithUser[]; + pendingInvitations: Invitation[]; +}): DisplayItem[] { + return [ + ...members.map((member) => { + const roles = parseRolesString(member.role); + const isInactive = member.deactivated || !member.isActive; + + return { + ...member, + type: 'member' as const, + displayName: member.user.name || member.user.email || '', + displayEmail: member.user.email || '', + displayRole: member.role, + displayStatus: isInactive ? ('deactivated' as const) : ('active' as const), + displayId: member.id, + processedRoles: roles, + isDeactivated: isInactive, + }; + }), + ...pendingInvitations.map((invitation) => { + const roles = parseRolesString(invitation.role); + + return { + ...invitation, + type: 'invitation' as const, + displayName: invitation.email.split('@')[0], + displayEmail: invitation.email, + displayRole: invitation.role, + displayStatus: 'pending' as const, + displayId: invitation.id, + processedRoles: roles, + }; + }), + ]; +} + +export function filterDisplayItems({ + items, + searchQuery, + roleFilter, + statusFilter, +}: { + items: DisplayItem[]; + searchQuery: string; + roleFilter: string; + statusFilter: string; +}): DisplayItem[] { + return items.filter((item) => { + const matchesSearch = + item.displayName.toLowerCase().includes(searchQuery.toLowerCase()) || + item.displayEmail.toLowerCase().includes(searchQuery.toLowerCase()); + + const matchesRole = !roleFilter || item.processedRoles.includes(roleFilter); + + // Status filter: by default (no filter), hide deactivated members + // 'active' explicitly shows only active members + // 'deactivated' shows only deactivated members + // 'pending' shows only pending invitations + // 'all' shows everything + const matchesStatus = + statusFilter === 'all' || + (statusFilter === 'deactivated' && item.displayStatus === 'deactivated') || + (statusFilter === 'pending' && item.displayStatus === 'pending') || + (!statusFilter && item.displayStatus !== 'deactivated') || + (statusFilter === 'active' && item.displayStatus === 'active'); + + return matchesSearch && matchesRole && matchesStatus; + }); +} diff --git a/apps/app/src/hooks/use-people-api.ts b/apps/app/src/hooks/use-people-api.ts index 20980946dc..34b043836b 100644 --- a/apps/app/src/hooks/use-people-api.ts +++ b/apps/app/src/hooks/use-people-api.ts @@ -11,6 +11,7 @@ export interface PeopleResponseDto { createdAt: string; // ISO string from API department: string; isActive: boolean; + deactivated: boolean; fleetDmLabelId: number | null; user: { id: string; From b318ca5be89f319a914629c1e923b5ec1c4429b5 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:20:05 -0400 Subject: [PATCH 05/13] fix(tasks): prevent framework-specific content leaks in split header paragraphs (#2381) * fix(tasks): hide framework-specific info irrelevant to organization Resolves SALE-3 Co-Authored-By: Claude Opus 4.6 (1M context) * fix(tasks): handle split framework header paragraphs * fix(tasks): support composite framework header labels --------- Signed-off-by: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Cursor Agent Co-authored-by: Mariano Fuentes --- .../description-framework-filter.spec.ts | 146 ++++++++++++++ .../src/tasks/description-framework-filter.ts | 180 ++++++++++++++++++ apps/api/src/tasks/tasks.service.ts | 121 ++++++++---- 3 files changed, 405 insertions(+), 42 deletions(-) create mode 100644 apps/api/src/tasks/description-framework-filter.spec.ts create mode 100644 apps/api/src/tasks/description-framework-filter.ts diff --git a/apps/api/src/tasks/description-framework-filter.spec.ts b/apps/api/src/tasks/description-framework-filter.spec.ts new file mode 100644 index 0000000000..2890fde9f2 --- /dev/null +++ b/apps/api/src/tasks/description-framework-filter.spec.ts @@ -0,0 +1,146 @@ +import { filterDescriptionByFrameworks } from './description-framework-filter'; + +describe('filterDescriptionByFrameworks', () => { + it('returns the description unchanged when no active frameworks are provided', () => { + const desc = + 'General task.\n\nFor ISO 27001: Store NDA evidence.\n\nFor PCI: Document checks.'; + expect(filterDescriptionByFrameworks(desc, [])).toBe(desc); + }); + + it('returns empty string for empty description', () => { + expect(filterDescriptionByFrameworks('', ['SOC 2'])).toBe(''); + }); + + it('keeps paragraphs that match an active framework', () => { + const desc = + 'Maintain a list.\n\nFor ISO 27001: Store NDA evidence.\n\nFor PCI: Document checks.'; + const result = filterDescriptionByFrameworks(desc, ['ISO 27001']); + expect(result).toContain('Maintain a list.'); + expect(result).toContain('For ISO 27001: Store NDA evidence.'); + expect(result).not.toContain('For PCI'); + }); + + it('removes paragraphs for inactive frameworks', () => { + const desc = + 'General description.\n\nFor HIPAA: Know which devices hold patient data.\n\nFor GDPR: Document lawful basis.'; + const result = filterDescriptionByFrameworks(desc, ['SOC 2']); + expect(result).toBe('General description.'); + }); + + it('keeps all framework paragraphs when all are active', () => { + const desc = + 'Base task.\n\nFor ISO 27001: ISO requirement.\n\nFor HIPAA: HIPAA requirement.'; + const result = filterDescriptionByFrameworks(desc, [ + 'ISO 27001', + 'HIPAA', + ]); + expect(result).toContain('For ISO 27001'); + expect(result).toContain('For HIPAA'); + expect(result).toContain('Base task.'); + }); + + it('handles alias matching (e.g. "PCI" matches "PCI DSS")', () => { + const desc = + 'Base.\n\nFor PCI: PCI-specific info.\n\nFor ISO 27001: ISO info.'; + const result = filterDescriptionByFrameworks(desc, ['PCI DSS']); + expect(result).toContain('For PCI'); + expect(result).not.toContain('For ISO 27001'); + }); + + it('handles case-insensitive matching', () => { + const desc = 'Base.\n\nFor HIPAA: Some requirement.'; + const result = filterDescriptionByFrameworks(desc, ['hipaa']); + expect(result).toContain('For HIPAA'); + }); + + it('keeps paragraphs without framework prefixes', () => { + const desc = + 'Upload a screenshot.\n\nProvide documentation.\n\nFor GDPR: Document lawful basis.'; + const result = filterDescriptionByFrameworks(desc, ['SOC 2']); + expect(result).toContain('Upload a screenshot.'); + expect(result).toContain('Provide documentation.'); + expect(result).not.toContain('GDPR'); + }); + + it('keeps unknown framework labels as a safe default', () => { + const desc = 'Base.\n\nFor CustomFramework: Custom requirement.'; + const result = filterDescriptionByFrameworks(desc, ['SOC 2']); + expect(result).toContain('For CustomFramework'); + }); + + it('handles the real-world Employee Verification example', () => { + const desc = + 'Maintain a list of reference checks you made for every new hire. Verify the identity of every new hire.\n\nFor ISO 27001: Ensure you are also storing the NDA, candidate evaluation form and access creation request with its approval evidence\n\nFor PCI: For employees with potential access to the CDE, document background verification checks (e.g., reference check, prior employment) before granting access to CHD systems.'; + + // Org only has SOC 2 active + const soc2Only = filterDescriptionByFrameworks(desc, ['SOC 2']); + expect(soc2Only).toBe( + 'Maintain a list of reference checks you made for every new hire. Verify the identity of every new hire.', + ); + + // Org has ISO 27001 active + const iso = filterDescriptionByFrameworks(desc, ['ISO 27001']); + expect(iso).toContain('For ISO 27001'); + expect(iso).not.toContain('For PCI'); + }); + + it('handles the real-world Asset Inventory with HIPAA example', () => { + const desc = + 'Keep and maintain a list of your devices (laptops/servers). If you install the Comp AI agent on your devices, these will be automatically tracked in-app and you can mark this task as not-relevant.\n\nFor HIPAA: Know which devices hold your patient data is going and create a maintain a system to track it\n\nComp AI device agent is located at: portal.trycomp.ai'; + + const soc2Only = filterDescriptionByFrameworks(desc, ['SOC 2']); + expect(soc2Only).toContain('Keep and maintain a list'); + expect(soc2Only).not.toContain('HIPAA'); + expect(soc2Only).toContain('Comp AI device agent'); + }); + + it('handles SOC 2 v.1 seed name variant', () => { + const desc = 'Base.\n\nFor HIPAA: HIPAA info.'; + const result = filterDescriptionByFrameworks(desc, ['SOC 2 v.1']); + expect(result).not.toContain('HIPAA'); + }); + + it('removes a framework section when header and content are split across paragraphs', () => { + const desc = + 'General guidance.\n\nFor GDPR:\n\nMaintain a documented data breach response plan.'; + const result = filterDescriptionByFrameworks(desc, ['SOC 2']); + + expect(result).toBe('General guidance.'); + }); + + it('removes leaked framework-specific content for Public Policies seed format', () => { + const desc = + 'Add a comment with links to your privacy policy.\n\nFor GDPR:\n\nMaintain clear, transparent, and GDPR-compliant privacy notices.\n\nFor ISO 42001:\n\nEnsure policies identify stakeholder rights and obligations.'; + const result = filterDescriptionByFrameworks(desc, ['SOC 2']); + + expect(result).toBe('Add a comment with links to your privacy policy.'); + }); + + it('removes leaked framework-specific content for Incident Response seed format', () => { + const desc = + 'Keep a record of all security incidents and how they were resolved.\n\nFor GDPR:\n\nMaintain a documented data breach response plan.\n\nFor PCI:\n\nMaintain and annually test the incident response plan for cardholder data incidents.'; + const result = filterDescriptionByFrameworks(desc, ['SOC 2']); + + expect(result).toBe( + 'Keep a record of all security incidents and how they were resolved.', + ); + }); + + it('removes leaked framework-specific content for Board Meetings & Independence seed format', () => { + const desc = + 'Submit board meeting evidence covering security topics.\n\nFor ISO 42001:\n\nEnsure board reviews discuss internal and external issues relevant to the AI MS.'; + const result = filterDescriptionByFrameworks(desc, ['SOC 2']); + + expect(result).toBe( + 'Submit board meeting evidence covering security topics.', + ); + }); + + it('removes leaked framework-specific content for Diagramming seed format', () => { + const desc = + 'Architecture Diagram: Draw a single-page diagram.\n\nFor ISO 27001 and HIPAA:\n\nData Flow Diagram: Show exactly how user and sensitive data travels.\n\nFor ISO 42001:\n\nDocument how internal and external issues are reflected in diagrams.\n\nFor GDPR:\n\nMaintain an up-to-date data inventory and data flow map.\n\nFor PCI:\n\nMaintain current CDE network diagrams.'; + const result = filterDescriptionByFrameworks(desc, ['SOC 2']); + + expect(result).toBe('Architecture Diagram: Draw a single-page diagram.'); + }); +}); diff --git a/apps/api/src/tasks/description-framework-filter.ts b/apps/api/src/tasks/description-framework-filter.ts new file mode 100644 index 0000000000..670275c459 --- /dev/null +++ b/apps/api/src/tasks/description-framework-filter.ts @@ -0,0 +1,180 @@ +/** + * Well-known framework name patterns used in task descriptions. + * Each entry maps a canonical label (used in "For
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 6149af8ecd..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 @@ -1,7 +1,8 @@ import { filterComplianceMembers } from '@/lib/compliance'; import { trainingVideos as trainingVideosData } from '@/lib/data/training-videos'; +import { HIPAA_TRAINING_ID } from '@/lib/data/hipaa-training-content'; import { auth } from '@/utils/auth'; -import type { Member, Organization, Policy, User } from '@db'; +import type { EmployeeTrainingVideoCompletion, Member, Organization, Policy, User } from '@db'; import { db } from '@db/server'; import { headers } from 'next/headers'; import { EmployeeCompletionChart } from './EmployeeCompletionChart'; @@ -37,11 +38,19 @@ export async function EmployeesOverview() { let policies: Policy[] = []; const processedTrainingVideos: ProcessedTrainingVideo[] = []; let organization: Organization | null = null; + let hasHipaaFramework = false; + let hipaaCompletions: EmployeeTrainingVideoCompletion[] = []; if (organizationId) { - organization = await db.organization.findUnique({ - where: { id: organizationId }, - }); + const [org, hipaaInstance] = await Promise.all([ + db.organization.findUnique({ where: { id: organizationId } }), + db.frameworkInstance.findFirst({ + where: { organizationId, framework: { name: 'HIPAA' } }, + select: { id: true }, + }), + ]); + organization = org; + hasHipaaFramework = !!hipaaInstance; // Fetch employees const fetchedMembers = await db.member.findMany({ @@ -67,7 +76,6 @@ export async function EmployeesOverview() { }, }); - // Fetch and process training videos if employees exist and training step is enabled if (employees.length > 0 && organization?.securityTrainingStepEnabled !== false) { const employeeTrainingVideos = await db.employeeTrainingVideoCompletion.findMany({ where: { @@ -83,7 +91,6 @@ export async function EmployeesOverview() { ); if (videoMetadata) { - // Push the object matching the updated ProcessedTrainingVideo interface processedTrainingVideos.push({ id: dbVideo.id, memberId: dbVideo.memberId, @@ -94,6 +101,15 @@ export async function EmployeesOverview() { } } } + + if (employees.length > 0 && hasHipaaFramework) { + hipaaCompletions = await db.employeeTrainingVideoCompletion.findMany({ + where: { + memberId: { in: employees.map((e) => e.id) }, + videoId: HIPAA_TRAINING_ID, + }, + }); + } } return ( @@ -104,6 +120,8 @@ export async function EmployeesOverview() { trainingVideos={processedTrainingVideos as any} showAll={true} securityTrainingStepEnabled={organization?.securityTrainingStepEnabled ?? true} + hasHipaaFramework={hasHipaaFramework} + hipaaCompletions={hipaaCompletions} /> ); diff --git a/apps/app/src/lib/data/hipaa-training-content.ts b/apps/app/src/lib/data/hipaa-training-content.ts new file mode 100644 index 0000000000..e837a5b188 --- /dev/null +++ b/apps/app/src/lib/data/hipaa-training-content.ts @@ -0,0 +1 @@ +export const HIPAA_TRAINING_ID = 'hipaa-sat-1'; diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/EmployeeTasksList.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/EmployeeTasksList.tsx index 5734c9df2f..71bf4734a2 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/EmployeeTasksList.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/EmployeeTasksList.tsx @@ -2,18 +2,22 @@ import { trainingVideos } from '@/lib/data/training-videos'; import { useTrainingCompletions } from '@/hooks/use-training-completions'; -import { evidenceFormDefinitionList } from '@trycompai/company'; import type { Device, EmployeeTrainingVideoCompletion, Member, Policy, PolicyVersion } from '@db'; -import { Accordion, Button, Card, CardContent } from '@trycompai/design-system'; -import { CheckmarkFilled } from '@trycompai/design-system/icons'; +import { Accordion, Button } from '@trycompai/design-system'; import Link from 'next/link'; import useSWR from 'swr'; import type { FleetPolicy, Host } from '../types'; +import { HIPAA_TRAINING_ID } from '@/lib/data/hipaa-training-content'; import { DeviceAgentAccordionItem } from './tasks/DeviceAgentAccordionItem'; import { GeneralTrainingAccordionItem } from './tasks/GeneralTrainingAccordionItem'; +import { HipaaTrainingAccordionItem } from './tasks/HipaaTrainingAccordionItem'; import { PoliciesAccordionItem } from './tasks/PoliciesAccordionItem'; -const portalForms = evidenceFormDefinitionList.filter((f) => f.portalAccessible); +interface PortalForm { + type: string; + title: string; + description: string; +} type PolicyWithVersion = Policy & { currentVersion?: Pick | null; @@ -26,11 +30,13 @@ interface EmployeeTasksListProps { member: Member; fleetPolicies: FleetPolicy[]; host: Host | null; - agentDevice: Device | null; + agentDevices: Device[]; deviceAgentStepEnabled: boolean; securityTrainingStepEnabled: boolean; + hasHipaaFramework: boolean; whistleblowerReportEnabled: boolean; accessRequestFormEnabled: boolean; + portalForms: PortalForm[]; } export const EmployeeTasksList = ({ @@ -40,11 +46,13 @@ export const EmployeeTasksList = ({ member, fleetPolicies, host, - agentDevice, + agentDevices, deviceAgentStepEnabled, securityTrainingStepEnabled, + hasHipaaFramework, whistleblowerReportEnabled, accessRequestFormEnabled, + portalForms, }: EmployeeTasksListProps) => { const { completions: trainingCompletions } = useTrainingCompletions({ fallbackData: trainingVideoCompletions, @@ -80,7 +88,7 @@ export const EmployeeTasksList = ({ return res.json(); }, { - fallbackData: agentDevice ? { devices: [agentDevice] } : { devices: [] }, + fallbackData: { devices: agentDevices }, refreshInterval: 30_000, revalidateOnFocus: true, revalidateOnMount: true, @@ -91,25 +99,24 @@ export const EmployeeTasksList = ({ return null; } - // Pick the most recently checked-in device (matching page.tsx ordering: lastCheckIn desc, nulls last) - const currentAgentDevice = - agentDeviceResponse?.devices - ?.sort((a, b) => { - if (!a.lastCheckIn && !b.lastCheckIn) return 0; - if (!a.lastCheckIn) return 1; - if (!b.lastCheckIn) return -1; - return new Date(b.lastCheckIn).getTime() - new Date(a.lastCheckIn).getTime(); - })[0] ?? null; + const sortedAgentDevices = [...(agentDeviceResponse?.devices ?? [])].sort( + (a, b) => { + if (!a.lastCheckIn && !b.lastCheckIn) return 0; + if (!a.lastCheckIn) return 1; + if (!b.lastCheckIn) return -1; + return new Date(b.lastCheckIn).getTime() - new Date(a.lastCheckIn).getTime(); + }, + ); // Check completion status const hasAcceptedPolicies = policies.length === 0 || policies.every((p) => p.signedBy.includes(member.id)); // Device agent takes priority over Fleet for completion - const hasAgentDevice = currentAgentDevice !== null; + const hasAnyAgentDevice = sortedAgentDevices.length > 0; const hasFleetDevice = response.device !== null; - const hasCompletedDeviceSetup = hasAgentDevice - ? currentAgentDevice.isCompliant + const hasCompletedDeviceSetup = hasAnyAgentDevice + ? sortedAgentDevices.some((d) => d.isCompliant) : hasFleetDevice && (response.fleetPolicies.length === 0 || response.fleetPolicies.every((policy) => policy.response === 'pass')); @@ -128,10 +135,15 @@ export const EmployeeTasksList = ({ const hasCompletedGeneralTraining = completedGeneralTrainingCount === generalTrainingVideoIds.length; + const hasCompletedHipaaTraining = trainingCompletions.some( + (c) => c.videoId === HIPAA_TRAINING_ID && c.completedAt !== null, + ); + const completedCount = [ hasAcceptedPolicies, ...(deviceAgentStepEnabled ? [hasCompletedDeviceSetup] : []), ...(securityTrainingStepEnabled ? [hasCompletedGeneralTraining] : []), + ...(hasHipaaFramework ? [hasCompletedHipaaTraining] : []), ].filter(Boolean).length; const accordionItems = [ @@ -147,7 +159,7 @@ export const EmployeeTasksList = ({ , + }, + ] + : []), ]; const visiblePortalForms = portalForms.filter((form) => { if (form.type === 'whistleblower-report') return whistleblowerReportEnabled; @@ -173,48 +193,27 @@ export const EmployeeTasksList = ({ return true; }); - const allCompleted = completedCount === accordionItems.length; - return (
- {allCompleted ? ( - - -
-
- -
-

You're all set!

-

- You've completed all required tasks. No further action is needed at this time. -

-
-
-
- ) : ( - <> - {/* Progress indicator */} -
-
- {completedCount} of {accordionItems.length} tasks completed -
-
-
-
-
+
+
+ {completedCount} of {accordionItems.length} tasks completed +
+
+
+
+
-
- - {accordionItems.map((item, idx) => ( -
{item.content}
- ))} -
-
- - )} +
+ + {accordionItems.map((item, idx) => ( +
{item.content}
+ ))} +
+
{/* Company forms */} {visiblePortalForms.length > 0 && ( diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/OrganizationDashboard.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/OrganizationDashboard.tsx index b012692d86..a2a80a78e9 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/OrganizationDashboard.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/OrganizationDashboard.tsx @@ -1,9 +1,14 @@ import type { Device, Member, Organization, User } from '@db'; import { db } from '@db/server'; +import { evidenceFormDefinitionList } from '@trycompai/company'; import { NoAccessMessage } from '../../components/NoAccessMessage'; import type { FleetPolicy, Host } from '../types'; import { EmployeeTasksList } from './EmployeeTasksList'; +const portalForms = evidenceFormDefinitionList + .filter((f) => f.portalAccessible) + .map(({ type, title, description }) => ({ type, title, description })); + // Define the type for the member prop passed from Overview interface MemberWithUserOrg extends Member { user: User; @@ -15,7 +20,7 @@ interface OrganizationDashboardProps { member: MemberWithUserOrg; fleetPolicies: FleetPolicy[]; host: Host | null; - agentDevice: Device | null; + agentDevices: Device[]; } export async function OrganizationDashboard({ @@ -23,7 +28,7 @@ export async function OrganizationDashboard({ member, fleetPolicies, host, - agentDevice, + agentDevices, }: OrganizationDashboardProps) { // Fetch policies specific to the selected organization const policies = await db.policy.findMany({ @@ -51,12 +56,18 @@ export async function OrganizationDashboard({ }, }); - // Get Org first to verify it exists - const org = await db.organization.findUnique({ - where: { - id: organizationId, - }, - }); + const [org, hipaaFramework] = await Promise.all([ + db.organization.findUnique({ + where: { id: organizationId }, + }), + db.frameworkInstance.findFirst({ + where: { + organizationId, + framework: { name: 'HIPAA' }, + }, + select: { id: true }, + }), + ]); if (!org) { return ; @@ -70,11 +81,13 @@ export async function OrganizationDashboard({ member={member} fleetPolicies={fleetPolicies} host={host} - agentDevice={agentDevice} + agentDevices={agentDevices} deviceAgentStepEnabled={org.deviceAgentStepEnabled} securityTrainingStepEnabled={org.securityTrainingStepEnabled} whistleblowerReportEnabled={org.whistleblowerReportEnabled} accessRequestFormEnabled={org.accessRequestFormEnabled} + hasHipaaFramework={!!hipaaFramework} + portalForms={portalForms} /> ); } diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx index 6a89fecfe3..a7a7bf91ac 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx @@ -23,7 +23,6 @@ import { SelectContent, SelectItem, SelectTrigger, - SelectValue, Spinner, } from '@trycompai/design-system'; import { CheckmarkFilled, CircleDash, Download, Renew } from '@trycompai/design-system/icons'; @@ -35,7 +34,7 @@ import { FleetPolicyItem } from './FleetPolicyItem'; interface DeviceAgentAccordionItemProps { member: Member; host: Host | null; - agentDevice: Device | null; + agentDevices: Device[]; isLoading: boolean; fleetPolicies?: FleetPolicy[]; fetchFleetPolicies: () => void; @@ -44,7 +43,7 @@ interface DeviceAgentAccordionItemProps { export function DeviceAgentAccordionItem({ member, host, - agentDevice, + agentDevices, isLoading, fleetPolicies = [], fetchFleetPolicies, @@ -58,16 +57,14 @@ export function DeviceAgentAccordionItem({ ); const hasFleetDevice = host !== null; - const hasAgentDevice = agentDevice !== null; - const hasInstalledAgent = hasFleetDevice || hasAgentDevice; + const hasAnyAgentDevice = agentDevices.length > 0; const failedPoliciesCount = useMemo( () => fleetPolicies.filter((policy) => policy.response !== 'pass').length, [fleetPolicies], ); - // Device agent takes priority over Fleet - const isCompleted = hasAgentDevice - ? agentDevice.isCompliant + const isCompleted = hasAnyAgentDevice + ? agentDevices.some((d) => d.isCompliant) : hasFleetDevice ? failedPoliciesCount === 0 : false; @@ -176,7 +173,7 @@ export function DeviceAgentAccordionItem({ Device Agent - {!hasAgentDevice && hasFleetDevice && failedPoliciesCount > 0 && ( + {!hasAnyAgentDevice && hasFleetDevice && failedPoliciesCount > 0 && ( {failedPoliciesCount} policies failing @@ -191,86 +188,46 @@ export function DeviceAgentAccordionItem({ device protected against security threats.

- {!hasInstalledAgent ? ( -
-
    -
  1. - Download the Device Agent installer. -

    - Click the download button below to get the Device Agent installer. -

    -
    - {isMacOS && !hasInstalledAgent && ( -
    - + {agentDevices.length > 0 && ( +
    +

    + Your Devices +

    + {agentDevices.map((device) => ( + + + + {device.name} + + + +
    +
    + {device.isCompliant ? ( +
    + ) : ( +
    + )} + + {device.isCompliant + ? 'All security checks passing' + : 'Some security checks need attention'} +
    - )} - -
    -
  2. -
  3. - Install the Comp AI Device Agent -

    - {isMacOS - ? 'Double-click the downloaded DMG file and follow the installation instructions.' - : detectedOS === 'linux' - ? 'Install the downloaded DEB package using your package manager or by double-clicking it.' - : 'Double-click the downloaded EXE file and follow the installation instructions.'} -

    -
  4. -
  5. - Login with your work email -

    - After installation, login with your work email, select your organization and - then click "Link Device". -

    -
  6. -
+

+ {device.platform} · {device.osVersion} + {device.lastCheckIn && ( + <> · Last check-in: {new Date(device.lastCheckIn).toLocaleDateString()} + )} +

+
+ + + ))}
- ) : hasAgentDevice ? ( - - - - {agentDevice.name} - - - -
-
- {agentDevice.isCompliant ? ( -
- ) : ( -
- )} - - {agentDevice.isCompliant - ? 'All security checks passing' - : 'Some security checks need attention'} - -
-

- {agentDevice.platform} · {agentDevice.osVersion} - {agentDevice.lastCheckIn && ( - <> · Last check-in: {new Date(agentDevice.lastCheckIn).toLocaleDateString()} - )} -

-
-
-
- ) : hasFleetDevice ? ( + )} + + {!hasAnyAgentDevice && hasFleetDevice && (
@@ -308,7 +265,59 @@ export function DeviceAgentAccordionItem({
- ) : null} + )} + +
+

+ {hasAnyAgentDevice ? 'Add Another Device' : 'Install on a Device'} +

+
    +
  1. + Download the Device Agent installer. +

    + Click the download button below to get the Device Agent installer. +

    +
    + {isMacOS && ( +
    + +
    + )} + +
    +
  2. +
  3. + Install the Comp AI Device Agent +

    + {isMacOS + ? 'Double-click the downloaded DMG file and follow the installation instructions.' + : detectedOS === 'linux' + ? 'Install the downloaded DEB package using your package manager or by double-clicking it.' + : 'Double-click the downloaded EXE file and follow the installation instructions.'} +

    +
  4. +
  5. + Login with your work email +

    + After installation, login with your work email, select your organization and + then click "Link Device". +

    +
  6. +
+
diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/HipaaTrainingAccordionItem.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/HipaaTrainingAccordionItem.tsx new file mode 100644 index 0000000000..bf175b64f6 --- /dev/null +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/HipaaTrainingAccordionItem.tsx @@ -0,0 +1,285 @@ +'use client'; + +import { + HIPAA_TRAINING_ID, + hipaaAcknowledgements, + hipaaTrainingSections, +} from '@/lib/data/hipaa-training-content'; +import { useTrainingCompletions } from '@/hooks/use-training-completions'; +import { + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + cn, +} from '@trycompai/design-system'; +import { CheckmarkFilled, CircleDash } from '@trycompai/design-system/icons'; +import { useState } from 'react'; + +export function HipaaTrainingAccordionItem() { + const { completions, markVideoComplete } = useTrainingCompletions(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [allChecked, setAllChecked] = useState(false); + const [checkedItems, setCheckedItems] = useState>({}); + + const hipaaCompletion = completions.find( + (c) => c.videoId === HIPAA_TRAINING_ID && c.completedAt !== null, + ); + const isCompleted = !!hipaaCompletion; + + const handleCheckChange = (index: number, checked: boolean) => { + const updated = { ...checkedItems, [index]: checked }; + setCheckedItems(updated); + setAllChecked( + hipaaAcknowledgements.every((_, i) => updated[i] === true), + ); + }; + + const handleAcknowledge = async () => { + if (!allChecked || isSubmitting) return; + setIsSubmitting(true); + try { + await markVideoComplete(HIPAA_TRAINING_ID); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ +
+ +
+ {isCompleted ? ( +
+ +
+ ) : ( +
+ +
+ )} + + HIPAA Security Awareness Training + +
+
+
+ +
+

+ Read the following HIPAA security awareness training and + acknowledge each statement at the bottom to complete this + requirement. +

+ + {isCompleted ? ( + + ) : ( + <> + + + + )} +
+
+
+
+ ); +} + +function CompletedBanner({ completedAt }: { completedAt: Date | null }) { + return ( +
+
+ +
+
+

+ HIPAA training acknowledged +

+

+ {completedAt + ? `Completed on ${new Date(completedAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })}. A certificate was emailed to you.` + : 'You have completed the HIPAA Security Awareness Training.'} +

+
+
+ ); +} + +function TrainingContent() { + return ( +
+ {hipaaTrainingSections.map((section) => ( +
+

{section.title}

+ +
+ ))} +
+ ); +} + +function TrainingSectionContent({ content }: { content: string }) { + const lines = content.split('\n'); + const elements: React.ReactNode[] = []; + let tableRows: string[][] = []; + let inTable = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (trimmed.startsWith('|') && trimmed.endsWith('|')) { + if (trimmed.replace(/[|\-\s]/g, '') === '') continue; + inTable = true; + const cells = trimmed + .split('|') + .slice(1, -1) + .map((c) => c.trim()); + tableRows.push(cells); + continue; + } + + if (inTable) { + elements.push( + , + ); + tableRows = []; + inTable = false; + } + + if (trimmed.startsWith('**') && trimmed.endsWith('**')) { + elements.push( +

+ {trimmed.slice(2, -2)} +

, + ); + } else if (trimmed.startsWith('- ')) { + elements.push( +
  • + {trimmed.slice(2)} +
  • , + ); + } else if (trimmed) { + elements.push( +

    + {trimmed} +

    , + ); + } + } + + if (inTable && tableRows.length > 0) { + elements.push( + , + ); + } + + return <>{elements}; +} + +function TrainingTable({ rows }: { rows: string[][] }) { + if (rows.length === 0) return null; + const [header, ...body] = rows; + + return ( +
    + + + + {header.map((cell, i) => ( + + ))} + + + + {body.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
    + {cell} +
    + {cell} +
    +
    + ); +} + +function AcknowledgementSection({ + checkedItems, + onCheckChange, + allChecked, + isSubmitting, + onAcknowledge, +}: { + checkedItems: Record; + onCheckChange: (index: number, checked: boolean) => void; + allChecked: boolean; + isSubmitting: boolean; + onAcknowledge: () => void; +}) { + return ( +
    +

    Acknowledgement

    +

    + By acknowledging this training, you confirm the following: +

    +
    + {hipaaAcknowledgements.map((statement, index) => ( + + ))} +
    +
    + +
    +
    + ); +} diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx index 4e71a2eb6e..967d7cdd41 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx @@ -57,8 +57,8 @@ export default async function OrganizationPage({ params }: { params: Promise<{ o // Fleet policies - only fetch if member has a fleet device label const fleetData = await getFleetPolicies(member); - // Device agent device - fetch from DB - const agentDevice = await db.device.findFirst({ + // Device agent devices - fetch all for this member + const agentDevices = await db.device.findMany({ where: { memberId: member.id, organizationId: orgId, @@ -75,7 +75,7 @@ export default async function OrganizationPage({ params }: { params: Promise<{ o member={member} fleetPolicies={fleetData.fleetPolicies} host={fleetData.device} - agentDevice={agentDevice} + agentDevices={agentDevices} /> ); diff --git a/apps/portal/src/lib/data/hipaa-training-content.ts b/apps/portal/src/lib/data/hipaa-training-content.ts new file mode 100644 index 0000000000..982c98dc54 --- /dev/null +++ b/apps/portal/src/lib/data/hipaa-training-content.ts @@ -0,0 +1,67 @@ +export const HIPAA_TRAINING_ID = 'hipaa-sat-1'; + +export interface HipaaTrainingSection { + title: string; + content: string; +} + +export const hipaaTrainingSections: readonly HipaaTrainingSection[] = [ + { + title: '1. Why this training exists', + content: `This document supplements the organization's existing general security awareness training with HIPAA-specific expectations for protecting Protected Health Information (PHI), including electronic PHI (ePHI), paper records, and verbal disclosures. + +It is intended to support onboarding and annual refresher requirements and to provide clear evidence that personnel were informed of their HIPAA security and privacy responsibilities.`, + }, + { + title: '2. What employees must understand', + content: `- PHI may exist in systems, email, chat, files, paper documents, screenshots, voicemail, and verbal conversations. +- Access to PHI is permitted only for authorized job duties and only to the minimum extent necessary to perform those duties. +- PHI must not be sent, stored, printed, discussed, or shared using unapproved methods or with unauthorized people. +- Security incidents, suspected misdirected disclosures, phishing attempts, lost devices, and any possible PHI exposure must be reported immediately.`, + }, + { + title: '3. Required day-to-day behaviors', + content: `**Protect PHI in all forms** +- Do not leave records, labels, printouts, or screens containing PHI unattended. +- Verify recipient names, addresses, and fax numbers before sending information. +- Use only approved storage locations, applications, and workflows for PHI. + +**Email, messaging, and file sharing** +- Use approved encrypted or otherwise authorized methods when transmitting PHI. +- Do not auto-forward work email containing PHI to personal accounts. +- Do not copy PHI into public AI tools or non-approved cloud services. + +**Passwords, access, and devices** +- Use unique passwords, enable multi-factor authentication where required, and never share credentials. +- Lock your screen when you step away and secure laptops and mobile devices physically. +- Report lost or stolen devices immediately, even if you are unsure whether PHI was involved. + +**Phishing and social engineering** +- Treat urgent requests, payment changes, unusual login prompts, and requests for credentials or patient information as suspicious until verified. +- Use approved reporting methods to report suspicious emails, texts, calls, or pop-ups.`, + }, + { + title: '4. Quick reference: do / do not', + content: `| Do | Do not | +|---|---| +| Access only the PHI needed for your role. | Browse records out of curiosity or convenience. | +| Confirm identity before sharing patient or employee information. | Discuss PHI in public areas, elevators, hallways, or on speakerphone where others can hear. | +| Use approved encrypted channels and approved repositories. | Store PHI in personal email, personal drives, USB devices, or unapproved apps. | +| Check recipients carefully before sending messages or attachments. | Rely on autofill without verifying the recipient. | +| Report incidents, phishing, lost devices, or mistakes immediately. | Delay reporting because you hope the issue will resolve on its own. |`, + }, + { + title: '5. Incident reporting expectation', + content: `Report immediately if you suspect a phishing email, accidental disclosure, misdirected message, unauthorized access, malware infection, stolen or missing device, or any situation that could affect the confidentiality, integrity, or availability of PHI. + +Prompt reporting matters even when you are unsure whether PHI was actually exposed. Early notice helps the organization contain risk and meet regulatory response obligations.`, + }, +] as const; + +export const hipaaAcknowledgements: readonly string[] = [ + 'I completed the organization\'s general security awareness training and this HIPAA Security Awareness Training Add-On.', + 'I understand that PHI includes information in electronic, paper, image, audio, and verbal form.', + 'I will access, use, disclose, transmit, and store PHI only as authorized for my job responsibilities and in accordance with organization policy.', + 'I will use approved safeguards, including strong authentication, secure handling practices, and prompt reporting of suspicious activity or incidents.', + 'I understand that failure to follow security and privacy requirements may lead to disciplinary action, up to and including termination, and may create legal or regulatory consequences.', +] as const; diff --git a/bun.lock b/bun.lock index f409289013..aeb08cf7c4 100644 --- a/bun.lock +++ b/bun.lock @@ -6779,8 +6779,6 @@ "@prisma/streams-local/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], - "@prisma/streams-local/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], - "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-checkbox/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index ac49c7243a..085d6f6fb3 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -18543,6 +18543,40 @@ ] } }, + "/v1/training/generate-hipaa-certificate": { + "post": { + "description": "Generates a PDF certificate for a member who has completed the HIPAA Security Awareness Training.", + "operationId": "TrainingController_generateHipaaCertificate_v1", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendTrainingCompletionDto" + } + } + } + }, + "responses": { + "200": { + "description": "PDF certificate file" + }, + "400": { + "description": "HIPAA training not complete or member not found" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Generate HIPAA training certificate PDF", + "tags": [ + "Training" + ] + } + }, "/v1/org-chart": { "get": { "operationId": "OrgChartController_getOrgChart_v1", From d7cd70bd486ccce818ce3ad2b3a877dad2be4495 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:39:29 -0400 Subject: [PATCH 08/13] chore(deps): bump @tiptap/extension-list from 3.16.0 to 3.18.0 (#2073) Bumps [@tiptap/extension-list](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-list) from 3.16.0 to 3.18.0. - [Release notes](https://github.com/ueberdosis/tiptap/releases) - [Changelog](https://github.com/ueberdosis/tiptap/blob/develop/packages/extension-list/CHANGELOG.md) - [Commits](https://github.com/ueberdosis/tiptap/commits/v3.18.0/packages/extension-list) --- updated-dependencies: - dependency-name: "@tiptap/extension-list" dependency-version: 3.18.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index 412955e865..596c12b91c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -43,7 +43,7 @@ "@tiptap/extension-highlight": "3.16.0", "@tiptap/extension-image": "3.16.0", "@tiptap/extension-link": "3.16.0", - "@tiptap/extension-list": "3.16.0", + "@tiptap/extension-list": "3.18.0", "@tiptap/extension-table": "3.16.0", "@tiptap/extension-text-align": "3.16.0", "@tiptap/extension-typography": "3.16.0", From bce51654d8cc1ea9f5a1bf8bbe9102dc34470e33 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:53:18 -0400 Subject: [PATCH 09/13] chore(deps-dev): bump globals from 16.5.0 to 17.2.0 (#2072) * chore(deps-dev): bump globals from 16.5.0 to 17.2.0 Bumps [globals](https://github.com/sindresorhus/globals) from 16.5.0 to 17.2.0. - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v16.5.0...v17.2.0) --- updated-dependencies: - dependency-name: globals dependency-version: 17.2.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * chore: update bun.lock after globals bump Co-Authored-By: Claude Opus 4.6 (1M context) --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mariano Fuentes Co-authored-by: Claude Opus 4.6 (1M context) --- apps/api/package.json | 2 +- bun.lock | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 23f23bfa68..39307a69a2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -83,7 +83,7 @@ "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", - "globals": "^16.0.0", + "globals": "^17.3.0", "jest": "^30.0.0", "prettier": "^3.5.3", "source-map-support": "^0.5.21", diff --git a/bun.lock b/bun.lock index aeb08cf7c4..6ad098a13a 100644 --- a/bun.lock +++ b/bun.lock @@ -148,7 +148,7 @@ "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", - "globals": "^16.0.0", + "globals": "^17.3.0", "jest": "^30.0.0", "prettier": "^3.5.3", "source-map-support": "^0.5.21", @@ -657,7 +657,7 @@ "@tiptap/extension-highlight": "3.16.0", "@tiptap/extension-image": "3.16.0", "@tiptap/extension-link": "3.16.0", - "@tiptap/extension-list": "3.16.0", + "@tiptap/extension-list": "3.18.0", "@tiptap/extension-table": "3.16.0", "@tiptap/extension-text-align": "3.16.0", "@tiptap/extension-typography": "3.16.0", @@ -2377,7 +2377,7 @@ "@tiptap/extension-link": ["@tiptap/extension-link@3.16.0", "", { "dependencies": { "linkifyjs": "^4.3.2" }, "peerDependencies": { "@tiptap/core": "^3.16.0", "@tiptap/pm": "^3.16.0" } }, "sha512-WPPJLtGXQadBVVwH6gcMpaXIgfvFF9NGpE2IVqleVKR3Epv2Rd4aWd4oyAdrT8KU9G6dzMXZfkrB8aArTDKxYQ=="], - "@tiptap/extension-list": ["@tiptap/extension-list@3.16.0", "", { "peerDependencies": { "@tiptap/core": "^3.16.0", "@tiptap/pm": "^3.16.0" } }, "sha512-tpjWGugfI0XYR9iG/QlYYtCY35TFWHNwGKc94wN4s7NmAjB4xlwdTkTZQ6PdZ39x1SeHkRjxAka+6GcBIoOHGQ=="], + "@tiptap/extension-list": ["@tiptap/extension-list@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0", "@tiptap/pm": "^3.18.0" } }, "sha512-9lQBo45HNqIFcLEHAk+CY3W51eMMxIJjWbthm2CwEWr4PB3+922YELlvq8JcLH1nVFkBVpmBFmQe/GxgnCkzwQ=="], "@tiptap/extension-list-item": ["@tiptap/extension-list-item@3.16.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.16.0" } }, "sha512-kshssUZEPoosPWbJNQEFJnVV3iPwsDU9l/RCdHJB5SE+aNWJyUk5hQ/YwngEHjV7rS+RnAuhbrcB5swgyzROuA=="], @@ -4079,7 +4079,7 @@ "global-directory": ["global-directory@4.0.1", "", { "dependencies": { "ini": "4.1.1" } }, "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q=="], - "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + "globals": ["globals@17.4.0", "", {}, "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], @@ -6897,6 +6897,8 @@ "@thallesp/nestjs-better-auth/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + "@tiptap/starter-kit/@tiptap/extension-list": ["@tiptap/extension-list@3.16.0", "", { "peerDependencies": { "@tiptap/core": "^3.16.0", "@tiptap/pm": "^3.16.0" } }, "sha512-tpjWGugfI0XYR9iG/QlYYtCY35TFWHNwGKc94wN4s7NmAjB4xlwdTkTZQ6PdZ39x1SeHkRjxAka+6GcBIoOHGQ=="], + "@trigger.dev/core/execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], "@trigger.dev/core/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], From 79fb25dd0aae127d7330fba0b97f715f86709ddc Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 2 Apr 2026 17:19:02 -0400 Subject: [PATCH 10/13] fix: use activeOrganizationId for org redirect on app open (#2444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: use activeOrganizationId for org redirect on app open The root page was picking the first "ready" org from the memberships list, ignoring the session's activeOrganizationId. This caused users to land on the wrong org after switching orgs and reopening the app. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use activeOrganizationId in setup and admin layouts Same issue as root page — these layouts picked organizations[0] instead of respecting the session's activeOrganizationId. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- apps/app/src/app/(app)/admin/layout.tsx | 8 +++++--- apps/app/src/app/(app)/setup/layout.tsx | 4 ++-- apps/app/src/app/page.tsx | 7 ++++++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/app/src/app/(app)/admin/layout.tsx b/apps/app/src/app/(app)/admin/layout.tsx index 7ede9b9836..345002718d 100644 --- a/apps/app/src/app/(app)/admin/layout.tsx +++ b/apps/app/src/app/(app)/admin/layout.tsx @@ -21,10 +21,12 @@ export default async function AdminLayout({ children }: { children: React.ReactN } const meRes = await serverApi.get('/v1/auth/me'); - const firstOrgId = meRes.data?.organizations?.[0]?.id; + const orgs = meRes.data?.organizations ?? []; + const activeOrgId = session.session.activeOrganizationId; + const targetOrg = orgs.find((o) => o.id === activeOrgId) ?? orgs[0]; - if (firstOrgId) { - redirect(`/${firstOrgId}/admin`); + if (targetOrg) { + redirect(`/${targetOrg.id}/admin`); } redirect('/'); diff --git a/apps/app/src/app/(app)/setup/layout.tsx b/apps/app/src/app/(app)/setup/layout.tsx index 6844f2b3a3..1c5a1089b0 100644 --- a/apps/app/src/app/(app)/setup/layout.tsx +++ b/apps/app/src/app/(app)/setup/layout.tsx @@ -21,8 +21,8 @@ export default async function SetupLayout({ children }: { children: React.ReactN const meRes = await serverApi.get('/v1/auth/me'); const orgs = meRes.data?.organizations ?? []; - // Find the most recently relevant org (API returns them, pick first) - const userOrg = orgs[0]; + const activeOrgId = session.session.activeOrganizationId; + const userOrg = orgs.find((o) => o.id === activeOrgId) ?? orgs[0]; if (userOrg) { if (userOrg.onboardingCompleted === false) { return redirect(`/onboarding/${userOrg.id}`); diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index c738987486..d2124d7f40 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -62,10 +62,15 @@ export default async function RootPage({ return redirect(await buildUrlWithParams('/setup')); } + // Always use the org the user last switched to (stored in session) + const activeOrgId = session.session.activeOrganizationId; + const activeOrg = activeOrgId + ? memberships.find((m) => m.id === activeOrgId) + : undefined; const readyOrg = memberships.find( (m) => m.onboardingCompleted && m.hasAccess, ); - const targetOrg = readyOrg || memberships[0]; + const targetOrg = activeOrg || readyOrg || memberships[0]; if (!targetOrg.onboardingCompleted) { return redirect(await buildUrlWithParams(`/onboarding/${targetOrg.id}`)); From e488a95e598a60e30af502ea0b3a1dffe211db3a Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 2 Apr 2026 17:23:31 -0400 Subject: [PATCH 11/13] fix(onboarding): reorder steps so cloud question comes before software (#2445) Move the infrastructure/cloud hosting question ahead of the software question in the onboarding wizard for a more logical flow. Co-authored-by: Claude Opus 4.6 (1M context) --- apps/app/src/app/(app)/setup/lib/constants.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/app/src/app/(app)/setup/lib/constants.ts b/apps/app/src/app/(app)/setup/lib/constants.ts index 4872027e67..42fd27b7ea 100644 --- a/apps/app/src/app/(app)/setup/lib/constants.ts +++ b/apps/app/src/app/(app)/setup/lib/constants.ts @@ -115,6 +115,12 @@ export const steps: Step[] = [ placeholder: 'e.g., Google Workspace', options: ['Google Workspace', 'Microsoft 365', 'Okta', 'Auth0', 'Email/Password', 'Other'], }, + { + key: 'infrastructure', + question: 'Where do you host your applications and data?', + placeholder: 'e.g., AWS', + options: ['AWS', 'Google Cloud', 'Microsoft Azure', 'Heroku', 'Vercel', 'Other'], + }, { key: 'software', question: 'What software do you use?', @@ -128,12 +134,6 @@ export const steps: Step[] = [ placeholder: 'e.g., Remote', options: ['Fully remote', 'Hybrid (office + remote)', 'Office-based'], }, - { - key: 'infrastructure', - question: 'Where do you host your applications and data?', - placeholder: 'e.g., AWS', - options: ['AWS', 'Google Cloud', 'Microsoft Azure', 'Heroku', 'Vercel', 'Other'], - }, { key: 'dataTypes', question: 'What types of data do you handle?', From 883caebd30bab00243e7dfc8355d91ab04e08cc1 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 2 Apr 2026 18:01:37 -0400 Subject: [PATCH 12/13] feat(portal): allow employees to view signed policies (#2446) * fix(onboarding): reorder steps so cloud question comes before software Move the infrastructure/cloud hosting question ahead of the software question in the onboarding wizard for a more logical flow. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(portal): add signed policies list page * feat(portal): add link to signed policies from dashboard Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../tasks/PoliciesAccordionItem.tsx | 27 ++-- .../(app)/(home)/[orgId]/policies/page.tsx | 116 ++++++++++++++++++ 2 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/policies/page.tsx diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PoliciesAccordionItem.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PoliciesAccordionItem.tsx index 2575029f3b..0fcb15bfde 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PoliciesAccordionItem.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PoliciesAccordionItem.tsx @@ -105,16 +105,23 @@ export function PoliciesAccordionItem({ policies, member }: PoliciesAccordionIte ); })}
    - +
    + + {hasAcceptedPolicies && ( + + + + )} +
    ) : (

    No policies ready to be signed.

    diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/policies/page.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/policies/page.tsx new file mode 100644 index 0000000000..a17f8f82f2 --- /dev/null +++ b/apps/portal/src/app/(app)/(home)/[orgId]/policies/page.tsx @@ -0,0 +1,116 @@ +import { auth } from '@/app/lib/auth'; +import { db } from '@db/server'; +import { + Breadcrumb, + Card, + CardContent, + PageHeader, + PageLayout, + Stack, + Text, +} from '@trycompai/design-system'; +import { Document } from '@trycompai/design-system/icons'; +import { headers } from 'next/headers'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; + +export default async function SignedPoliciesPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = await params; + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user) { + redirect('/auth'); + } + + const member = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId: orgId, + deactivated: false, + }, + }); + + if (!member) { + redirect('/'); + } + + const policies = await db.policy.findMany({ + where: { + organizationId: orgId, + status: 'published', + isRequiredToSign: true, + isArchived: false, + signedBy: { has: member.id }, + }, + orderBy: { name: 'asc' }, + select: { + id: true, + name: true, + description: true, + updatedAt: true, + }, + }); + + return ( + + }, + }, + { label: 'Signed Policies', isCurrent: true }, + ]} + /> + + + {policies.length === 0 ? ( + + No signed policies yet. + + ) : ( +
    + {policies.map((policy) => ( + + + +
    +
    + +
    +
    + + {policy.name} + + {policy.description && ( + + {policy.description} + + )} +
    + + {new Date(policy.updatedAt).toLocaleDateString()} + +
    +
    +
    + + ))} +
    + )} +
    +
    + ); +} From 13501bdfc12406be95fcb48dec54ddf77b968e8c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:21:10 -0400 Subject: [PATCH 13/13] CS-219 Hide devices of platform admin users on People/Devices tab (#2447) * fix(app): hide platform admin devices on People/Devices tab * fix(app): add platform admin filtering from fleet device source --------- Co-authored-by: chasprowebdev --- .../[orgId]/people/devices/data/index.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) 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 index 3bb1db1aa7..b14004a382 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts @@ -24,7 +24,17 @@ export const getEmployeeDevicesFromDB: () => Promise = async } const devices = await db.device.findMany({ - where: { organizationId }, + where: { + organizationId, + // Exclude devices for users whose User.role is platform admin (not org Member.role). + NOT: { + member: { + user: { + role: 'admin', + }, + }, + }, + }, include: { member: { include: { @@ -65,7 +75,7 @@ export const getEmployeeDevicesFromDB: () => Promise = async /** * Fetches Fleet (legacy) devices for the current organization. - * Returns Host[] exactly as main branch — untouched Fleet logic. + * Excludes members whose User.role is platform admin (same as getEmployeeDevicesFromDB). */ export const getFleetHosts: () => Promise = async () => { const session = await auth.api.getSession({ @@ -80,11 +90,16 @@ export const getFleetHosts: () => Promise = async () => { return null; } - // Find all members belonging to the organization. + // 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,