Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,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 (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!isDeleting || nextOpen) onOpenChange(nextOpen);
}}
>
<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
74 changes: 73 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,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,
});
}