Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
25ea26a
feat(portal): upload policy images to S3 bucket
chasprowebdev Jan 19, 2026
d7ddd4f
fix(portal): rename s3 bucket for fleet policy attachments
chasprowebdev Jan 19, 2026
1b2e8c9
feat(db): create new table - FleetPolicyResult
chasprowebdev Jan 19, 2026
07bc56a
feat(portal): preview fleet policy attachments and set it passed when…
chasprowebdev Jan 19, 2026
4bb6228
feat(app): mark device policy passed if the screenshots are manually …
chasprowebdev Jan 19, 2026
776ad11
fix(portal): refresh the page after uploading policy images
chasprowebdev Jan 19, 2026
685dfe8
fix(portal): fix border color issue on device policy item
chasprowebdev Jan 19, 2026
84903c0
feat(portal): enable to upload images for MDM Enabled policy
chasprowebdev Jan 19, 2026
248c3c5
fix(app): show images for device policy - MDM Enabled
chasprowebdev Jan 19, 2026
0352144
fix(app): issue - Admin view fetches admin's policy results instead o…
chasprowebdev Jan 19, 2026
e87e795
fix(app): add user authentication to get-image-url endpoint
chasprowebdev Jan 19, 2026
919261c
fix(portal): progress bar ignores fleetPolicyResults for completion s…
chasprowebdev Jan 20, 2026
4d767a6
fix(portal): make device policy passed only when uploading 1+ files
chasprowebdev Jan 20, 2026
82d074d
fix(portal): remove unused dependency from variable
chasprowebdev Jan 20, 2026
379506a
fix(portal): make transh icon disabled while uploading images for dev…
chasprowebdev Jan 20, 2026
a4b50ad
Merge branch 'main' of https://github.com/trycompai/comp into chas/up…
chasprowebdev Jan 20, 2026
0428a77
fix(app): add org authorization check in get-image-url endpoint
chasprowebdev Jan 20, 2026
81ab24c
fix(app): use negative value as MDM Enabled policy id
chasprowebdev Jan 20, 2026
befa85c
fix(portal): use negative value as MDM Enabled policy id
chasprowebdev Jan 20, 2026
dd1e5ed
fix(portal): add bounds guard in PolicyImagePreviewModal
chasprowebdev Jan 20, 2026
09db683
fix(portal): cancel button bypasses file cleanup causing memory leak
chasprowebdev Jan 20, 2026
32cebeb
fix(app): missing null guard on policies array causes crash
chasprowebdev Jan 20, 2026
f5c364c
fix(portal): move preview/upload buttons under dropdownmenu on device…
chasprowebdev Jan 21, 2026
0a46363
fix(app): move preview button under dropdown menu on device policy item
chasprowebdev Jan 21, 2026
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
@@ -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 (
<div className="flex items-center justify-between">
<Button
variant="outline"
size="icon"
onClick={onPrevious}
disabled={isFirstVideo}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carousel previous button blocks intended wrap-around navigation

Low Severity

The CarouselControls component disables the previous button when currentIndex === 0 (via isFirstVideo), but PolicyImagePreviewModal passes a goPrevious handler that implements wrap-around logic (prev === 0 ? images.length - 1 : prev - 1). This creates inconsistent carousel behavior where forward navigation wraps from last to first image, but backward navigation is blocked at the first image instead of wrapping to the last.

Additional Locations (1)

Fix in Cursor Fix in Web

aria-label="Previous video"
>
<ChevronLeft className="h-4 w-4" />
</Button>

<div className="text-muted-foreground text-sm">
{currentIndex + 1} of {total}
</div>

<Button
variant="outline"
size="icon"
onClick={onNext}
disabled={!onNext}
aria-label="Next video"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,80 +1,26 @@
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 (
<div className="space-y-4">
<Button variant="outline" className="w-min" onClick={onClose}>
<ArrowLeft size={16} className="mr-2" />
Back
</Button>
<Card>
<Card style={{ marginBottom: 48 }}>
<CardHeader>
<CardTitle>{host.computer_name}'s Policies</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{host.policies.length > 0 ? (
<>
{host.policies.map((policy) => (
<div
key={policy.id}
className={cn(
'hover:bg-muted/50 flex items-center justify-between rounded-md border border-l-4 p-3 shadow-sm transition-colors',
policy.response === 'pass' ? 'border-l-primary' : 'border-l-red-500',
)}
>
<p className="font-medium">{policy.name}</p>
{policy.response === 'pass' ? (
<div className="flex items-center gap-1 text-primary">
<CheckCircle2 size={16} />
<span>Pass</span>
</div>
) : (
<div className="flex items-center gap-1 text-red-600">
<XCircle size={16} />
<span>Fail</span>
</div>
)}
</div>
<PolicyItem key={policy.id} policy={policy} />
))}
{isMacOS && (
<div
key={mdmEnabledStatus.id}
className={cn(
'hover:bg-muted/50 flex items-center justify-between rounded-md border border-l-4 p-3 shadow-sm transition-colors',
mdmEnabledStatus.response === 'pass' ? 'border-l-primary' : 'border-l-red-500',
)}
>
<p className="font-medium">{mdmEnabledStatus.name}</p>
{mdmEnabledStatus.response === 'pass' ? (
<div className="flex items-center gap-1 text-primary">
<CheckCircle2 size={16} />
<span>Pass</span>
</div>
) : (
<div className="flex items-center gap-1 text-red-600">
<XCircle size={16} />
<span>Fail</span>
</div>
)}
</div>
)}
</>
) : (
<p className="text-muted-foreground">No policies found for this device.</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <div className="h-[500px] w-full flex items-center justify-center text-sm text-muted-foreground">Loading image...</div>;
}

if (error || !signedUrl) {
return <div className="h-[500px] w-full flex items-center justify-center text-sm text-muted-foreground">Failed to load image</div>;
}

return (
<div className="overflow-hidden w-full h-[500px]">
<Image
key={signedUrl}
src={signedUrl}
alt="Policy image"
width={800}
height={600}
className="h-full w-full object-contain"
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Preview</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{!hasImages && <p className="text-sm text-muted-foreground">No images to display.</p>}

{hasImages && (
<>
<PolicyImagePreview image={images[currentIndex]} />
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing array bounds check in image preview modal

Low Severity

The PolicyImagePreviewModal in apps/app lacks a bounds check before accessing images[currentIndex]. The portal version has hasImages && currentIndex >= 0 && currentIndex < images.length, but this version only checks hasImages. If images.length changes between renders (before the useEffect resets currentIndex to 0), accessing images[currentIndex] with an out-of-bounds index could pass undefined to PolicyImagePreview, causing a brief "Failed to load image" flash.

Fix in Cursor Fix in Web

<CarouselControls
currentIndex={currentIndex}
total={images.length}
onPrevious={goPrevious}
onNext={goNext}
/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carousel navigation has inconsistent wrap-around behavior

Low Severity

The PolicyImagePreviewModal passes goNext unconditionally to CarouselControls, but CarouselControls disables the "previous" button at index 0 while only disabling "next" when onNext is falsy. This creates asymmetric navigation: the carousel wraps forward (last → first) but not backward (first stays disabled). Users at the last image can click "Next" to loop back to the first image, but users at the first image cannot click "Previous" to reach the last image.

Additional Locations (1)

Fix in Cursor Fix in Web

</>
)}
</div>
</DialogContent>
</Dialog>
);
}
Original file line number Diff line number Diff line change
@@ -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: () => <ImageIcon className="h-4 w-4" />,
onClick: () => setIsPreviewOpen(true),
},
];
}

return [];
}, [policy]);

return (
<div
className={cn(
'hover:bg-muted/50 flex items-center justify-between rounded-md border border-l-4 p-3 shadow-sm transition-colors',
policy.response === 'pass' ? 'border-l-primary' : 'border-l-red-500',
)}
>
<p className="font-medium">{policy.name}</p>
<div className="flex items-center gap-3">
{policy.response === 'pass' ? (
<div className="flex items-center gap-1 text-primary">
<CheckCircle2 size={16} />
<span>Pass</span>
</div>
) : (
<div className="flex items-center gap-1 text-red-600">
<XCircle size={16} />
<span>Fail</span>
</div>
)}
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
disabled={actions.length === 0}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{actions.map(({ label, renderIcon, onClick }) => (
<DropdownMenuItem
key={label}
onSelect={(event) => {
event.preventDefault();
onClick();
setDropdownOpen(false);
}}
>
{renderIcon()}
<span>{label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<PolicyImagePreviewModal
images={policy?.attachments || []}
open={isPreviewOpen}
onOpenChange={setIsPreviewOpen}
/>
</div>
);
};
42 changes: 37 additions & 5 deletions apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Host[] | null> = async () => {
const session = await auth.api.getSession({
headers: await headers(),
Expand All @@ -30,14 +32,44 @@ export const getEmployeeDevices: () => Promise<Host[] | null> = 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 || [],
};
}),
};
});
};
Loading