Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,12 @@ export function DeviceAgentAccordionItem({
{fleetPolicies.length > 0 ? (
<>
{fleetPolicies.map((policy) => (
<FleetPolicyItem key={policy.id} policy={policy} onRefresh={handleRefresh} />
<FleetPolicyItem
key={policy.id}
policy={policy}
organizationId={member.organizationId}
onRefresh={handleRefresh}
/>
))}
</>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(() => {
Expand All @@ -35,6 +38,11 @@ export function FleetPolicyItem({ policy, onRefresh }: FleetPolicyItemProps) {
renderIcon: () => <Image className="mr-2 h-4 w-4" />,
onClick: () => setIsPreviewOpen(true),
},
{
label: 'Remove images',
renderIcon: () => <Trash className="mr-2 h-4 w-4" />,
onClick: () => setIsRemoveOpen(true),
},
];
}

Expand Down Expand Up @@ -131,6 +139,7 @@ export function FleetPolicyItem({ policy, onRefresh }: FleetPolicyItemProps) {
</div>
<PolicyImageUploadModal
policy={policy}
organizationId={organizationId}
open={isUploadOpen}
onOpenChange={setIsUploadOpen}
onRefresh={onRefresh}
Expand All @@ -140,6 +149,13 @@ export function FleetPolicyItem({ policy, onRefresh }: FleetPolicyItemProps) {
open={isPreviewOpen}
onOpenChange={setIsPreviewOpen}
/>
<PolicyImageResetModal
open={isRemoveOpen}
organizationId={organizationId}
policyId={policy.id}
onOpenChange={setIsRemoveOpen}
onRefresh={onRefresh}
/>
</>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove all images</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Are you sure you want to remove all images?
</p>
<DialogFooter>
<Button
variant="ghost"
type="button"
onClick={() => onOpenChange(false)}
disabled={isDeleting}
>
No
</Button>
<Button type="button" onClick={handleConfirm} disabled={isDeleting}>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Yes'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>(null);
const [files, setFiles] = useState<Array<{ file: File; previewUrl: string }>>([]);
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<HTMLInputElement>) => {
const selected = Array.from(e.target.files ?? []);
Expand Down
67 changes: 66 additions & 1 deletion apps/portal/src/app/api/fleet-policy/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -61,3 +61,68 @@ 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);

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,
});
}