From fd648bde72a570d32c6c444a66eb828928cc96f2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:26:28 -0500 Subject: [PATCH 1/3] [BUG] Updating Assignee's within Vendors (#2138) * fix(app): cannot update the vendor due to validation error * fix(app): include website field to vendor form submission --------- Co-authored-by: chasprowebdev --- .../src/app/(app)/[orgId]/vendors/[vendorId]/actions/schema.ts | 2 +- .../secondary-fields/update-secondary-fields-form.tsx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/actions/schema.ts b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/actions/schema.ts index a97021ab80..4f51738266 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/actions/schema.ts +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/actions/schema.ts @@ -45,7 +45,7 @@ export const createVendorSchema = z.object({ export const updateVendorSchema = z.object({ id: z.string(), name: z.string().min(1, 'Name is required'), - description: z.string().min(1, 'Description is required'), + description: z.string().optional(), category: z.nativeEnum(VendorCategory), status: z.nativeEnum(VendorStatus), assigneeId: z.string().nullable(), diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/update-secondary-fields-form.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/update-secondary-fields-form.tsx index 19166a0238..42ffbdd2d8 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/update-secondary-fields-form.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/update-secondary-fields-form.tsx @@ -45,6 +45,7 @@ export function UpdateSecondaryFieldsForm({ assigneeId: vendor.assigneeId, category: vendor.category, status: vendor.status, + website: vendor.website ?? '', isSubProcessor: vendor.isSubProcessor, }, }); @@ -60,6 +61,7 @@ export function UpdateSecondaryFieldsForm({ assigneeId: finalAssigneeId, // Use the potentially nulled value category: data.category, status: data.status, + website: data.website, isSubProcessor: data.isSubProcessor, }); }; From b1b072e95d351a3624ee34bf8bbbaf6bbc926fe6 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:20:41 -0500 Subject: [PATCH 2/3] fix(portal): recognize device agent compliance for task completion (#2152) * fix(portal): recognize device agent compliance for task completion The portal was only checking FleetDM data to determine if the device agent task was complete. Device agent check results (stored in the Device table) were being ignored, so even when all agent checks passed, the portal still showed the task as incomplete. Now checks both sources: Fleet device + policies OR device agent isCompliant flag. Either one being complete marks the task as done. Also updates Prisma in API Dockerfile from 6.13.0 to 6.18.0 to match the version used in packages/db. Co-Authored-By: Claude Opus 4.6 * fix: prevent null reference crash when only agent device exists The installed device view assumed a Fleet host always exists, but with the new agent device support, hasInstalledAgent can be true from agentDevice alone while host is null. Co-Authored-By: Claude Opus 4.6 * fix: device agent takes priority over Fleet in portal When both Fleet and device agent exist, device agent completion and UI now takes priority. Fleet is still fully supported as fallback for orgs that only use Fleet. Co-Authored-By: Claude Opus 4.6 * fix: correct download filename and instructions for all platforms - Add LINUX_FILENAME export and use it for Linux downloads (was falling through to WINDOWS_FILENAME) - Add Linux-specific install instruction for DEB packages - Unify step 3 to show device agent login instructions for all platforms (was showing Fleet MDM instructions for non-macOS) Co-Authored-By: Claude Opus 4.6 * fix: add Linux to system requirements Co-Authored-By: Claude Opus 4.6 * fix: prevent unchecked device from hiding compliant device PostgreSQL puts NULL values first in DESC ordering, so a newly registered device (lastCheckIn=null) would be selected over an older compliant device. Use nulls:'last' to prefer devices that have actually checked in. Co-Authored-By: Claude Opus 4.6 * feat: add SWR polling for agent device compliance status Agent device status was fetched once server-side and never refreshed, so compliance changes required a full page reload. Now polls /api/device-agent/status every 30s and revalidates on tab focus, matching the Fleet SWR pattern. Co-Authored-By: Claude Opus 4.6 * fix: sort agent devices by lastCheckIn to match server-side ordering The status API returns devices sorted by installedAt, but page.tsx fetches by lastCheckIn desc nulls last. Without client-side sorting, SWR could pick a freshly registered device (null lastCheckIn) over an older compliant one, causing the task to flash back to incomplete. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- apps/api/Dockerfile.multistage | 2 +- .../[orgId]/components/EmployeeTasksList.tsx | 47 ++++++++-- .../components/OrganizationDashboard.tsx | 5 +- .../tasks/DeviceAgentAccordionItem.tsx | 91 +++++++++++-------- .../src/app/(app)/(home)/[orgId]/page.tsx | 10 ++ .../src/app/api/download-agent/constants.ts | 1 + 6 files changed, 112 insertions(+), 44 deletions(-) diff --git a/apps/api/Dockerfile.multistage b/apps/api/Dockerfile.multistage index b5f4f0ff27..9eef3018e0 100644 --- a/apps/api/Dockerfile.multistage +++ b/apps/api/Dockerfile.multistage @@ -92,7 +92,7 @@ ENV NODE_ENV=production ENV PORT=3333 # Install Prisma CLI and regenerate client in production stage -RUN npm install -g prisma@6.13.0 && \ +RUN npm install -g prisma@6.18.0 && \ prisma generate --schema=./prisma/schema.prisma # Create non-root user 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 51a3ee0ecb..a57bb2c08e 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/EmployeeTasksList.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/EmployeeTasksList.tsx @@ -4,7 +4,7 @@ import { trainingVideos } from '@/lib/data/training-videos'; import { evidenceFormDefinitionList } from '@comp/company'; import { Accordion } from '@comp/ui/accordion'; import { Card, CardContent } from '@comp/ui/card'; -import type { EmployeeTrainingVideoCompletion, Member, Policy, PolicyVersion } from '@db'; +import type { Device, EmployeeTrainingVideoCompletion, Member, Policy, PolicyVersion } from '@db'; import { Button } from '@trycompai/design-system'; import Link from 'next/link'; import { CheckCircle2 } from 'lucide-react'; @@ -27,6 +27,7 @@ interface EmployeeTasksListProps { member: Member; fleetPolicies: FleetPolicy[]; host: Host | null; + agentDevice: Device | null; deviceAgentStepEnabled: boolean; securityTrainingStepEnabled: boolean; whistleblowerReportEnabled: boolean; @@ -40,6 +41,7 @@ export const EmployeeTasksList = ({ member, fleetPolicies, host, + agentDevice, deviceAgentStepEnabled, securityTrainingStepEnabled, whistleblowerReportEnabled, @@ -64,18 +66,50 @@ export const EmployeeTasksList = ({ }, ); + // Poll agent device status so compliance updates appear without full reload + const { data: agentDeviceResponse } = useSWR<{ devices: Device[] }>( + deviceAgentStepEnabled + ? `/api/device-agent/status?organizationId=${organizationId}` + : null, + async (url) => { + const res = await fetch(url); + if (!res.ok) throw new Error('Failed to fetch'); + return res.json(); + }, + { + fallbackData: agentDevice ? { devices: [agentDevice] } : { devices: [] }, + refreshInterval: 30_000, + revalidateOnFocus: true, + revalidateOnMount: false, + }, + ); + if (!response) { 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; + // Check completion status const hasAcceptedPolicies = policies.length === 0 || policies.every((p) => p.signedBy.includes(member.id)); - const hasInstalledAgent = response.device !== null; - const allFleetPoliciesPass = - response.fleetPolicies.length === 0 || - response.fleetPolicies.every((policy) => policy.response === 'pass'); - const hasCompletedDeviceSetup = hasInstalledAgent && allFleetPoliciesPass; + + // Device agent takes priority over Fleet for completion + const hasAgentDevice = currentAgentDevice !== null; + const hasFleetDevice = response.device !== null; + const hasCompletedDeviceSetup = hasAgentDevice + ? currentAgentDevice.isCompliant + : hasFleetDevice && + (response.fleetPolicies.length === 0 || + response.fleetPolicies.every((policy) => policy.response === 'pass')); // Calculate general training completion (matching logic from GeneralTrainingAccordionItem) const generalTrainingVideoIds = trainingVideos @@ -109,6 +143,7 @@ export const EmployeeTasksList = ({ void; @@ -29,6 +31,7 @@ interface DeviceAgentAccordionItemProps { export function DeviceAgentAccordionItem({ member, host, + agentDevice, isLoading, fleetPolicies = [], fetchFleetPolicies, @@ -41,13 +44,20 @@ export function DeviceAgentAccordionItem({ [detectedOS], ); - const hasInstalledAgent = host !== null; + const hasFleetDevice = host !== null; + const hasAgentDevice = agentDevice !== null; + const hasInstalledAgent = hasFleetDevice || hasAgentDevice; const failedPoliciesCount = useMemo( () => fleetPolicies.filter((policy) => policy.response !== 'pass').length, [fleetPolicies], ); - const isCompleted = hasInstalledAgent && failedPoliciesCount === 0; + // Device agent takes priority over Fleet + const isCompleted = hasAgentDevice + ? agentDevice.isCompliant + : hasFleetDevice + ? failedPoliciesCount === 0 + : false; const handleDownload = async () => { if (!detectedOS) { @@ -87,6 +97,8 @@ export function DeviceAgentAccordionItem({ // Set filename based on OS and architecture if (isMacOS) { a.download = detectedOS === 'macos' ? MAC_APPLE_SILICON_FILENAME : MAC_INTEL_FILENAME; + } else if (detectedOS === 'linux') { + a.download = LINUX_FILENAME; } else { a.download = WINDOWS_FILENAME; } @@ -149,7 +161,7 @@ export function DeviceAgentAccordionItem({ Device Agent - {hasInstalledAgent && failedPoliciesCount > 0 && ( + {!hasAgentDevice && hasFleetDevice && failedPoliciesCount > 0 && ( {failedPoliciesCount} policies failing @@ -196,40 +208,47 @@ export function DeviceAgentAccordionItem({

{isMacOS ? 'Double-click the downloaded DMG file and follow the installation instructions.' - : 'Double-click the downloaded EXE 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.'} +

+ +
  • + Login with your work email +

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

  • - {isMacOS ? ( -
  • - Login with your work email -

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

    -
  • - ) : ( -
  • - Enable MDM -
    -

    - Find the Fleet Desktop app in your system tray (bottom right corner). Click - on it and click My Device. -

    -

    - You should see a banner that asks you to enable MDM. Click the button and - follow the instructions. -

    -

    - After you've enabled MDM, if you refresh the page, the banner will - disappear. Now your computer will automatically enable the necessary - settings on your computer in order to be compliant. -

    -
    -
  • - )} - ) : ( + ) : 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 ? (
    @@ -263,7 +282,7 @@ export function DeviceAgentAccordionItem({ )} - )} + ) : null}
    @@ -276,7 +295,7 @@ export function DeviceAgentAccordionItem({

    - Operating Systems: macOS 14+, Windows 10+ + Operating Systems: macOS 14+, Windows 10+, Linux (Ubuntu 20.04+)

    Memory: 512MB RAM minimum diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx index ca0d294a35..a0d45853bc 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx @@ -57,6 +57,15 @@ 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({ + where: { + memberId: member.id, + organizationId: orgId, + }, + orderBy: { lastCheckIn: { sort: 'desc', nulls: 'last' } }, + }); + return ( @@ -66,6 +75,7 @@ export default async function OrganizationPage({ params }: { params: Promise<{ o member={member} fleetPolicies={fleetData.fleetPolicies} host={fleetData.device} + agentDevice={agentDevice} /> ); diff --git a/apps/portal/src/app/api/download-agent/constants.ts b/apps/portal/src/app/api/download-agent/constants.ts index a4b8e31aa4..3a47edd84f 100644 --- a/apps/portal/src/app/api/download-agent/constants.ts +++ b/apps/portal/src/app/api/download-agent/constants.ts @@ -34,3 +34,4 @@ export const DOWNLOAD_TARGETS: Record< export const MAC_APPLE_SILICON_FILENAME = DOWNLOAD_TARGETS.macos.filename; export const MAC_INTEL_FILENAME = DOWNLOAD_TARGETS['macos-intel'].filename; export const WINDOWS_FILENAME = DOWNLOAD_TARGETS.windows.filename; +export const LINUX_FILENAME = DOWNLOAD_TARGETS.linux.filename; From 36914f5f1e235b88d4021affb8c9cbb4f743124f Mon Sep 17 00:00:00 2001 From: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:28:29 -0500 Subject: [PATCH 3/3] Fix collapsed OTP input boxes in portal login (#2153) * fix: update OTP input to modern children + context API The InputOTPSlot component used the deprecated render prop pattern from input-otp v1.x which caused the OTP boxes to collapse. Updated to the modern children-based API using OTPInputContext, matching the approach used by @trycompai/design-system. Co-Authored-By: Claude Opus 4.6 * fix: handle undefined slot in InputOTPSlot for type safety Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- apps/portal/src/app/components/otp-form.tsx | 18 +++++++----------- packages/ui/src/components/input-otp.tsx | 11 +++++++---- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/apps/portal/src/app/components/otp-form.tsx b/apps/portal/src/app/components/otp-form.tsx index 35ff104af0..9550e20379 100644 --- a/apps/portal/src/app/components/otp-form.tsx +++ b/apps/portal/src/app/components/otp-form.tsx @@ -71,17 +71,13 @@ export function OtpForm({ email }: OtpFormProps) { render={({ field }) => ( - ( - - {slots.map((slot, index) => ( - - ))} - - )} - /> + + + {Array.from({ length: INPUT_LENGTH }, (_, i) => ( + + ))} + + diff --git a/packages/ui/src/components/input-otp.tsx b/packages/ui/src/components/input-otp.tsx index 81665a5dc7..9f299efd2a 100644 --- a/packages/ui/src/components/input-otp.tsx +++ b/packages/ui/src/components/input-otp.tsx @@ -1,7 +1,7 @@ 'use client'; import { DashIcon } from '@radix-ui/react-icons'; -import { OTPInput, type SlotProps } from 'input-otp'; +import { OTPInput, OTPInputContext } from 'input-otp'; import * as React from 'react'; import { cn } from '../utils'; @@ -23,13 +23,16 @@ InputOTPGroup.displayName = 'InputOTPGroup'; const InputOTPSlot = React.forwardRef< React.ElementRef<'div'>, - SlotProps & React.ComponentPropsWithoutRef<'div'> ->(({ char, hasFakeCaret, isActive, className, placeholderChar, ...props }, ref) => { + React.ComponentPropsWithoutRef<'div'> & { index: number } +>(({ index, className, ...props }, ref) => { + const inputOTPContext = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}; + return (