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..ea510dc71 --- /dev/null +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageResetModal.tsx @@ -0,0 +1,82 @@ +'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 ( + { + if (!isDeleting || nextOpen) onOpenChange(nextOpen); + }} + > + + + Remove all images + + + Are you sure you want to remove all images? + + + onOpenChange(false)} + disabled={isDeleting} + > + No + + + {isDeleting ? : 'Yes'} + + + + + ); +} 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..0b94e2692 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'; @@ -61,3 +61,75 @@ 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 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); + + // 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) { + 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) { + // 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: true, + deletedCount: result.count, + }); +}
+ Are you sure you want to remove all images? +