Skip to content

Commit 0f4e9ca

Browse files
authored
Merge branch 'main' into lewis/comp-trust-portal-remove-request-details
2 parents 2fb4a6d + 961235b commit 0f4e9ca

File tree

14 files changed

+1160
-704
lines changed

14 files changed

+1160
-704
lines changed

apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/components/comments/CommentForm.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import { Label } from "@comp/ui/label";
88
import { Textarea } from "@comp/ui/textarea";
99
import clsx from "clsx";
1010
import { ArrowUp, Loader2, Paperclip } from "lucide-react";
11-
import React, { useCallback, useRef, useState } from "react";
11+
import React, { useCallback, useEffect, useRef, useState } from "react";
1212
import { toast } from "sonner";
1313
import { useParams, useRouter } from "next/navigation";
1414
import { createComment } from "../../actions/createComment";
1515
import { AttachmentItem } from "../../tasks/[taskId]/components/AttachmentItem";
16+
import { Input } from "@comp/ui/input";
1617

1718
interface CommentFormProps {
1819
entityId: string;
@@ -28,24 +29,30 @@ interface PendingAttachment {
2829

2930
export function CommentForm({ entityId, entityType }: CommentFormProps) {
3031
const session = authClient.useSession();
32+
const router = useRouter();
33+
const params = useParams();
3134
const [newComment, setNewComment] = useState("");
3235
const [pendingAttachments, setPendingAttachments] = useState<
3336
PendingAttachment[]
3437
>([]);
3538
const [isUploading, setIsUploading] = useState(false);
3639
const fileInputRef = useRef<HTMLInputElement>(null);
37-
const { orgId } = useParams<{ orgId: string }>();
40+
const [hasMounted, setHasMounted] = useState(false);
41+
42+
useEffect(() => {
43+
setHasMounted(true);
44+
}, []);
3845

3946
let pathToRevalidate = "";
4047
switch (entityType) {
4148
case "task":
42-
pathToRevalidate = `/${orgId}/tasks/${entityId}`;
49+
pathToRevalidate = `/${params.orgId}/tasks/${entityId}`;
4350
break;
4451
case "vendor":
45-
pathToRevalidate = `/${orgId}/vendors/${entityId}`;
52+
pathToRevalidate = `/${params.orgId}/vendors/${entityId}`;
4653
break;
4754
case "risk":
48-
pathToRevalidate = `/${orgId}/risks/${entityId}`;
55+
pathToRevalidate = `/${params.orgId}/risks/${entityId}`;
4956
break;
5057
}
5158

@@ -213,7 +220,7 @@ export function CommentForm({ entityId, entityType }: CommentFormProps) {
213220

214221
const isLoading = isUploading || session.isPending;
215222

216-
if (session.isPending) {
223+
if (!hasMounted || session.isPending) {
217224
return (
218225
<div className="border rounded p-3 space-y-3 animate-pulse">
219226
<div className="flex gap-3 items-start">
@@ -243,7 +250,7 @@ export function CommentForm({ entityId, entityType }: CommentFormProps) {
243250
return (
244251
<div className="border rounded p-0 bg-foreground/5">
245252
<div className="flex gap-3 items-start">
246-
<input
253+
<Input
247254
type="file"
248255
multiple
249256
ref={fileInputRef}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"use server";
2+
3+
import { auth } from "@/utils/auth";
4+
import { db } from "@comp/db";
5+
import { revalidatePath } from "next/cache";
6+
import { headers } from "next/headers";
7+
8+
export async function mapPolicyToControls({
9+
policyId,
10+
controlIds,
11+
}: {
12+
policyId: string;
13+
controlIds: string[];
14+
}) {
15+
const session = await auth.api.getSession({
16+
headers: await headers(),
17+
});
18+
19+
const organizationId = session?.session.activeOrganizationId;
20+
21+
if (!organizationId) {
22+
throw new Error("Unauthorized");
23+
}
24+
25+
try {
26+
console.log(`Mapping controls ${controlIds} to policy ${policyId}`);
27+
// 1. For each control, create a new artifact that is linked to the policy.
28+
const artifacts = await db.$transaction(
29+
controlIds.map((controlId) =>
30+
db.artifact.create({
31+
data: {
32+
organizationId,
33+
policyId,
34+
type: "policy",
35+
controls: {
36+
connect: { id: controlId },
37+
},
38+
},
39+
}),
40+
),
41+
);
42+
43+
console.log("Artifacts:", artifacts);
44+
console.log(`Controls mapped successfully to policy ${policyId}`);
45+
46+
revalidatePath(`/${organizationId}/policies/${policyId}`);
47+
} catch (error) {
48+
console.error(error);
49+
throw error;
50+
}
51+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"use server";
2+
3+
import { auth } from "@/utils/auth";
4+
import { headers } from "next/headers";
5+
import { revalidatePath } from "next/cache";
6+
import { db } from "@comp/db";
7+
8+
export async function unmapPolicyFromControl({
9+
policyId,
10+
controlId,
11+
}: {
12+
policyId: string;
13+
controlId: string;
14+
}) {
15+
const session = await auth.api.getSession({
16+
headers: await headers(),
17+
});
18+
19+
const organizationId = session?.session.activeOrganizationId;
20+
21+
if (!organizationId) {
22+
throw new Error("Unauthorized");
23+
}
24+
25+
try {
26+
console.log(`Unmapping control ${controlId} from policy ${policyId}`);
27+
await db.artifact.deleteMany({
28+
where: {
29+
organizationId,
30+
policyId,
31+
controls: {
32+
some: {
33+
id: controlId,
34+
},
35+
},
36+
},
37+
});
38+
39+
console.log(`Control ${controlId} unmapped from policy ${policyId}`);
40+
revalidatePath(`/${organizationId}/policies/${policyId}`);
41+
revalidatePath(`/${organizationId}/controls/${controlId}`);
42+
} catch (error) {
43+
console.error(error);
44+
throw error;
45+
}
46+
}

apps/app/src/components/policies/sheets/policy-archive-sheet.tsx renamed to apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/[policyId]/components/PolicyArchiveSheet.tsx

File renamed without changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Control } from "@comp/db/types";
2+
import { useState } from "react";
3+
import {
4+
Dialog,
5+
DialogContent,
6+
DialogDescription,
7+
DialogFooter,
8+
DialogHeader,
9+
DialogTitle,
10+
DialogTrigger,
11+
} from "@comp/ui/dialog";
12+
import { Button } from "@comp/ui/button";
13+
import { X } from "lucide-react";
14+
import { unmapPolicyFromControl } from "../actions/unmapPolicyFromControl";
15+
import { toast } from "sonner";
16+
import { useParams } from "next/navigation";
17+
18+
export const PolicyControlMappingConfirmDeleteModal = ({
19+
control,
20+
}: {
21+
control: Control;
22+
}) => {
23+
const { policyId } = useParams<{ policyId: string }>();
24+
const [open, setOpen] = useState(false);
25+
const [loading, setLoading] = useState(false);
26+
27+
const handleUnmap = async () => {
28+
console.log("Unmapping control", control.id, "from policy", policyId);
29+
try {
30+
setLoading(true);
31+
await unmapPolicyFromControl({
32+
policyId,
33+
controlId: control.id,
34+
});
35+
toast.success(
36+
`Control: ${control.name} unmapped successfully from policy ${policyId}`,
37+
);
38+
} catch (error) {
39+
console.error(error);
40+
toast.error("Failed to unlink control");
41+
} finally {
42+
setLoading(false);
43+
setOpen(false);
44+
}
45+
};
46+
47+
return (
48+
<Dialog open={open} onOpenChange={setOpen}>
49+
<DialogTrigger asChild>
50+
<X className="ml-2 h-3 w-3 cursor-pointer" />
51+
</DialogTrigger>
52+
<DialogContent>
53+
<DialogHeader>
54+
<DialogTitle>Confirm Unlink</DialogTitle>
55+
</DialogHeader>
56+
<DialogDescription>
57+
Are you sure you want to unlink{" "}
58+
<span className="font-semibold text-foreground">
59+
{control.name}
60+
</span>{" "}
61+
from this policy? {"\n"} You can link it back again later.
62+
</DialogDescription>
63+
<DialogFooter>
64+
<Button
65+
variant="outline"
66+
onClick={() => setOpen(false)}
67+
disabled={loading}
68+
>
69+
Cancel
70+
</Button>
71+
<Button onClick={handleUnmap} disabled={loading}>
72+
Unmap
73+
</Button>
74+
</DialogFooter>
75+
</DialogContent>
76+
</Dialog>
77+
);
78+
};
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { Control } from "@comp/db/types";
2+
import { useEffect, useState } from "react";
3+
import {
4+
Dialog,
5+
DialogContent,
6+
DialogDescription,
7+
DialogFooter,
8+
DialogHeader,
9+
DialogTitle,
10+
DialogTrigger,
11+
} from "@comp/ui/dialog";
12+
import { Badge } from "@comp/ui/badge";
13+
import { Button } from "@comp/ui/button";
14+
import MultipleSelector, { Option } from "@comp/ui/multiple-selector";
15+
import { PlusIcon } from "lucide-react";
16+
import { mapPolicyToControls } from "../actions/mapPolicyToControls";
17+
import { useParams } from "next/navigation";
18+
import { toast } from "sonner";
19+
20+
export const PolicyControlMappingModal = ({
21+
allControls,
22+
mappedControls,
23+
}: {
24+
allControls: Control[];
25+
mappedControls: Control[];
26+
}) => {
27+
const [open, setOpen] = useState(false);
28+
const mappedControlIds = new Set(mappedControls.map((c) => c.id));
29+
const [selectedControls, setSelectedControls] = useState<Option[]>([]);
30+
const { policyId } = useParams<{ policyId: string }>();
31+
32+
// Filter out controls that are already mapped
33+
const filteredControls = allControls.filter(
34+
(control) => !mappedControlIds.has(control.id),
35+
);
36+
37+
// Prepare options for the MultipleSelector
38+
const preparedOptions = filteredControls.map((control) => ({
39+
value: control.id,
40+
label: control.name,
41+
}));
42+
43+
const handleMapControls = async () => {
44+
try {
45+
console.log(
46+
`Mapping controls ${selectedControls.map((c) => c.label)} to policy ${policyId}`,
47+
);
48+
await mapPolicyToControls({
49+
policyId,
50+
controlIds: selectedControls.map((c) => c.value),
51+
});
52+
setOpen(false);
53+
toast.success(
54+
`Controls ${selectedControls.map((c) => c.label)} mapped successfully to policy ${policyId}`,
55+
);
56+
} catch (error) {
57+
console.error(error);
58+
toast.error("Failed to map controls");
59+
}
60+
};
61+
62+
useEffect(() => {
63+
return () => {
64+
setSelectedControls([]);
65+
};
66+
}, [open]);
67+
68+
return (
69+
<Dialog open={open} onOpenChange={setOpen}>
70+
<DialogTrigger asChild>
71+
<Badge
72+
variant="secondary"
73+
className="flex items-center hover:bg-secondary/80 cursor-pointer h-5 self-end"
74+
onClick={() => setOpen(true)}
75+
>
76+
<PlusIcon className="mr-2 h-3 w-3" />
77+
Link Controls
78+
</Badge>
79+
</DialogTrigger>
80+
<DialogContent>
81+
<DialogHeader>
82+
<DialogTitle>Link New Controls</DialogTitle>
83+
</DialogHeader>
84+
<DialogDescription>
85+
Select controls you want to link to this policy
86+
</DialogDescription>
87+
<MultipleSelector
88+
placeholder="Search or select controls..."
89+
value={selectedControls}
90+
onChange={setSelectedControls}
91+
options={preparedOptions}
92+
commandProps={{
93+
// Custom filter function to match by label (name) instead of value
94+
filter: (value, search) => {
95+
// Find the option with this value
96+
const option = preparedOptions.find(
97+
(opt) => opt.value === value,
98+
);
99+
if (!option) return 0;
100+
101+
// Check if the option label contains the search string
102+
return option.label
103+
.toLowerCase()
104+
.includes(search.toLowerCase())
105+
? 1
106+
: 0;
107+
},
108+
}}
109+
/>
110+
<DialogFooter>
111+
<Button variant="outline" onClick={() => setOpen(false)}>
112+
Cancel
113+
</Button>
114+
<Button onClick={handleMapControls}>Map</Button>
115+
</DialogFooter>
116+
</DialogContent>
117+
</Dialog>
118+
);
119+
};

0 commit comments

Comments
 (0)