From 812186caf0ec1934a2cb7b06b6da05b4b40401df Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 2 Feb 2026 20:46:16 -0500 Subject: [PATCH 1/6] feat(portal): add a way to remove screenshots on portal --- .../tasks/DeviceAgentAccordionItem.tsx | 7 +- .../components/tasks/FleetPolicyItem.tsx | 20 ++++- .../tasks/PolicyImageResetModal.tsx | 77 +++++++++++++++++++ .../tasks/PolicyImageUploadModal.tsx | 13 ++-- apps/portal/src/app/api/fleet-policy/route.ts | 38 +++++++++ 5 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageResetModal.tsx 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 d9975c867..70b141776 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 @@ -253,7 +253,12 @@ export function DeviceAgentAccordionItem({ {fleetPolicies.length > 0 ? ( <> {fleetPolicies.map((policy) => ( - + ))} ) : ( 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 fb97c810f..1f2b5f93f 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 @@ -5,7 +5,7 @@ import { useMemo, useState } from 'react'; import { Button } from '@comp/ui/button'; import { cn } from '@comp/ui/cn'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@comp/ui/tooltip'; -import { CheckCircle2, HelpCircle, Image, MoreVertical, Upload, XCircle } from 'lucide-react'; +import { CheckCircle2, HelpCircle, Image, MoreVertical, Trash, Upload, XCircle } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, @@ -15,15 +15,18 @@ import { import type { FleetPolicy } from '../../types'; import { PolicyImageUploadModal } from './PolicyImageUploadModal'; import { PolicyImagePreviewModal } from './PolicyImagePreviewModal'; +import { PolicyImageResetModal } from './PolicyImageResetModal'; interface FleetPolicyItemProps { policy: FleetPolicy; + organizationId: string; onRefresh: () => void; } -export function FleetPolicyItem({ policy, onRefresh }: FleetPolicyItemProps) { +export function FleetPolicyItem({ policy, organizationId, onRefresh }: FleetPolicyItemProps) { const [isUploadOpen, setIsUploadOpen] = useState(false); const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [isRemoveOpen, setIsRemoveOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); const actions = useMemo(() => { @@ -35,6 +38,11 @@ export function FleetPolicyItem({ policy, onRefresh }: FleetPolicyItemProps) { renderIcon: () => , onClick: () => setIsPreviewOpen(true), }, + { + label: 'Remove images', + renderIcon: () => , + onClick: () => setIsRemoveOpen(true), + }, ]; } @@ -131,6 +139,7 @@ export function FleetPolicyItem({ policy, onRefresh }: FleetPolicyItemProps) { + ); } \ No newline at end of file diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageResetModal.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageResetModal.tsx new file mode 100644 index 000000000..23442661d --- /dev/null +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageResetModal.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { useState } from 'react'; + +import { Button } from '@comp/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@comp/ui/dialog'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +interface PolicyImageResetModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + organizationId: string; + policyId: number; + onRefresh: () => void; +} + +export function PolicyImageResetModal({ + open, + onOpenChange, + organizationId, + policyId, + onRefresh, +}: PolicyImageResetModalProps) { + const [isDeleting, setIsDeleting] = useState(false); + + const handleConfirm = async () => { + setIsDeleting(true); + try { + const params = new URLSearchParams({ organizationId, policyId: String(policyId) }); + const res = await fetch(`/api/fleet-policy?${params}`, { method: 'DELETE' }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error ?? 'Failed to remove images'); + } + onRefresh(); + onOpenChange(false); + toast.success('Images removed'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to remove images'); + } finally { + setIsDeleting(false); + } + }; + + return ( + + + + Remove all images + +

+ Are you sure you want to remove all images? +

+ + + + +
+
+ ); +} 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 7530dac6f..5f04e63a9 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,24 +12,27 @@ import { } from '@comp/ui/dialog'; import { ImagePlus, Trash2, Loader2 } from 'lucide-react'; import Image from 'next/image'; -import { useParams } from 'next/navigation'; import { toast } from 'sonner'; import { FleetPolicy } from '../../types'; interface PolicyImageUploadModalProps { open: boolean; policy: FleetPolicy; + organizationId: string; onOpenChange: (open: boolean) => void; onRefresh: () => void; } -export function PolicyImageUploadModal({ open, policy, onOpenChange, onRefresh }: PolicyImageUploadModalProps) { +export function PolicyImageUploadModal({ + open, + policy, + organizationId, + onOpenChange, + onRefresh, +}: PolicyImageUploadModalProps) { const fileInputRef = useRef(null); const [files, setFiles] = useState>([]); const [isLoading, setIsLoading] = useState(false); - const params = useParams<{ orgId: string }>(); - const orgIdParam = params?.orgId; - const organizationId = Array.isArray(orgIdParam) ? orgIdParam[0] : orgIdParam; const handleFileChange = (e: React.ChangeEvent) => { const selected = Array.from(e.target.files ?? []); diff --git a/apps/portal/src/app/api/fleet-policy/route.ts b/apps/portal/src/app/api/fleet-policy/route.ts index 906bfde58..05f28c019 100644 --- a/apps/portal/src/app/api/fleet-policy/route.ts +++ b/apps/portal/src/app/api/fleet-policy/route.ts @@ -61,3 +61,41 @@ export async function GET(req: NextRequest) { return NextResponse.json({ success: true, data: withSignedUrls }); } + +export async function DELETE(req: NextRequest) { + const organizationId = req.nextUrl.searchParams.get('organizationId'); + const policyIdParam = req.nextUrl.searchParams.get('policyId'); + + if (!organizationId) { + return NextResponse.json({ error: 'No organization ID' }, { status: 400 }); + } + + const policyId = policyIdParam ? parseInt(policyIdParam, 10) : NaN; + if (Number.isNaN(policyId)) { + return NextResponse.json({ error: 'Invalid or missing policy ID' }, { status: 400 }); + } + + const session = await auth.api.getSession({ headers: req.headers }); + + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const member = await validateMemberAndOrg(session.user.id, organizationId); + if (!member) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const result = await db.fleetPolicyResult.deleteMany({ + where: { + organizationId, + fleetPolicyId: policyId, + userId: session.user.id, + }, + }); + + return NextResponse.json({ + success: true, + deletedCount: result.count, + }); +} From 9b433ba443d6a90e6613b76f78eb72c6db0a2973 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 2 Feb 2026 21:12:23 -0500 Subject: [PATCH 2/6] fix(portal): remove images from S3 when removing screenshots on portal --- apps/portal/src/app/api/fleet-policy/route.ts | 41 +++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/portal/src/app/api/fleet-policy/route.ts b/apps/portal/src/app/api/fleet-policy/route.ts index 05f28c019..9e240996c 100644 --- a/apps/portal/src/app/api/fleet-policy/route.ts +++ b/apps/portal/src/app/api/fleet-policy/route.ts @@ -1,7 +1,7 @@ import { auth } from '@/app/lib/auth'; import { validateMemberAndOrg } from '@/app/api/download-agent/utils'; import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/utils/s3'; -import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { DeleteObjectsCommand, GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { db } from '@db'; import { NextRequest, NextResponse } from 'next/server'; @@ -86,14 +86,41 @@ export async function DELETE(req: NextRequest) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } - const result = await db.fleetPolicyResult.deleteMany({ - where: { - organizationId, - fleetPolicyId: policyId, - userId: session.user.id, - }, + const where = { + organizationId, + fleetPolicyId: policyId, + userId: session.user.id, + }; + + const recordsToDelete = await db.fleetPolicyResult.findMany({ + where, + select: { attachments: true }, }); + const allKeys = recordsToDelete.flatMap((r) => r.attachments ?? []).filter(Boolean); + + if (s3Client && APP_AWS_ORG_ASSETS_BUCKET && allKeys.length > 0) { + try { + await s3Client.send( + new DeleteObjectsCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Delete: { + Objects: allKeys.map((key) => ({ Key: key })), + }, + }), + ); + } catch (error) { + console.error('Failed to delete policy attachment objects from S3', { + error, + policyId, + organizationId, + keyCount: allKeys.length, + }); + } + } + + const result = await db.fleetPolicyResult.deleteMany({ where }); + return NextResponse.json({ success: true, deletedCount: result.count, From 5f1a9894924454f2cb0adff023664d0a43eb100f Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 5 Feb 2026 09:52:27 -0500 Subject: [PATCH 3/6] fix(app): fix the limit issue of S3 delete request --- apps/portal/src/app/api/fleet-policy/route.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/portal/src/app/api/fleet-policy/route.ts b/apps/portal/src/app/api/fleet-policy/route.ts index 9e240996c..4b90013c0 100644 --- a/apps/portal/src/app/api/fleet-policy/route.ts +++ b/apps/portal/src/app/api/fleet-policy/route.ts @@ -99,16 +99,21 @@ export async function DELETE(req: NextRequest) { const allKeys = recordsToDelete.flatMap((r) => r.attachments ?? []).filter(Boolean); + const S3_DELETE_MAX_KEYS = 1000; + if (s3Client && APP_AWS_ORG_ASSETS_BUCKET && allKeys.length > 0) { try { - await s3Client.send( - new DeleteObjectsCommand({ - Bucket: APP_AWS_ORG_ASSETS_BUCKET, - Delete: { - Objects: allKeys.map((key) => ({ Key: key })), - }, - }), - ); + for (let i = 0; i < allKeys.length; i += S3_DELETE_MAX_KEYS) { + const batch = allKeys.slice(i, i + S3_DELETE_MAX_KEYS); + await s3Client.send( + new DeleteObjectsCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Delete: { + Objects: batch.map((key) => ({ Key: key })), + }, + }), + ); + } } catch (error) { console.error('Failed to delete policy attachment objects from S3', { error, From 6c6d6c95b734e8b3a80eecd84228acd659f6447e Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 5 Feb 2026 10:32:19 -0500 Subject: [PATCH 4/6] fix(app): return fail if S3 deletion fails --- apps/portal/src/app/api/fleet-policy/route.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/portal/src/app/api/fleet-policy/route.ts b/apps/portal/src/app/api/fleet-policy/route.ts index 4b90013c0..036c56203 100644 --- a/apps/portal/src/app/api/fleet-policy/route.ts +++ b/apps/portal/src/app/api/fleet-policy/route.ts @@ -121,6 +121,13 @@ export async function DELETE(req: NextRequest) { organizationId, keyCount: allKeys.length, }); + return NextResponse.json( + { + success: false, + error: 'Failed to remove screenshots from storage. Please try again.', + }, + { status: 503 }, + ); } } From ac3d7ad06bdd27d446d1841059fbcec3f50af616 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 5 Feb 2026 10:40:09 -0500 Subject: [PATCH 5/6] fix(portal): policy image reset modal should not be closed during deletion --- .../[orgId]/components/tasks/PolicyImageResetModal.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageResetModal.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageResetModal.tsx index 23442661d..ea510dc71 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageResetModal.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageResetModal.tsx @@ -50,7 +50,12 @@ export function PolicyImageResetModal({ }; return ( - + { + if (!isDeleting || nextOpen) onOpenChange(nextOpen); + }} + > Remove all images From f2528a285c1e4df2da6b1f123f2dce6221449d0e Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 6 Feb 2026 12:38:53 -0500 Subject: [PATCH 6/6] fix(portal): reverse the operation order - delete DB records first, then S3 --- apps/portal/src/app/api/fleet-policy/route.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/apps/portal/src/app/api/fleet-policy/route.ts b/apps/portal/src/app/api/fleet-policy/route.ts index 036c56203..0b94e2692 100644 --- a/apps/portal/src/app/api/fleet-policy/route.ts +++ b/apps/portal/src/app/api/fleet-policy/route.ts @@ -99,8 +99,11 @@ export async function DELETE(req: NextRequest) { const allKeys = recordsToDelete.flatMap((r) => r.attachments ?? []).filter(Boolean); - const S3_DELETE_MAX_KEYS = 1000; + // Delete DB records first to avoid inconsistent state: if we deleted S3 first and + // DB delete fails, we'd have broken image links. Orphaned S3 objects are preferable. + const result = await db.fleetPolicyResult.deleteMany({ where }); + const S3_DELETE_MAX_KEYS = 1000; if (s3Client && APP_AWS_ORG_ASSETS_BUCKET && allKeys.length > 0) { try { for (let i = 0; i < allKeys.length; i += S3_DELETE_MAX_KEYS) { @@ -115,24 +118,16 @@ export async function DELETE(req: NextRequest) { ); } } catch (error) { - console.error('Failed to delete policy attachment objects from S3', { + // DB is already clean; orphaned S3 objects are acceptable and can be cleaned up later + console.error('Failed to delete policy attachment objects from S3 (orphaned)', { error, policyId, organizationId, keyCount: allKeys.length, }); - return NextResponse.json( - { - success: false, - error: 'Failed to remove screenshots from storage. Please try again.', - }, - { status: 503 }, - ); } } - const result = await db.fleetPolicyResult.deleteMany({ where }); - return NextResponse.json({ success: true, deletedCount: result.count,