= async () => {
const labelIdsResponses = await Promise.all(
employees
.filter((employee) => employee.fleetDmLabelId)
- .map((employee) => fleet.get(`/labels/${employee.fleetDmLabelId}/hosts`)),
+ .map(async (employee) => ({
+ userId: employee.userId,
+ response: await fleet.get(`/labels/${employee.fleetDmLabelId}/hosts`),
+ })),
);
- const allIds = labelIdsResponses.flatMap((response) =>
- response.data.hosts.map((host: { id: number }) => host.id),
+
+ const hostRequests = labelIdsResponses.flatMap((entry) =>
+ entry.response.data.hosts.map((host: { id: number }) => ({
+ userId: entry.userId,
+ hostId: host.id,
+ })),
);
// Get all devices by id. in parallel
- const devices = await Promise.all(allIds.map((id: number) => fleet.get(`/hosts/${id}`)));
+ const devices = await Promise.all(hostRequests.map(({ hostId }) => fleet.get(`/hosts/${hostId}`)));
+ const userIds = hostRequests.map(({ userId }) => userId);
- return devices.map((device: { data: { host: Host } }) => device.data.host);
+ const results = await db.fleetPolicyResult.findMany({
+ where: { organizationId },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ return devices.map((device: { data: { host: Host } }, index: number) => {
+ const host = device.data.host;
+ const isMacOS = host.cpu_type && (host.cpu_type.includes('arm64') || host.cpu_type.includes('intel'));
+ return {
+ ...host,
+ policies: [
+ ...(host.policies || []),
+ ...(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 {
+ ...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..891d7ea1b
--- /dev/null
+++ b/apps/app/src/app/api/get-image-url/route.ts
@@ -0,0 +1,49 @@
+'use server';
+
+import { auth } from '@/utils/auth';
+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 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 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 });
+ }
+
+ 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 });
+ }
+}
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..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
@@ -11,11 +11,11 @@ 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 type { FleetPolicy, Host } from '../../types';
interface DeviceAgentAccordionItemProps {
@@ -37,21 +37,8 @@ export function DeviceAgentAccordionItem({
[detectedOS],
);
- const mdmEnabledStatus = useMemo(() => {
- return {
- id: 'mdm',
- response: host?.mdm.connected_to_fleet ? 'pass' : 'fail',
- name: 'MDM Enabled',
- };
- }, [host]);
-
const hasInstalledAgent = host !== null;
- const failedPoliciesCount = useMemo(() => {
- return (
- fleetPolicies.filter((policy) => policy.response !== 'pass').length +
- (!isMacOS || mdmEnabledStatus.response === 'pass' ? 0 : 1)
- );
- }, [fleetPolicies, mdmEnabledStatus, isMacOS]);
+ const failedPoliciesCount = useMemo(() => fleetPolicies.filter((policy) => policy.response !== 'pass').length, [fleetPolicies]);
const isCompleted = hasInstalledAgent && failedPoliciesCount === 0;
@@ -246,80 +233,8 @@ 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
-
- )}
-
- )}
>
) : (
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..4a2901cda
--- /dev/null
+++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+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 {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@comp/ui/dropdown-menu';
+import type { FleetPolicy } from '../../types';
+import { PolicyImageUploadModal } from './PolicyImageUploadModal';
+import { PolicyImagePreviewModal } from './PolicyImagePreviewModal';
+
+interface FleetPolicyItemProps {
+ policy: FleetPolicy;
+}
+
+export function FleetPolicyItem({ policy }: FleetPolicyItemProps) {
+ const [isUploadOpen, setIsUploadOpen] = useState(false);
+ const [isPreviewOpen, setIsPreviewOpen] = useState(false);
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+
+ const actions = useMemo(() => {
+ if (policy.response === 'pass') {
+ if ((policy?.attachments || []).length > 0) {
+ return [
+ {
+ label: 'Preview images',
+ renderIcon: () => ,
+ onClick: () => setIsPreviewOpen(true),
+ },
+ ];
+ }
+
+ return [];
+ }
+
+ return [
+ {
+ label: 'Upload images',
+ renderIcon: () => ,
+ onClick: () => setIsUploadOpen(true),
+ }
+ ]
+ }, [policy]);
+
+ const hasActions = useMemo(() => actions.length > 0, [actions]);
+
+ return (
+ <>
+
+
+
{policy.name}
+ {policy.name === 'MDM Enabled' && policy.response === 'fail' && (
+
+
+
+
+
+
+
+ There are additional steps required to enable MDM. Please check{' '}
+
+ this documentation
+
+ .
+
+
+
+
+ )}
+
+
+ {policy.response === 'pass' ? (
+
+
+ Pass
+
+ ) : (
+
+
+ Fail
+
+ )}
+
+
+
+
+
+ {actions.map(({ label, renderIcon, onClick }) => (
+ {
+ event.preventDefault();
+ onClick();
+ setDropdownOpen(false);
+ }}
+ >
+ {renderIcon()}
+ {label}
+
+ ))}
+
+
+
+
+
+
+ >
+ );
+}
\ No newline at end of file
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..4d53de2f4
--- /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 (
+
+ );
+}
\ 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..1b577eaf4
--- /dev/null
+++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx
@@ -0,0 +1,195 @@
+'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 { useRouter } from 'next/navigation';
+import { toast } from 'sonner';
+import { FleetPolicy } from '../../types';
+
+interface PolicyImageUploadModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ policy: FleetPolicy;
+}
+
+export function PolicyImageUploadModal({ open, onOpenChange, policy }: PolicyImageUploadModalProps) {
+ 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 ?? []);
+ 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 handleSubmit = async () => {
+ if (files.length === 0 || isLoading) return;
+
+ try {
+ 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);
+ router.refresh();
+ } catch (error) {
+ console.error('Failed to upload policy images', error);
+ const message = error instanceof Error ? error.message : 'Failed to upload policy images';
+ toast.error(message);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
\ 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 a9dfdde40..2a2acb83b 100644
--- a/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx
+++ b/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx
@@ -2,13 +2,15 @@
import { auth } from '@/app/lib/auth';
import { getFleetInstance } from '@/utils/fleet';
-import type { Member } from '@db';
+import type { FleetPolicyResult, Member } from '@db';
import { db } from '@db';
import { headers } from 'next/headers';
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;
@@ -84,9 +86,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: MDM_POLICY_ID,
+ 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) {
@@ -97,3 +121,27 @@ const getFleetPolicies = async (
return { fleetPolicies: [], device: null };
}
};
+
+const getFleetPolicyResults = async (): Promise => {
+ 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)/[orgId]/types/index.ts b/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts
index c132d164f..658359065 100644
--- a/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts
+++ b/apps/portal/src/app/(app)/(home)/[orgId]/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/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..f15acdeb6
--- /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: uploads.length > 0 ? 'pass' : 'fail',
+ fleetPolicyName: policyName,
+ },
+ });
+ } else {
+ await db.fleetPolicyResult.create({
+ data: {
+ userId,
+ organizationId,
+ fleetPolicyId: policyId,
+ fleetPolicyName: policyName,
+ fleetPolicyResponse: uploads.length > 0 ? 'pass' : 'fail',
+ 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 });
+}
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
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])
}