diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/CarouselControls.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/CarouselControls.tsx new file mode 100644 index 000000000..dd5660fec --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/CarouselControls.tsx @@ -0,0 +1,46 @@ +import { Button } from '@comp/ui/button'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +interface CarouselControlsProps { + currentIndex: number; + total: number; + onPrevious: () => void; + onNext?: () => void; +} + +export function CarouselControls({ + currentIndex, + total, + onPrevious, + onNext, +}: CarouselControlsProps) { + const isFirstVideo = currentIndex === 0; + + return ( +
+ + +
+ {currentIndex + 1} of {total} +
+ + +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx index d1786cbdf..bba2bf098 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx @@ -1,30 +1,17 @@ import { Button } from '@comp/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; -import { cn } from '@comp/ui/cn'; -import { ArrowLeft, CheckCircle2, XCircle } from 'lucide-react'; -import { useMemo } from 'react'; +import { ArrowLeft } from 'lucide-react'; import type { Host } from '../types'; +import { PolicyItem } from './PolicyItem'; export const HostDetails = ({ host, onClose }: { host: Host; onClose: () => void }) => { - const isMacOS = useMemo(() => { - return host.cpu_type && (host.cpu_type.includes('arm64') || host.cpu_type.includes('intel')); - }, [host]); - - const mdmEnabledStatus = useMemo(() => { - return { - id: 'mdm', - response: host?.mdm.connected_to_fleet ? 'pass' : 'fail', - name: 'MDM Enabled', - }; - }, [host]); - return (
- + {host.computer_name}'s Policies @@ -32,49 +19,8 @@ export const HostDetails = ({ host, onClose }: { host: Host; onClose: () => void {host.policies.length > 0 ? ( <> {host.policies.map((policy) => ( -
-

{policy.name}

- {policy.response === 'pass' ? ( -
- - Pass -
- ) : ( -
- - Fail -
- )} -
+ ))} - {isMacOS && ( -
-

{mdmEnabledStatus.name}

- {mdmEnabledStatus.response === 'pass' ? ( -
- - Pass -
- ) : ( -
- - Fail -
- )} -
- )} ) : (

No policies found for this device.

diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreview.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreview.tsx new file mode 100644 index 000000000..22b90b4a3 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreview.tsx @@ -0,0 +1,42 @@ +import useSWR from 'swr'; +import Image from 'next/image'; + +const fetcher = async (key: string) => { + const res = await fetch(key, { cache: 'no-store' }); + if (!res.ok) { + throw new Error('Failed to fetch image url'); + } + const json = (await res.json()) as { url?: string }; + if (!json.url) { + throw new Error('No url returned'); + } + return json.url; +}; + +export function PolicyImagePreview({ image }: { image: string }) { + const { data: signedUrl, error, isLoading } = useSWR( + () => (image ? `/api/get-image-url?key=${encodeURIComponent(image)}` : null), + fetcher, + ); + + if (isLoading) { + return
Loading image...
; + } + + if (error || !signedUrl) { + return
Failed to load image
; + } + + return ( +
+ Policy image +
+ ); +} \ No newline at end of file diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreviewModal.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreviewModal.tsx new file mode 100644 index 000000000..b9ce838f7 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreviewModal.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@comp/ui/dialog'; +import { CarouselControls } from './CarouselControls'; +import { PolicyImagePreview } from './PolicyImagePreview'; + +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 ( + + + + Preview + +
+ {!hasImages &&

No images to display.

} + + {hasImages && ( + <> + + + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyItem.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyItem.tsx new file mode 100644 index 000000000..b189b4170 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyItem.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { cn } from '@comp/ui/cn'; +import { CheckCircle2, Image as ImageIcon, MoreVertical, XCircle } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@comp/ui/dropdown-menu'; + +import { FleetPolicy } from "../types"; +import { Button } from '@comp/ui/button'; +import { PolicyImagePreviewModal } from './PolicyImagePreviewModal'; + +interface PolicyItemProps { + policy: FleetPolicy; +} + +export const PolicyItem = ({ policy }: PolicyItemProps) => { + const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + + const actions = useMemo(() => { + if ((policy?.attachments || []).length > 0 && policy.response === 'pass') { + return [ + { + label: 'Preview images', + renderIcon: () => , + onClick: () => setIsPreviewOpen(true), + }, + ]; + } + + return []; + }, [policy]); + + return ( +
+

{policy.name}

+
+ {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/app/src/app/(app)/[orgId]/people/devices/data/index.ts b/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts index 965348591..1717ddd1f 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts @@ -6,6 +6,8 @@ import { db } from '@db'; import { headers } from 'next/headers'; import type { Host } from '../types'; +const MDM_POLICY_ID = -9999; + export const getEmployeeDevices: () => Promise = async () => { const session = await auth.api.getSession({ headers: await headers(), @@ -30,14 +32,44 @@ export const getEmployeeDevices: () => Promise = 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 ( + + + + Preview + +
+ {!hasImages &&

No images to display.

} + + {hasImages && currentIndex >= 0 && currentIndex < images.length && ( + <> +
+ {`Policy +
+ + + )} +
+
+
+ ); +} \ 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 ( + + { + e.preventDefault(); + }} + > + + Upload Policy Images + + +
+ + {files.length === 0 && ( +
+ +
+ )} + + {files.length > 0 && ( +
+
+
    + {files.map((item, idx) => ( +
  • +
    +
    + {item.file.name} +
    + {item.file.name} +
    + +
  • + ))} +
+
+
+ )} +
+ + + {files.length > 0 && ( + <> +
+ + {files.length} file{files.length === 1 ? '' : 's'} + +
+ + + )} + + +
+
+
+ ); +} \ 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]) }