From 25ea26adcd629d65f10e33461d5293a3f9529c13 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 19 Jan 2026 10:21:27 -0500 Subject: [PATCH 01/25] feat(portal): upload policy images to S3 bucket --- .../tasks/DeviceAgentAccordionItem.tsx | 80 +------ .../components/tasks/FleetPolicyItem.tsx | 60 ++++++ .../components/tasks/MDMPolicyItem.tsx | 65 ++++++ .../tasks/PolicyImageUploadModal.tsx | 203 ++++++++++++++++++ .../(home)/actions/uploadPolicyImages.ts | 83 +++++++ apps/portal/src/utils/s3.ts | 1 + 6 files changed, 417 insertions(+), 75 deletions(-) create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/MDMPolicyItem.tsx create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx create mode 100644 apps/portal/src/app/(app)/(home)/actions/uploadPolicyImages.ts 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 fa37a99e9..0f3b82bbc 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 @@ -11,11 +11,12 @@ import { Button } from '@comp/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; import { cn } from '@comp/ui/cn'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@comp/ui/tooltip'; import type { Member } from '@db'; -import { CheckCircle2, Circle, Download, HelpCircle, Loader2, XCircle } from 'lucide-react'; +import { CheckCircle2, Circle, Download, Loader2 } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; +import { FleetPolicyItem } from './FleetPolicyItem'; +import { MDMPolicyItem } from './MDMPolicyItem'; import type { FleetPolicy, Host } from '../../types'; interface DeviceAgentAccordionItemProps { @@ -246,80 +247,9 @@ export function DeviceAgentAccordionItem({ {fleetPolicies.length > 0 ? ( <> {fleetPolicies.map((policy) => ( -
-

{policy.name}

- {policy.response === 'pass' ? ( -
- - Pass -
- ) : ( -
- - Fail -
- )} -
+ ))} - {isMacOS && ( -
-
-

{mdmEnabledStatus.name}

- {mdmEnabledStatus.response === 'fail' && host?.id && ( - - - - - - -

- There are additional steps required to enable MDM. Please check{' '} - - this documentation - - . -

-
-
-
- )} -
- {mdmEnabledStatus.response === 'pass' ? ( -
- - Pass -
- ) : ( -
- - Fail -
- )} -
- )} + {isMacOS && } ) : (

diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx new file mode 100644 index 000000000..a3ae9ecfc --- /dev/null +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useState } from 'react'; + +import { Button } from '@comp/ui/button'; +import { cn } from '@comp/ui/cn'; +import { CheckCircle2, Image, Upload, XCircle } from 'lucide-react'; +import type { FleetPolicy } from '../../types'; +import { PolicyImageUploadModal } from './PolicyImageUploadModal'; + +interface FleetPolicyItemProps { + policy: FleetPolicy; +} + +export function FleetPolicyItem({ policy }: FleetPolicyItemProps) { + const [isUploadOpen, setIsUploadOpen] = useState(false); + + return ( + <> +

+

{policy.name}

+
+ + + {policy.response !== 'pass' ? ( +
+ + Pass +
+ ) : ( +
+ + Fail +
+ )} +
+
+ + + ); +} \ No newline at end of file diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/MDMPolicyItem.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/MDMPolicyItem.tsx new file mode 100644 index 000000000..0b66c0142 --- /dev/null +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/MDMPolicyItem.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { cn } from '@comp/ui/cn'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@comp/ui/tooltip'; +import { CheckCircle2, HelpCircle, XCircle } from 'lucide-react'; +import type { Host } from '../../types'; + +interface MDMPolicyItemProps { + host: Host | null; +} + +export function MDMPolicyItem({ host }: MDMPolicyItemProps) { + const status = host?.mdm.connected_to_fleet ? 'pass' : 'fail'; + const name = 'MDM Enabled'; + const hostId = host?.id ?? null; + + return ( +
+
+

{name}

+ {status === 'fail' && hostId && ( + + + + + + +

+ There are additional steps required to enable MDM. Please check{' '} + + this documentation + + . +

+
+
+
+ )} +
+ {status === 'pass' ? ( +
+ + Pass +
+ ) : ( +
+ + Fail +
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx new file mode 100644 index 000000000..db468ca53 --- /dev/null +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx @@ -0,0 +1,203 @@ +'use client'; + +import { useRef, useState } from 'react'; + +import { Button } from '@comp/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@comp/ui/dialog'; +import { ImagePlus, Trash2, Loader2 } from 'lucide-react'; +import Image from 'next/image'; +import { useAction } from 'next-safe-action/hooks'; +import { uploadPolicyImagesAction } from '../../../actions/uploadPolicyImages'; +import { toast } from 'sonner'; + +interface PolicyImageUploadModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + policyId: string; +} + +export function PolicyImageUploadModal({ open, onOpenChange, policyId }: PolicyImageUploadModalProps) { + const fileInputRef = useRef(null); + const [files, setFiles] = useState>([]); + + const uploadPolicyImages = useAction(uploadPolicyImagesAction, { + onSuccess: (result) => { + toast.success('Policy images uploaded successfully'); + }, + onError: (error) => { + toast.error(error.error.serverError || 'Failed to upload policy images'); + }, + }); + + const handleFileChange = (e: React.ChangeEvent) => { + const selected = Array.from(e.target.files ?? []); + const imageFiles = selected.filter((file) => file.type.startsWith('image/')); + const withPreviews = imageFiles.map((file) => ({ + file, + previewUrl: URL.createObjectURL(file), + })); + setFiles((prev) => [...prev, ...withPreviews]); + + // reset input so same files can be reselected + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const onRemoveFile = (target: { file: File; previewUrl: string }) => { + URL.revokeObjectURL(target.previewUrl); + setFiles((prev) => prev.filter((item) => item !== target)); + }; + + const handleClose = (nextOpen: boolean) => { + if (!nextOpen) { + files.forEach(({ previewUrl }) => URL.revokeObjectURL(previewUrl)); + setFiles([]); + } + onOpenChange(nextOpen); + }; + + const fileToBase64 = (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + const base64 = result.split(',')[1]; + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + + const handleSubmit = async () => { + if (files.length === 0 || uploadPolicyImages.status === 'executing') return; + + try { + const images = await Promise.all( + files.map(async ({ file }) => ({ + fileName: file.name, + fileType: file.type, + fileData: await fileToBase64(file), + })), + ); + + await uploadPolicyImages.execute({ + policyId, + images, + }); + + handleClose(false); + } catch (error) { + console.error('Failed to upload policy images', error); + toast.error('Failed to upload policy images'); + } + }; + + const isLoading = uploadPolicyImages.status === 'executing'; + + return ( + + { + e.preventDefault(); + }} + > + + Upload Policy Images + + +
+ + {files.length === 0 && ( +
+ +
+ )} + + {files.length > 0 && ( +
+
+
    + {files.map((item, idx) => ( +
  • +
    +
    + {item.file.name} +
    + {item.file.name} +
    + +
  • + ))} +
+
+
+ )} +
+ + + {files.length > 0 && ( + <> +
+ + {files.length} file{files.length === 1 ? '' : 's'} + +
+ + + )} + + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/portal/src/app/(app)/(home)/actions/uploadPolicyImages.ts b/apps/portal/src/app/(app)/(home)/actions/uploadPolicyImages.ts new file mode 100644 index 000000000..2cabdd3e8 --- /dev/null +++ b/apps/portal/src/app/(app)/(home)/actions/uploadPolicyImages.ts @@ -0,0 +1,83 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/utils/s3'; +import { PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { z } from 'zod'; + +const uploadPolicyImagesSchema = z.object({ + policyId: z.string(), + images: z + .array( + z.object({ + fileName: z.string(), + fileType: z.string(), + fileData: z.string(), // base64 encoded + }), + ) + .min(1, 'At least one image is required'), +}); + +export const uploadPolicyImagesAction = authActionClient + .inputSchema(uploadPolicyImagesSchema) + .metadata({ + name: 'upload-policy-images', + track: { + event: 'upload-policy-images', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { policyId, images } = parsedInput; + const organizationId = ctx.session.activeOrganizationId; + + if (!organizationId) { + throw new Error('No active organization'); + } + + if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) { + throw new Error('File upload service is not available'); + } + + const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB per image + const uploads: Array<{ fileName: string; key: string; url: string }> = []; + + for (const image of images) { + const { fileName, fileType, fileData } = image; + + if (!fileType.startsWith('image/')) { + throw new Error(`Only image files are allowed (${fileName})`); + } + + const fileBuffer = Buffer.from(fileData, 'base64'); + if (fileBuffer.length > MAX_FILE_SIZE_BYTES) { + throw new Error(`Image ${fileName} must be less than 5MB`); + } + + const timestamp = Date.now(); + const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); + const key = `${organizationId}/policies/${policyId}/${timestamp}-${sanitizedFileName}`; + + const putCommand = new PutObjectCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Key: key, + Body: fileBuffer, + ContentType: fileType, + }); + await s3Client.send(putCommand); + + const getCommand = new GetObjectCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Key: key, + }); + const signedUrl = await getSignedUrl(s3Client, getCommand, { expiresIn: 3600 }); + + uploads.push({ fileName, key, url: signedUrl }); + } + + return { + success: true, + uploads, + }; + }); diff --git a/apps/portal/src/utils/s3.ts b/apps/portal/src/utils/s3.ts index 52f32752c..153ef4d7b 100644 --- a/apps/portal/src/utils/s3.ts +++ b/apps/portal/src/utils/s3.ts @@ -8,6 +8,7 @@ const APP_AWS_SECRET_ACCESS_KEY = process.env.APP_AWS_SECRET_ACCESS_KEY; const APP_AWS_ENDPOINT = process.env.APP_AWS_ENDPOINT; export const BUCKET_NAME = process.env.APP_AWS_BUCKET_NAME; +export const APP_AWS_ORG_ASSETS_BUCKET = process.env.APP_AWS_ORG_ASSETS_BUCKET; if (!APP_AWS_ACCESS_KEY_ID || !APP_AWS_SECRET_ACCESS_KEY || !BUCKET_NAME || !APP_AWS_REGION) { // Log the error in production environments From d7ddd4f8c13fbbbbc4f6aef2977399f2f84fa735 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 19 Jan 2026 10:23:02 -0500 Subject: [PATCH 02/25] fix(portal): rename s3 bucket for fleet policy attachments --- apps/portal/src/app/(app)/(home)/actions/uploadPolicyImages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/portal/src/app/(app)/(home)/actions/uploadPolicyImages.ts b/apps/portal/src/app/(app)/(home)/actions/uploadPolicyImages.ts index 2cabdd3e8..5fe594ab2 100644 --- a/apps/portal/src/app/(app)/(home)/actions/uploadPolicyImages.ts +++ b/apps/portal/src/app/(app)/(home)/actions/uploadPolicyImages.ts @@ -57,7 +57,7 @@ export const uploadPolicyImagesAction = authActionClient const timestamp = Date.now(); const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); - const key = `${organizationId}/policies/${policyId}/${timestamp}-${sanitizedFileName}`; + const key = `${organizationId}/fleet-policies/${policyId}/${timestamp}-${sanitizedFileName}`; const putCommand = new PutObjectCommand({ Bucket: APP_AWS_ORG_ASSETS_BUCKET, From 1b2e8c904db1a79e930fa17a8c60c28aa50a1860 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 19 Jan 2026 12:29:00 -0500 Subject: [PATCH 03/25] feat(db): create new table - FleetPolicyResult --- .../migration.sql | 26 +++++++++++++++++++ .../migrations/20260119153557/migration.sql | 4 +++ .../migration.sql | 10 +++++++ packages/db/prisma/schema/auth.prisma | 1 + .../prisma/schema/fleet-policy-result.prisma | 17 ++++++++++++ packages/db/prisma/schema/organization.prisma | 2 +- 6 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 packages/db/prisma/migrations/20260117020000_add_fleet_policy_result/migration.sql create mode 100644 packages/db/prisma/migrations/20260119153557/migration.sql create mode 100644 packages/db/prisma/migrations/20260119154500_rename_member_to_user_in_fleet_policy_result/migration.sql create mode 100644 packages/db/prisma/schema/fleet-policy-result.prisma diff --git a/packages/db/prisma/migrations/20260117020000_add_fleet_policy_result/migration.sql b/packages/db/prisma/migrations/20260117020000_add_fleet_policy_result/migration.sql new file mode 100644 index 000000000..0c6165bfd --- /dev/null +++ b/packages/db/prisma/migrations/20260117020000_add_fleet_policy_result/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE "FleetPolicyResult" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('fpr'::text), + "memberId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "fleetPolicyId" INTEGER NOT NULL, + "fleetPolicyName" TEXT NOT NULL, + "fleetPolicyResponse" TEXT NOT NULL, + "attachments" TEXT[] NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FleetPolicyResult_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "FleetPolicyResult_memberId_idx" ON "FleetPolicyResult"("memberId"); + +-- CreateIndex +CREATE INDEX "FleetPolicyResult_organizationId_idx" ON "FleetPolicyResult"("organizationId"); + +-- AddForeignKey +ALTER TABLE "FleetPolicyResult" ADD CONSTRAINT "FleetPolicyResult_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "Member"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FleetPolicyResult" ADD CONSTRAINT "FleetPolicyResult_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260119153557/migration.sql b/packages/db/prisma/migrations/20260119153557/migration.sql new file mode 100644 index 000000000..4f3f475a5 --- /dev/null +++ b/packages/db/prisma/migrations/20260119153557/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "FleetPolicyResult" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMP(3), +ALTER COLUMN "updatedAt" DROP DEFAULT, +ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMP(3); diff --git a/packages/db/prisma/migrations/20260119154500_rename_member_to_user_in_fleet_policy_result/migration.sql b/packages/db/prisma/migrations/20260119154500_rename_member_to_user_in_fleet_policy_result/migration.sql new file mode 100644 index 000000000..16acac6a6 --- /dev/null +++ b/packages/db/prisma/migrations/20260119154500_rename_member_to_user_in_fleet_policy_result/migration.sql @@ -0,0 +1,10 @@ +-- Rename column +ALTER TABLE "FleetPolicyResult" RENAME COLUMN "memberId" TO "userId"; + +-- Drop old index if exists and create new index +DROP INDEX IF EXISTS "FleetPolicyResult_memberId_idx"; +CREATE INDEX "FleetPolicyResult_userId_idx" ON "FleetPolicyResult"("userId"); + +-- Drop old foreign key and add new one to User +ALTER TABLE "FleetPolicyResult" DROP CONSTRAINT IF EXISTS "FleetPolicyResult_memberId_fkey"; +ALTER TABLE "FleetPolicyResult" ADD CONSTRAINT "FleetPolicyResult_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index c376dd59e..b71467592 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -17,6 +17,7 @@ model User { invitations Invitation[] members Member[] sessions Session[] + fleetPolicyResults FleetPolicyResult[] @@unique([email]) } diff --git a/packages/db/prisma/schema/fleet-policy-result.prisma b/packages/db/prisma/schema/fleet-policy-result.prisma new file mode 100644 index 000000000..6c9932077 --- /dev/null +++ b/packages/db/prisma/schema/fleet-policy-result.prisma @@ -0,0 +1,17 @@ +model FleetPolicyResult { + id String @id @default(dbgenerated("generate_prefixed_cuid('fpr'::text)")) + userId String + organizationId String + fleetPolicyId Int + fleetPolicyName String + fleetPolicyResponse String + attachments String[] @default([]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([organizationId]) +} diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma index 1bd3c358f..ea5743c9f 100644 --- a/packages/db/prisma/schema/organization.prisma +++ b/packages/db/prisma/schema/organization.prisma @@ -47,13 +47,13 @@ model Organization { primaryColor String? trustPortalFaqs Json? // Array of { question: string, answer: string, order: number } - // Integration Platform integrationConnections IntegrationConnection[] integrationOAuthApps IntegrationOAuthApp[] // Browser Automation browserbaseContext BrowserbaseContext? + fleetPolicyResults FleetPolicyResult[] @@index([slug]) } From 07bc56a2694d1d63f24385e7c158c6ce9d7bfe75 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 19 Jan 2026 13:37:27 -0500 Subject: [PATCH 04/25] feat(portal): preview fleet policy attachments and set it passed when images are uploaded --- .../[orgId]/components/EmployeeTasksList.tsx | 6 +- .../components/OrganizationDashboard.tsx | 5 +- .../tasks/DeviceAgentAccordionItem.tsx | 17 ++- .../components/tasks/FleetPolicyItem.tsx | 67 +++++++---- .../tasks/PolicyImagePreviewModal.tsx | 73 ++++++++++++ .../tasks/PolicyImageUploadModal.tsx | 68 +++++------ .../src/app/(app)/(home)/[orgId]/page.tsx | 31 ++++- .../(home)/actions/uploadPolicyImages.ts | 83 ------------- .../src/app/api/confirm-fleet-policy/route.ts | 110 ++++++++++++++++++ apps/portal/src/app/api/fleet-policy/route.ts | 57 +++++++++ 10 files changed, 363 insertions(+), 154 deletions(-) create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImagePreviewModal.tsx delete mode 100644 apps/portal/src/app/(app)/(home)/actions/uploadPolicyImages.ts create mode 100644 apps/portal/src/app/api/confirm-fleet-policy/route.ts create mode 100644 apps/portal/src/app/api/fleet-policy/route.ts 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 4113cdcc2..3afded53b 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/EmployeeTasksList.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/EmployeeTasksList.tsx @@ -3,7 +3,7 @@ import { trainingVideos } from '@/lib/data/training-videos'; import { Accordion } from '@comp/ui/accordion'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card'; -import type { EmployeeTrainingVideoCompletion, Member, Policy } from '@db'; +import type { EmployeeTrainingVideoCompletion, FleetPolicyResult, Member, Policy } from '@db'; import type { FleetPolicy, Host } from '../types'; import { DeviceAgentAccordionItem } from './tasks/DeviceAgentAccordionItem'; import { GeneralTrainingAccordionItem } from './tasks/GeneralTrainingAccordionItem'; @@ -14,6 +14,7 @@ interface EmployeeTasksListProps { trainingVideos: EmployeeTrainingVideoCompletion[]; member: Member; fleetPolicies: FleetPolicy[]; + fleetPolicyResults: FleetPolicyResult[]; host: Host | null; } @@ -22,6 +23,7 @@ export const EmployeeTasksList = ({ trainingVideos: trainingVideoCompletions, member, fleetPolicies, + fleetPolicyResults, host, }: EmployeeTasksListProps) => { // Check completion status @@ -59,7 +61,7 @@ export const EmployeeTasksList = ({ { title: 'Download and install Comp AI Device Agent', content: ( - + ), }, { 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 1383bdb15..6c14b2498 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/OrganizationDashboard.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/OrganizationDashboard.tsx @@ -1,4 +1,4 @@ -import type { Member, Organization, User } from '@db'; +import type { FleetPolicyResult, Member, Organization, User } from '@db'; import { db } from '@db'; import { NoAccessMessage } from '../../components/NoAccessMessage'; import type { FleetPolicy, Host } from '../types'; @@ -14,6 +14,7 @@ interface OrganizationDashboardProps { organizationId: string; member: MemberWithUserOrg; // Pass the full member object for user info etc. fleetPolicies: FleetPolicy[]; + fleetPolicyResults: FleetPolicyResult[]; host: Host | null; } @@ -21,6 +22,7 @@ export async function OrganizationDashboard({ organizationId, member, fleetPolicies, + fleetPolicyResults, host, }: OrganizationDashboardProps) { // Fetch policies specific to the selected organization @@ -72,6 +74,7 @@ export async function OrganizationDashboard({ trainingVideos={trainingVideos} member={member} // Pass the member object down fleetPolicies={fleetPolicies} + fleetPolicyResults={fleetPolicyResults} host={host} /> ); 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 0f3b82bbc..0cc4a0e17 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 @@ -11,7 +11,7 @@ import { Button } from '@comp/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; import { cn } from '@comp/ui/cn'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; -import type { Member } from '@db'; +import type { FleetPolicyResult, Member } from '@db'; import { CheckCircle2, Circle, Download, Loader2 } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; @@ -23,12 +23,14 @@ interface DeviceAgentAccordionItemProps { member: Member; host: Host | null; fleetPolicies?: FleetPolicy[]; + fleetPolicyResults: FleetPolicyResult[]; } export function DeviceAgentAccordionItem({ member, host, fleetPolicies = [], + fleetPolicyResults = [], }: DeviceAgentAccordionItemProps) { const [isDownloading, setIsDownloading] = useState(false); const [detectedOS, setDetectedOS] = useState(null); @@ -46,13 +48,20 @@ export function DeviceAgentAccordionItem({ }; }, [host]); + const fleetPolicyResultsMap = useMemo(() => { + return fleetPolicyResults.reduce((acc, result) => { + acc[result.fleetPolicyId] = result; + return acc; + }, {} as Record); + }, [fleetPolicyResults]); + const hasInstalledAgent = host !== null; const failedPoliciesCount = useMemo(() => { return ( - fleetPolicies.filter((policy) => policy.response !== 'pass').length + + fleetPolicies.filter((policy) => policy.response !== 'pass' && fleetPolicyResultsMap[policy.id]?.fleetPolicyResponse !== 'pass').length + (!isMacOS || mdmEnabledStatus.response === 'pass' ? 0 : 1) ); - }, [fleetPolicies, mdmEnabledStatus, isMacOS]); + }, [fleetPolicies, mdmEnabledStatus, isMacOS, fleetPolicyResultsMap]); const isCompleted = hasInstalledAgent && failedPoliciesCount === 0; @@ -247,7 +256,7 @@ export function DeviceAgentAccordionItem({ {fleetPolicies.length > 0 ? ( <> {fleetPolicies.map((policy) => ( - + ))} {isMacOS && } diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx index a3ae9ecfc..d04478d22 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx @@ -7,13 +7,17 @@ import { cn } from '@comp/ui/cn'; import { CheckCircle2, Image, Upload, XCircle } from 'lucide-react'; import type { FleetPolicy } from '../../types'; import { PolicyImageUploadModal } from './PolicyImageUploadModal'; +import { FleetPolicyResult } from '@db'; +import { PolicyImagePreviewModal } from './PolicyImagePreviewModal'; interface FleetPolicyItemProps { policy: FleetPolicy; + policyResult?: FleetPolicyResult; } -export function FleetPolicyItem({ policy }: FleetPolicyItemProps) { +export function FleetPolicyItem({ policy, policyResult }: FleetPolicyItemProps) { const [isUploadOpen, setIsUploadOpen] = useState(false); + const [isPreviewOpen, setIsPreviewOpen] = useState(false); return ( <> @@ -25,35 +29,52 @@ export function FleetPolicyItem({ policy }: FleetPolicyItemProps) { >

{policy.name}

- - - {policy.response !== 'pass' ? ( -
- - Pass -
+ {policy.response === 'pass' || policyResult?.fleetPolicyResponse === 'pass' ? ( + <> + {(policyResult?.attachments || []).length > 0 && ( + + )} +
+ + Pass +
+ ) : ( -
- - Fail -
+ <> + +
+ + Fail +
+ )}
+ ); diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImagePreviewModal.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImagePreviewModal.tsx new file mode 100644 index 000000000..b647afe03 --- /dev/null +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImagePreviewModal.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@comp/ui/dialog'; +import Image from 'next/image'; +import { CarouselControls } from '../video/CarouselControls'; + +interface PolicyImagePreviewModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + images: string[]; +} + +export function PolicyImagePreviewModal({ open, images, onOpenChange }: PolicyImagePreviewModalProps) { + const [currentIndex, setCurrentIndex] = useState(0); + + useEffect(() => { + if (open) { + setCurrentIndex(0); + } + }, [open, images.length]); + + const hasImages = images.length > 0; + + const goPrevious = () => { + setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1)); + }; + + const goNext = () => { + setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1)); + }; + + return ( + + + + Preview + +
+ {!hasImages &&

No images to display.

} + + {hasImages && ( + <> +
+ {`Policy +
+ + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx index db468ca53..cfc451666 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx @@ -12,28 +12,19 @@ import { } from '@comp/ui/dialog'; import { ImagePlus, Trash2, Loader2 } from 'lucide-react'; import Image from 'next/image'; -import { useAction } from 'next-safe-action/hooks'; -import { uploadPolicyImagesAction } from '../../../actions/uploadPolicyImages'; import { toast } from 'sonner'; +import { FleetPolicy } from '../../types'; interface PolicyImageUploadModalProps { open: boolean; onOpenChange: (open: boolean) => void; - policyId: string; + policy: FleetPolicy; } -export function PolicyImageUploadModal({ open, onOpenChange, policyId }: PolicyImageUploadModalProps) { +export function PolicyImageUploadModal({ open, onOpenChange, policy }: PolicyImageUploadModalProps) { const fileInputRef = useRef(null); const [files, setFiles] = useState>([]); - - const uploadPolicyImages = useAction(uploadPolicyImagesAction, { - onSuccess: (result) => { - toast.success('Policy images uploaded successfully'); - }, - onError: (error) => { - toast.error(error.error.serverError || 'Failed to upload policy images'); - }, - }); + const [isLoading, setIsLoading] = useState(false); const handleFileChange = (e: React.ChangeEvent) => { const selected = Array.from(e.target.files ?? []); @@ -63,44 +54,41 @@ export function PolicyImageUploadModal({ open, onOpenChange, policyId }: PolicyI onOpenChange(nextOpen); }; - const fileToBase64 = (file: File) => - new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const result = reader.result as string; - const base64 = result.split(',')[1]; - resolve(base64); - }; - reader.onerror = reject; - reader.readAsDataURL(file); - }); - const handleSubmit = async () => { - if (files.length === 0 || uploadPolicyImages.status === 'executing') return; + if (files.length === 0 || isLoading) return; try { - const images = await Promise.all( - files.map(async ({ file }) => ({ - fileName: file.name, - fileType: file.type, - fileData: await fileToBase64(file), - })), - ); - - await uploadPolicyImages.execute({ - policyId, - images, + setIsLoading(true); + + const formData = new FormData(); + formData.append('policyId', String(policy.id)); + formData.append('policyName', policy.name); + + files.forEach(({ file }) => { + formData.append('files', file, file.name); }); + const response = await fetch('/api/confirm-fleet-policy', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || 'Failed to upload policy images'); + } + + toast.success('Policy images uploaded successfully'); handleClose(false); } catch (error) { console.error('Failed to upload policy images', error); - toast.error('Failed to upload policy images'); + const message = error instanceof Error ? error.message : 'Failed to upload policy images'; + toast.error(message); + } finally { + setIsLoading(false); } }; - const isLoading = uploadPolicyImages.status === 'executing'; - return ( ); @@ -97,3 +99,30 @@ const getFleetPolicies = async ( return { fleetPolicies: [], device: null }; } }; + +const getFleetPolicyResults = async (member: Member): Promise => { + if (!member || !member.organizationId) { + return []; + } + try { + const portalBase = process.env.NEXT_PUBLIC_BETTER_AUTH_URL?.replace(/\/$/, ''); + const url = `${portalBase}/api/fleet-policy`; + + const res = await fetch(url, { + method: 'GET', + headers: await headers(), + cache: 'no-store', + }); + + if (!res.ok) { + console.error('Failed to fetch fleet policy results', res.status, await res.text()); + return []; + } + + const json = (await res.json()) as { success?: boolean; data?: FleetPolicyResult[] }; + return json.data ?? []; + } catch (error) { + console.error('Error fetching fleet policy results', error); + return []; + } +}; diff --git a/apps/portal/src/app/(app)/(home)/actions/uploadPolicyImages.ts b/apps/portal/src/app/(app)/(home)/actions/uploadPolicyImages.ts deleted file mode 100644 index 5fe594ab2..000000000 --- a/apps/portal/src/app/(app)/(home)/actions/uploadPolicyImages.ts +++ /dev/null @@ -1,83 +0,0 @@ -'use server'; - -import { authActionClient } from '@/actions/safe-action'; -import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/utils/s3'; -import { PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { z } from 'zod'; - -const uploadPolicyImagesSchema = z.object({ - policyId: z.string(), - images: z - .array( - z.object({ - fileName: z.string(), - fileType: z.string(), - fileData: z.string(), // base64 encoded - }), - ) - .min(1, 'At least one image is required'), -}); - -export const uploadPolicyImagesAction = authActionClient - .inputSchema(uploadPolicyImagesSchema) - .metadata({ - name: 'upload-policy-images', - track: { - event: 'upload-policy-images', - channel: 'server', - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { policyId, images } = parsedInput; - const organizationId = ctx.session.activeOrganizationId; - - if (!organizationId) { - throw new Error('No active organization'); - } - - if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) { - throw new Error('File upload service is not available'); - } - - const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB per image - const uploads: Array<{ fileName: string; key: string; url: string }> = []; - - for (const image of images) { - const { fileName, fileType, fileData } = image; - - if (!fileType.startsWith('image/')) { - throw new Error(`Only image files are allowed (${fileName})`); - } - - const fileBuffer = Buffer.from(fileData, 'base64'); - if (fileBuffer.length > MAX_FILE_SIZE_BYTES) { - throw new Error(`Image ${fileName} must be less than 5MB`); - } - - const timestamp = Date.now(); - const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); - const key = `${organizationId}/fleet-policies/${policyId}/${timestamp}-${sanitizedFileName}`; - - const putCommand = new PutObjectCommand({ - Bucket: APP_AWS_ORG_ASSETS_BUCKET, - Key: key, - Body: fileBuffer, - ContentType: fileType, - }); - await s3Client.send(putCommand); - - const getCommand = new GetObjectCommand({ - Bucket: APP_AWS_ORG_ASSETS_BUCKET, - Key: key, - }); - const signedUrl = await getSignedUrl(s3Client, getCommand, { expiresIn: 3600 }); - - uploads.push({ fileName, key, url: signedUrl }); - } - - return { - success: true, - uploads, - }; - }); diff --git a/apps/portal/src/app/api/confirm-fleet-policy/route.ts b/apps/portal/src/app/api/confirm-fleet-policy/route.ts new file mode 100644 index 000000000..0efebdb00 --- /dev/null +++ b/apps/portal/src/app/api/confirm-fleet-policy/route.ts @@ -0,0 +1,110 @@ +import { auth } from '@/app/lib/auth'; +import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/utils/s3'; +import { PutObjectCommand } from '@aws-sdk/client-s3'; +import { db } from '@db'; +import { Buffer } from 'node:buffer'; +import { type NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 60; + +const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB per image + +const sanitizeFileName = (name: string) => name.replace(/[^a-zA-Z0-9.-]/g, '_'); + +export async function POST(req: NextRequest) { + const session = await auth.api.getSession({ headers: req.headers }); + + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const formData = await req.formData(); + const policyIdValue = formData.get('policyId'); + const policyName = formData.get('policyName'); + const files = formData.getAll('files'); + + const policyId = typeof policyIdValue === 'string' ? Number(policyIdValue) : null; + const organizationId = session.session?.activeOrganizationId; + const userId = session.user.id; + + if (!organizationId) { + return NextResponse.json({ error: 'No active organization' }, { status: 400 }); + } + + if (!policyId || Number.isNaN(policyId)) { + return NextResponse.json({ error: 'Invalid policyId' }, { status: 400 }); + } + + if (typeof policyName !== 'string' || policyName.trim().length === 0) { + return NextResponse.json({ error: 'Invalid policyName' }, { status: 400 }); + } + + if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) { + return NextResponse.json({ error: 'File upload service is not available' }, { status: 500 }); + } + + const uploads: Array<{ fileName: string; key: string }> = []; + + for (const fileEntry of files) { + if (!(fileEntry instanceof File)) continue; + + if (!fileEntry.type.startsWith('image/')) { + return NextResponse.json({ error: `Only image files are allowed (${fileEntry.name})` }, { status: 400 }); + } + + const arrayBuffer = await fileEntry.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + if (buffer.length > MAX_FILE_SIZE_BYTES) { + return NextResponse.json({ error: `Image ${fileEntry.name} must be less than 5MB` }, { status: 400 }); + } + + const timestamp = Date.now(); + const sanitized = sanitizeFileName(fileEntry.name); + const key = `${organizationId}/fleet-policies/${policyId}/${timestamp}-${sanitized}`; + + const putCommand = new PutObjectCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Key: key, + Body: buffer, + ContentType: fileEntry.type, + }); + + await s3Client.send(putCommand); + uploads.push({ fileName: fileEntry.name, key }); + } + + const existing = await db.fleetPolicyResult.findFirst({ + where: { + userId, + organizationId, + fleetPolicyId: policyId, + }, + }); + + if (existing) { + await db.fleetPolicyResult.update({ + where: { id: existing.id }, + data: { + attachments: uploads.map((upload) => upload.key), + fleetPolicyResponse: 'pass', + fleetPolicyName: policyName, + }, + }); + } else { + await db.fleetPolicyResult.create({ + data: { + userId, + organizationId, + fleetPolicyId: policyId, + fleetPolicyName: policyName, + fleetPolicyResponse: 'pass', + attachments: uploads.map((upload) => upload.key), + }, + }); + } + + return NextResponse.json({ success: true, uploads }); +} diff --git a/apps/portal/src/app/api/fleet-policy/route.ts b/apps/portal/src/app/api/fleet-policy/route.ts new file mode 100644 index 000000000..472716b93 --- /dev/null +++ b/apps/portal/src/app/api/fleet-policy/route.ts @@ -0,0 +1,57 @@ +import { auth } from '@/app/lib/auth'; +import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/utils/s3'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { db } from '@db'; +import { NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(req: NextRequest) { + const session = await auth.api.getSession({ headers: req.headers }); + + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const organizationId = session.session?.activeOrganizationId; + + if (!organizationId) { + return NextResponse.json({ error: 'No active organization' }, { status: 400 }); + } + + const results = await db.fleetPolicyResult.findMany({ + where: { organizationId, userId: session.user.id }, + orderBy: { createdAt: 'desc' }, + }); + + if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) { + return NextResponse.json({ success: true, data: results }); + } + + const withSignedUrls = await Promise.all( + results.map(async (result) => { + const signedAttachments = await Promise.all( + result.attachments.map(async (key) => { + try { + const command = new GetObjectCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Key: key, + }); + return await getSignedUrl(s3Client, command, { expiresIn: 3600 }); + } catch { + return key; + } + }), + ); + + return { + ...result, + attachments: signedAttachments, + }; + }), + ); + + return NextResponse.json({ success: true, data: withSignedUrls }); +} From 4bb6228bdf75204e142b2daebf1026a50f3ae217 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 19 Jan 2026 14:36:42 -0500 Subject: [PATCH 05/25] feat(app): mark device policy passed if the screenshots are manually uploaded by employee --- .../devices/components/CarouselControls.tsx | 46 ++++++++++++++ .../people/devices/components/HostDetails.tsx | 54 +++------------- .../devices/components/PolicyImagePreview.tsx | 42 +++++++++++++ .../components/PolicyImagePreviewModal.tsx | 63 +++++++++++++++++++ .../people/devices/components/PolicyItem.tsx | 57 +++++++++++++++++ .../[orgId]/people/devices/data/index.ts | 19 +++++- .../[orgId]/people/devices/types/index.ts | 25 ++++---- apps/app/src/app/api/get-image-url/route.ts | 31 +++++++++ 8 files changed, 277 insertions(+), 60 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/people/devices/components/CarouselControls.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreview.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreviewModal.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyItem.tsx create mode 100644 apps/app/src/app/api/get-image-url/route.ts diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/CarouselControls.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/CarouselControls.tsx new file mode 100644 index 000000000..dd5660fec --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/CarouselControls.tsx @@ -0,0 +1,46 @@ +import { Button } from '@comp/ui/button'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +interface CarouselControlsProps { + currentIndex: number; + total: number; + onPrevious: () => void; + onNext?: () => void; +} + +export function CarouselControls({ + currentIndex, + total, + onPrevious, + onNext, +}: CarouselControlsProps) { + const isFirstVideo = currentIndex === 0; + + return ( +
+ + +
+ {currentIndex + 1} of {total} +
+ + +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx index d1786cbdf..00e5ababa 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx @@ -1,18 +1,18 @@ import { Button } from '@comp/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; -import { cn } from '@comp/ui/cn'; -import { ArrowLeft, CheckCircle2, XCircle } from 'lucide-react'; +import { ArrowLeft } from 'lucide-react'; import { useMemo } from 'react'; -import type { Host } from '../types'; +import type { FleetPolicy, Host } from '../types'; +import { PolicyItem } from './PolicyItem'; export const HostDetails = ({ host, onClose }: { host: Host; onClose: () => void }) => { const isMacOS = useMemo(() => { return host.cpu_type && (host.cpu_type.includes('arm64') || host.cpu_type.includes('intel')); }, [host]); - const mdmEnabledStatus = useMemo(() => { + const mdmEnabledStatus = useMemo(() => { return { - id: 'mdm', + id: 9999, response: host?.mdm.connected_to_fleet ? 'pass' : 'fail', name: 'MDM Enabled', }; @@ -32,49 +32,9 @@ export const HostDetails = ({ host, onClose }: { host: Host; onClose: () => void {host.policies.length > 0 ? ( <> {host.policies.map((policy) => ( -
-

{policy.name}

- {policy.response === 'pass' ? ( -
- - Pass -
- ) : ( -
- - Fail -
- )} -
+ ))} - {isMacOS && ( -
-

{mdmEnabledStatus.name}

- {mdmEnabledStatus.response === 'pass' ? ( -
- - Pass -
- ) : ( -
- - Fail -
- )} -
- )} + {isMacOS && } ) : (

No policies found for this device.

diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreview.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreview.tsx new file mode 100644 index 000000000..22b90b4a3 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreview.tsx @@ -0,0 +1,42 @@ +import useSWR from 'swr'; +import Image from 'next/image'; + +const fetcher = async (key: string) => { + const res = await fetch(key, { cache: 'no-store' }); + if (!res.ok) { + throw new Error('Failed to fetch image url'); + } + const json = (await res.json()) as { url?: string }; + if (!json.url) { + throw new Error('No url returned'); + } + return json.url; +}; + +export function PolicyImagePreview({ image }: { image: string }) { + const { data: signedUrl, error, isLoading } = useSWR( + () => (image ? `/api/get-image-url?key=${encodeURIComponent(image)}` : null), + fetcher, + ); + + if (isLoading) { + return
Loading image...
; + } + + if (error || !signedUrl) { + return
Failed to load image
; + } + + return ( +
+ Policy image +
+ ); +} \ No newline at end of file diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreviewModal.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreviewModal.tsx new file mode 100644 index 000000000..b9ce838f7 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreviewModal.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@comp/ui/dialog'; +import { CarouselControls } from './CarouselControls'; +import { PolicyImagePreview } from './PolicyImagePreview'; + +interface PolicyImagePreviewModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + images: string[]; +} + +export function PolicyImagePreviewModal({ open, images, onOpenChange }: PolicyImagePreviewModalProps) { + const [currentIndex, setCurrentIndex] = useState(0); + + useEffect(() => { + if (open) { + setCurrentIndex(0); + } + }, [open, images.length]); + + const hasImages = images.length > 0; + + const goPrevious = () => { + setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1)); + }; + + const goNext = () => { + setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1)); + }; + + return ( + + + + Preview + +
+ {!hasImages &&

No images to display.

} + + {hasImages && ( + <> + + + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyItem.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyItem.tsx new file mode 100644 index 000000000..c505b1571 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyItem.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { cn } from '@comp/ui/cn'; +import { CheckCircle2, Image as ImageIcon,XCircle } from 'lucide-react'; +import { useState } from 'react'; + +import { FleetPolicy } from "../types"; +import { Button } from '@comp/ui/button'; +import { PolicyImagePreviewModal } from './PolicyImagePreviewModal'; + +interface PolicyItemProps { + policy: FleetPolicy; +} + +export const PolicyItem = ({ policy }: PolicyItemProps) => { + const [isPreviewOpen, setIsPreviewOpen] = useState(false); + + return ( +
+

{policy.name}

+
+ {(policy?.attachments || []).length > 0 && ( + + )} + {policy.response === 'pass' ? ( +
+ + Pass +
+ ) : ( +
+ + Fail +
+ )} +
+ +
+ ); +}; \ No newline at end of file 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 965348591..31aa17f6e 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 @@ -39,5 +39,22 @@ export const getEmployeeDevices: () => Promise = async () => { // Get all devices by id. in parallel const devices = await Promise.all(allIds.map((id: number) => fleet.get(`/hosts/${id}`))); - return devices.map((device: { data: { host: Host } }) => device.data.host); + const results = await db.fleetPolicyResult.findMany({ + where: { organizationId, userId: session.user.id }, + orderBy: { createdAt: 'desc' }, + }); + + return devices.map((device: { data: { host: Host } }) => { + return { + ...device.data.host, + policies: device.data.host.policies.map((policy) => { + const policyResult = results.find((result) => result.fleetPolicyId === policy.id); + return { + ...policy, + response: policy.response === 'pass' || policyResult?.fleetPolicyResponse === 'pass' ? 'pass' : 'fail', + attachments: policyResult?.attachments || [], + }; + }), + }; + }); }; diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts b/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts index 8c8f8f3a3..b260fe000 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts @@ -1,19 +1,20 @@ export interface FleetPolicy { id: number; name: string; - query: string; - critical: boolean; - description: string; - author_id: number; - author_name: string; - author_email: string; - team_id: number | null; - resolution: string; - platform: string; - calendar_events_enabled: boolean; - created_at: string; // ISO date-time string - updated_at: string; // ISO date-time string + query?: string; + critical?: boolean; + description?: string; + author_id?: number; + author_name?: string; + author_email?: string; + team_id?: number | null; + resolution?: string; + platform?: string; + calendar_events_enabled?: boolean; + created_at?: string; // ISO date-time string + updated_at?: string; // ISO date-time string response: string; + attachments?: string[]; } export interface Host { diff --git a/apps/app/src/app/api/get-image-url/route.ts b/apps/app/src/app/api/get-image-url/route.ts new file mode 100644 index 000000000..69d189998 --- /dev/null +++ b/apps/app/src/app/api/get-image-url/route.ts @@ -0,0 +1,31 @@ +'use server'; + +import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/app/s3'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(req: NextRequest) { + const key = req.nextUrl.searchParams.get('key'); + + if (!key) { + return NextResponse.json({ error: 'Missing key' }, { status: 400 }); + } + + if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) { + return NextResponse.json({ error: 'File service unavailable' }, { status: 500 }); + } + + try { + const command = new GetObjectCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Key: key, + }); + const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 }); + + return NextResponse.json({ url }); + } catch (error) { + console.error('Failed to generate signed URL', error); + return NextResponse.json({ error: 'Failed to generate URL' }, { status: 500 }); + } +} From 776ad112edd9beea734bbffa16bb00c7a23cd70d Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 19 Jan 2026 14:47:02 -0500 Subject: [PATCH 06/25] fix(portal): refresh the page after uploading policy images --- .../(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx index cfc451666..01c5f9bd2 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx @@ -12,6 +12,7 @@ import { } from '@comp/ui/dialog'; import { ImagePlus, Trash2, Loader2 } from 'lucide-react'; import Image from 'next/image'; +import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; import { FleetPolicy } from '../../types'; @@ -25,6 +26,7 @@ export function PolicyImageUploadModal({ open, onOpenChange, policy }: PolicyIma const fileInputRef = useRef(null); const [files, setFiles] = useState>([]); const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); const handleFileChange = (e: React.ChangeEvent) => { const selected = Array.from(e.target.files ?? []); @@ -80,6 +82,7 @@ export function PolicyImageUploadModal({ open, onOpenChange, policy }: PolicyIma toast.success('Policy images uploaded successfully'); handleClose(false); + router.refresh(); } catch (error) { console.error('Failed to upload policy images', error); const message = error instanceof Error ? error.message : 'Failed to upload policy images'; From 685dfe8ba33dfc135367642eca797c7d2dd533a5 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 19 Jan 2026 14:47:30 -0500 Subject: [PATCH 07/25] fix(portal): fix border color issue on device policy item --- .../(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx index d04478d22..826fa4eba 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx @@ -24,7 +24,7 @@ export function FleetPolicyItem({ policy, policyResult }: FleetPolicyItemProps)

{policy.name}

From 84903c047133e1bc775e023603a8af19f514be1f Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 19 Jan 2026 15:14:23 -0500 Subject: [PATCH 08/25] feat(portal): enable to upload images for MDM Enabled policy --- .../tasks/DeviceAgentAccordionItem.tsx | 6 +- .../components/tasks/MDMPolicyItem.tsx | 80 ++++++++++++++----- .../app/(app)/(home)/[orgId]/types/index.ts | 24 +++--- 3 files changed, 77 insertions(+), 33 deletions(-) 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 0cc4a0e17..a96ece872 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 @@ -40,9 +40,9 @@ export function DeviceAgentAccordionItem({ [detectedOS], ); - const mdmEnabledStatus = useMemo(() => { + const mdmEnabledStatus = useMemo(() => { return { - id: 'mdm', + id: 9999, response: host?.mdm.connected_to_fleet ? 'pass' : 'fail', name: 'MDM Enabled', }; @@ -258,7 +258,7 @@ export function DeviceAgentAccordionItem({ {fleetPolicies.map((policy) => ( ))} - {isMacOS && } + {isMacOS && } ) : (

diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/MDMPolicyItem.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/MDMPolicyItem.tsx index 0b66c0142..a7ce98a77 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/MDMPolicyItem.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/MDMPolicyItem.tsx @@ -2,17 +2,25 @@ import { cn } from '@comp/ui/cn'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@comp/ui/tooltip'; -import { CheckCircle2, HelpCircle, XCircle } from 'lucide-react'; -import type { Host } from '../../types'; +import { CheckCircle2, HelpCircle, Image, Upload, XCircle } from 'lucide-react'; +import type { FleetPolicy, Host } from '../../types'; +import { FleetPolicyResult } from '@db'; +import { Button } from '@comp/ui/button'; +import { useState } from 'react'; +import { PolicyImageUploadModal } from './PolicyImageUploadModal'; +import { PolicyImagePreviewModal } from './PolicyImagePreviewModal'; interface MDMPolicyItemProps { - host: Host | null; + policy: FleetPolicy; + policyResult?: FleetPolicyResult; } -export function MDMPolicyItem({ host }: MDMPolicyItemProps) { - const status = host?.mdm.connected_to_fleet ? 'pass' : 'fail'; +export function MDMPolicyItem({ policy, policyResult }: MDMPolicyItemProps) { + const status = policy.response === 'pass' || policyResult?.fleetPolicyResponse === 'pass' ? 'pass' : 'fail'; const name = 'MDM Enabled'; - const hostId = host?.id ?? null; + + const [isUploadOpen, setIsUploadOpen] = useState(false); + const [isPreviewOpen, setIsPreviewOpen] = useState(false); return (

{name}

- {status === 'fail' && hostId && ( + {status === 'fail' && ( @@ -49,17 +57,53 @@ export function MDMPolicyItem({ host }: MDMPolicyItemProps) { )}
- {status === 'pass' ? ( -
- - Pass -
- ) : ( -
- - Fail -
- )} +
+ {status === 'pass' ? ( + <> + {(policyResult?.attachments || []).length > 0 && ( + + )} +
+ + Pass +
+ + ) : ( + <> + +
+ + Fail +
+ + )} +
+ +
); } \ No newline at end of file diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts b/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts index c132d164f..7e73de606 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts +++ b/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts @@ -1,18 +1,18 @@ export interface FleetPolicy { id: number; name: string; - query: string; - critical: boolean; - description: string; - author_id: number; - author_name: string; - author_email: string; - team_id: number | null; - resolution: string; - platform: string; - calendar_events_enabled: boolean; - created_at: string; // ISO date-time string - updated_at: string; // ISO date-time string + query?: string; + critical?: boolean; + description?: string; + author_id?: number; + author_name?: string; + author_email?: string; + team_id?: number | null; + resolution?: string; + platform?: string; + calendar_events_enabled?: boolean; + created_at?: string; // ISO date-time string + updated_at?: string; // ISO date-time string response: string; } From 248c3c5ccf493b19bb44ea083cb44f207b57e1c8 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 19 Jan 2026 15:15:45 -0500 Subject: [PATCH 09/25] fix(app): show images for device policy - MDM Enabled --- .../people/devices/components/HostDetails.tsx | 16 +--------------- .../(app)/[orgId]/people/devices/data/index.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx index 00e5ababa..bfd8b648e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx @@ -1,23 +1,10 @@ import { Button } from '@comp/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; import { ArrowLeft } from 'lucide-react'; -import { useMemo } from 'react'; -import type { FleetPolicy, Host } from '../types'; +import type { Host } from '../types'; import { PolicyItem } from './PolicyItem'; export const HostDetails = ({ host, onClose }: { host: Host; onClose: () => void }) => { - const isMacOS = useMemo(() => { - return host.cpu_type && (host.cpu_type.includes('arm64') || host.cpu_type.includes('intel')); - }, [host]); - - const mdmEnabledStatus = useMemo(() => { - return { - id: 9999, - response: host?.mdm.connected_to_fleet ? 'pass' : 'fail', - name: 'MDM Enabled', - }; - }, [host]); - return (
+ + +

+ There are additional steps required to enable MDM. Please check{' '} + + this documentation + + . +

+
+ + + )} +
- {policy.response === 'pass' || policyResult?.fleetPolicyResponse === 'pass' ? ( + {policy.response === 'pass' ? ( <> - {(policyResult?.attachments || []).length > 0 && ( + {(policy?.attachments || []).length > 0 && ( - - -

- There are additional steps required to enable MDM. Please check{' '} - - this documentation - - . -

-
- - - )} -
-
- {status === 'pass' ? ( - <> - {(policyResult?.attachments || []).length > 0 && ( - - )} -
- - Pass -
- - ) : ( - <> - -
- - Fail -
- - )} -
- - -
- ); -} \ No newline at end of file diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx index bed5ea3f7..19088277a 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx @@ -51,7 +51,6 @@ export default async function OrganizationPage({ params }: { params: Promise<{ o // Fleet policies - already has graceful error handling in getFleetPolicies const fleetData = await getFleetPolicies(member); - const fleetPolicyResults = await getFleetPolicyResults(member); return ( ); @@ -86,9 +84,31 @@ const getFleetPolicies = async ( return { fleetPolicies: [], device: null }; } + const isMacOS = device.cpu_type && (device.cpu_type.includes('arm64') || device.cpu_type.includes('intel')); + const mdmEnabledStatus = { + id: 9999, + response: device.mdm.connected_to_fleet ? 'pass' : 'fail', + name: 'MDM Enabled', + }; const deviceWithPolicies = await fleet.get(`/hosts/${device.id}`); - const fleetPolicies: FleetPolicy[] = deviceWithPolicies.data.host.policies || []; - return { fleetPolicies, device }; + const fleetPolicies: FleetPolicy[] = [ + ...(deviceWithPolicies.data.host.policies || []), + ...(isMacOS ? [mdmEnabledStatus] : []), + ]; + + // Get Policy Results from the database. + const fleetPolicyResults = await getFleetPolicyResults(); + return { + device, + fleetPolicies: fleetPolicies.map((policy) => { + const policyResult = fleetPolicyResults.find((result) => result.fleetPolicyId === policy.id); + return { + ...policy, + response: policy.response === 'pass' || policyResult?.fleetPolicyResponse === 'pass' ? 'pass' : 'fail', + attachments: policyResult?.attachments || [], + } + }), + }; } catch (error: any) { // Log more details about the error if (error.response?.status === 404) { @@ -100,10 +120,7 @@ const getFleetPolicies = async ( } }; -const getFleetPolicyResults = async (member: Member): Promise => { - if (!member || !member.organizationId) { - return []; - } +const getFleetPolicyResults = async (): Promise => { try { const portalBase = process.env.NEXT_PUBLIC_BETTER_AUTH_URL?.replace(/\/$/, ''); const url = `${portalBase}/api/fleet-policy`; diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts b/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts index 7e73de606..658359065 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts +++ b/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts @@ -14,6 +14,7 @@ export interface FleetPolicy { created_at?: string; // ISO date-time string updated_at?: string; // ISO date-time string response: string; + attachments?: string[]; } export interface Host { From 4d767a6add4e28693768c4586417e09cc0b80c01 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 19 Jan 2026 20:39:45 -0500 Subject: [PATCH 13/25] fix(portal): make device policy passed only when uploading 1+ files --- apps/portal/src/app/api/confirm-fleet-policy/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/portal/src/app/api/confirm-fleet-policy/route.ts b/apps/portal/src/app/api/confirm-fleet-policy/route.ts index 0efebdb00..f15acdeb6 100644 --- a/apps/portal/src/app/api/confirm-fleet-policy/route.ts +++ b/apps/portal/src/app/api/confirm-fleet-policy/route.ts @@ -89,7 +89,7 @@ export async function POST(req: NextRequest) { where: { id: existing.id }, data: { attachments: uploads.map((upload) => upload.key), - fleetPolicyResponse: 'pass', + fleetPolicyResponse: uploads.length > 0 ? 'pass' : 'fail', fleetPolicyName: policyName, }, }); @@ -100,7 +100,7 @@ export async function POST(req: NextRequest) { organizationId, fleetPolicyId: policyId, fleetPolicyName: policyName, - fleetPolicyResponse: 'pass', + fleetPolicyResponse: uploads.length > 0 ? 'pass' : 'fail', attachments: uploads.map((upload) => upload.key), }, }); From 82d074d14b8e165c85c89850d036ca6861d8c893 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 19 Jan 2026 20:46:32 -0500 Subject: [PATCH 14/25] fix(portal): remove unused dependency from variable --- .../[orgId]/components/tasks/DeviceAgentAccordionItem.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 571719d57..eeadee68c 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 @@ -38,11 +38,7 @@ export function DeviceAgentAccordionItem({ ); const hasInstalledAgent = host !== null; - const failedPoliciesCount = useMemo(() => { - return ( - fleetPolicies.filter((policy) => policy.response !== 'pass').length - ); - }, [fleetPolicies, isMacOS]); + const failedPoliciesCount = useMemo(() => fleetPolicies.filter((policy) => policy.response !== 'pass').length, [fleetPolicies]); const isCompleted = hasInstalledAgent && failedPoliciesCount === 0; From 379506a57d0c6cd93f50806dd9d969d193220352 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 19 Jan 2026 20:47:02 -0500 Subject: [PATCH 15/25] fix(portal): make transh icon disabled while uploading images for device policy --- .../(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx index 01c5f9bd2..32248eadf 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx @@ -151,6 +151,7 @@ export function PolicyImageUploadModal({ open, onOpenChange, policy }: PolicyIma variant="ghost" size="icon" className="text-red-500 hover:text-red-600" + disabled={isLoading} onClick={() => onRemoveFile(item)} > From 0428a77f5d1043c430c6b5b598bd170e5caa733f Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 19 Jan 2026 20:58:03 -0500 Subject: [PATCH 16/25] fix(app): add org authorization check in get-image-url endpoint --- apps/app/src/app/api/get-image-url/route.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/app/src/app/api/get-image-url/route.ts b/apps/app/src/app/api/get-image-url/route.ts index 85f216f2c..891d7ea1b 100644 --- a/apps/app/src/app/api/get-image-url/route.ts +++ b/apps/app/src/app/api/get-image-url/route.ts @@ -13,12 +13,23 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + const organizationId = session.session?.activeOrganizationId; + if (!organizationId) { + return NextResponse.json({ error: 'No active organization' }, { status: 400 }); + } + const key = req.nextUrl.searchParams.get('key'); if (!key) { return NextResponse.json({ error: 'Missing key' }, { status: 400 }); } + // Enforce that the requested key belongs to the caller's organization + const orgPrefix = `${organizationId}/`; + if (!key.startsWith(orgPrefix)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) { return NextResponse.json({ error: 'File service unavailable' }, { status: 500 }); } From 81ab24c4a8d0efb1b226e78a7485fbd11743bdd1 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 19 Jan 2026 21:07:32 -0500 Subject: [PATCH 17/25] fix(app): use negative value as MDM Enabled policy id --- apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 d2cd07b63..f7620af6c 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 @@ -6,6 +6,8 @@ import { db } from '@db'; import { headers } from 'next/headers'; import type { Host } from '../types'; +const MDM_POLICY_ID = -9999; + export const getEmployeeDevices: () => Promise = async () => { const session = await auth.api.getSession({ headers: await headers(), @@ -59,7 +61,7 @@ export const getEmployeeDevices: () => Promise = async () => { ...host, policies: [ ...host.policies, - ...(isMacOS ? [{ id: 9999, name: 'MDM Enabled', response: host.mdm.connected_to_fleet ? 'pass' : 'fail' }] : []), + ...(isMacOS ? [{ id: MDM_POLICY_ID, name: 'MDM Enabled', response: host.mdm.connected_to_fleet ? 'pass' : 'fail' }] : []), ].map((policy) => { const policyResult = results.find((result) => result.fleetPolicyId === policy.id && result.userId === userIds[index]); return { From befa85c70056d069b5c5372297cd00d08520e9e8 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 19 Jan 2026 21:07:44 -0500 Subject: [PATCH 18/25] fix(portal): use negative value as MDM Enabled policy id --- apps/portal/src/app/(app)/(home)/[orgId]/page.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx index 19088277a..2a2acb83b 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx @@ -9,6 +9,8 @@ import { redirect } from 'next/navigation'; import { OrganizationDashboard } from './components/OrganizationDashboard'; import type { FleetPolicy, Host } from './types'; +const MDM_POLICY_ID = -9999; + export default async function OrganizationPage({ params }: { params: Promise<{ orgId: string }> }) { const { orgId } = await params; @@ -86,7 +88,7 @@ const getFleetPolicies = async ( const isMacOS = device.cpu_type && (device.cpu_type.includes('arm64') || device.cpu_type.includes('intel')); const mdmEnabledStatus = { - id: 9999, + id: MDM_POLICY_ID, response: device.mdm.connected_to_fleet ? 'pass' : 'fail', name: 'MDM Enabled', }; From dd1e5ed4cd1929eef6e1966c61f913aa1bf420df Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 19 Jan 2026 21:12:07 -0500 Subject: [PATCH 19/25] fix(portal): add bounds guard in PolicyImagePreviewModal --- .../(home)/[orgId]/components/tasks/PolicyImagePreviewModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImagePreviewModal.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImagePreviewModal.tsx index b647afe03..4d53de2f4 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImagePreviewModal.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImagePreviewModal.tsx @@ -46,7 +46,7 @@ export function PolicyImagePreviewModal({ open, images, onOpenChange }: PolicyIm
{!hasImages &&

No images to display.

} - {hasImages && ( + {hasImages && currentIndex >= 0 && currentIndex < images.length && ( <>
Date: Mon, 19 Jan 2026 21:24:45 -0500 Subject: [PATCH 20/25] fix(portal): cancel button bypasses file cleanup causing memory leak --- .../(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx index 32248eadf..1b577eaf4 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx @@ -182,7 +182,7 @@ export function PolicyImageUploadModal({ open, onOpenChange, policy }: PolicyIma )} -
{policy.response === 'pass' ? ( - <> - {(policy?.attachments || []).length > 0 && ( - - )} -
- - Pass -
- +
+ + Pass +
) : ( - <> +
+ + Fail +
+ )} + + -
- - Fail -
- - )} +
+ + {actions.map(({ label, renderIcon, onClick }) => ( + { + event.preventDefault(); + onClick(); + setDropdownOpen(false); + }} + > + {renderIcon()} + {label} + + ))} + +
Date: Tue, 20 Jan 2026 21:10:59 -0500 Subject: [PATCH 23/25] fix(app): move preview button under dropdown menu on device policy item --- .../people/devices/components/HostDetails.tsx | 2 +- .../people/devices/components/PolicyItem.tsx | 63 +++++++++++++++---- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx index bfd8b648e..bba2bf098 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx @@ -11,7 +11,7 @@ export const HostDetails = ({ host, onClose }: { host: Host; onClose: () => void Back - + {host.computer_name}'s Policies diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyItem.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyItem.tsx index 7f9cff8c2..b189b4170 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyItem.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyItem.tsx @@ -1,8 +1,14 @@ 'use client'; import { cn } from '@comp/ui/cn'; -import { CheckCircle2, Image as ImageIcon,XCircle } from 'lucide-react'; -import { useState } from 'react'; +import { CheckCircle2, Image as ImageIcon, MoreVertical, XCircle } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@comp/ui/dropdown-menu'; import { FleetPolicy } from "../types"; import { Button } from '@comp/ui/button'; @@ -14,6 +20,21 @@ interface PolicyItemProps { export const PolicyItem = ({ policy }: PolicyItemProps) => { const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + + const actions = useMemo(() => { + if ((policy?.attachments || []).length > 0 && policy.response === 'pass') { + return [ + { + label: 'Preview images', + renderIcon: () => , + onClick: () => setIsPreviewOpen(true), + }, + ]; + } + + return []; + }, [policy]); return (
{ >

{policy.name}

- {(policy?.attachments || []).length > 0 && policy.response === 'pass' && ( - - )} {policy.response === 'pass' ? (
@@ -46,6 +56,33 @@ export const PolicyItem = ({ policy }: PolicyItemProps) => { Fail
)} + + + + + + {actions.map(({ label, renderIcon, onClick }) => ( + { + event.preventDefault(); + onClick(); + setDropdownOpen(false); + }} + > + {renderIcon()} + {label} + + ))} + +
Date: Wed, 21 Jan 2026 09:37:36 -0500 Subject: [PATCH 24/25] fix(app): remove 'use server' in get-image-url endpoint --- apps/app/src/app/api/get-image-url/route.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/app/src/app/api/get-image-url/route.ts b/apps/app/src/app/api/get-image-url/route.ts index 891d7ea1b..0bfb72594 100644 --- a/apps/app/src/app/api/get-image-url/route.ts +++ b/apps/app/src/app/api/get-image-url/route.ts @@ -1,5 +1,3 @@ -'use server'; - import { auth } from '@/utils/auth'; import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/app/s3'; import { GetObjectCommand } from '@aws-sdk/client-s3'; From 5f4ed163a484a307ef1e71588c41618cbcd0e636 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 21 Jan 2026 09:45:36 -0500 Subject: [PATCH 25/25] fix(app): missing array bounds check in device policy image preview modal --- .../people/devices/components/PolicyImagePreviewModal.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreviewModal.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreviewModal.tsx index b9ce838f7..974fad2f8 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreviewModal.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreviewModal.tsx @@ -27,6 +27,7 @@ export function PolicyImagePreviewModal({ open, images, onOpenChange }: PolicyIm }, [open, images.length]); const hasImages = images.length > 0; + const hasValidImage = hasImages && currentIndex >= 0 && currentIndex < images.length; const goPrevious = () => { setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1)); @@ -45,7 +46,7 @@ export function PolicyImagePreviewModal({ open, images, onOpenChange }: PolicyIm
{!hasImages &&

No images to display.

} - {hasImages && ( + {hasValidImage && ( <>