From ba1d15b482011e7f937ab20234def4fe54ecd09e Mon Sep 17 00:00:00 2001 From: Lewis Carhart Date: Sun, 16 Mar 2025 15:15:32 -0400 Subject: [PATCH 1/2] Add create policy functionality with form and sheet components; implement schema validation and integrate with server actions for policy creation. --- .../src/actions/policies/create-new-policy.ts | 72 +++ apps/app/src/actions/schema.ts | 455 +++++++++--------- .../[id]/components/SingleControlSkeleton.tsx | 54 +-- .../forms/policies/create-new-policy.tsx | 128 +++++ .../components/sheets/create-policy-sheet.tsx | 55 +++ .../tables/policies/filter-toolbar.tsx | 29 +- apps/app/src/locales/en.ts | 7 + 7 files changed, 536 insertions(+), 264 deletions(-) create mode 100644 apps/app/src/actions/policies/create-new-policy.ts create mode 100644 apps/app/src/components/forms/policies/create-new-policy.tsx create mode 100644 apps/app/src/components/sheets/create-policy-sheet.tsx diff --git a/apps/app/src/actions/policies/create-new-policy.ts b/apps/app/src/actions/policies/create-new-policy.ts new file mode 100644 index 0000000000..9986766374 --- /dev/null +++ b/apps/app/src/actions/policies/create-new-policy.ts @@ -0,0 +1,72 @@ +"use server"; + +import { db, Departments, Frequency } from "@bubba/db"; +import { revalidatePath, revalidateTag } from "next/cache"; +import { authActionClient } from "../safe-action"; +import { createPolicySchema } from "../schema"; + +export const createPolicyAction = authActionClient + .schema(createPolicySchema) + .metadata({ + name: "create-policy", + track: { + event: "create-policy", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { title, description, frameworkIds, controlIds } = parsedInput; + const { user } = ctx; + + if (!user || !user.organizationId) { + return { + success: false, + error: "Not authorized", + }; + } + + try { + const policy = await db.policy.create({ + data: { + slug: title, + name: title, + description, + content: [ + { type: "paragraph", content: [{ type: "text", text: "" }] }, + ], + usedBy: JSON.stringify([]), + policyFrameworks: { + create: frameworkIds.map((id) => ({ frameworkId: id })), + }, + PolicyControl: { + create: controlIds.map((id) => ({ controlId: id })), + }, + }, + }); + + await db.organizationPolicy.create({ + data: { + organizationId: user.organizationId, + policyId: policy.id, + ownerId: user.id, + department: Departments.none, + frequency: Frequency.monthly, + }, + }); + + revalidatePath("/policies"); + revalidateTag("policies"); + + return { + success: true, + policyId: policy.id, + }; + } catch (error) { + console.error(error); + + return { + success: false, + error: "Failed to create policy", + }; + } + }); diff --git a/apps/app/src/actions/schema.ts b/apps/app/src/actions/schema.ts index 55f3e9d9f4..9e9811edea 100644 --- a/apps/app/src/actions/schema.ts +++ b/apps/app/src/actions/schema.ts @@ -1,327 +1,334 @@ import { - Departments, - Frequency, - PolicyStatus, - RiskCategory, - RiskStatus, - RiskTaskStatus, - VendorCategory, - VendorStatus, + Departments, + Frequency, + PolicyStatus, + RiskCategory, + RiskStatus, + RiskTaskStatus, + VendorCategory, + VendorStatus, } from "@bubba/db"; import { z } from "zod"; export const organizationSchema = z.object({ - fullName: z.string().min(1, "Full name is required"), - name: z.string().min(1, "Name is required"), - website: z.string().url("Must be a valid URL"), - subdomain: z.string().min(1, "Subdomain is required").optional(), + fullName: z.string().min(1, "Full name is required"), + name: z.string().min(1, "Name is required"), + website: z.string().url("Must be a valid URL"), + subdomain: z.string().min(1, "Subdomain is required").optional(), }); export const organizationNameSchema = z.object({ - name: z - .string() - .min(1, "Organization name is required") - .max(255, "Organization name cannot exceed 255 characters"), + name: z + .string() + .min(1, "Organization name is required") + .max(255, "Organization name cannot exceed 255 characters"), }); export const subdomainAvailabilitySchema = z.object({ - subdomain: z - .string() - .min(1, "Subdomain is required") - .max(255, "Subdomain cannot exceed 255 characters") - .regex(/^[a-z0-9-]+$/, { - message: - "Subdomain can only contain lowercase letters, numbers, and hyphens", - }), + subdomain: z + .string() + .min(1, "Subdomain is required") + .max(255, "Subdomain cannot exceed 255 characters") + .regex(/^[a-z0-9-]+$/, { + message: + "Subdomain can only contain lowercase letters, numbers, and hyphens", + }), }); export const uploadSchema = z.object({ - file: z.instanceof(File), - organizationId: z.string().uuid(), + file: z.instanceof(File), + organizationId: z.string().uuid(), }); export const deleteOrganizationSchema = z.object({ - id: z.string().uuid(), - organizationId: z.string().uuid(), + id: z.string().uuid(), + organizationId: z.string().uuid(), }); export const sendFeedbackSchema = z.object({ - feedback: z.string(), + feedback: z.string(), }); export const updaterMenuSchema = z.array( - z.object({ - path: z.string(), - name: z.string(), - }) + z.object({ + path: z.string(), + name: z.string(), + }), ); export const organizationWebsiteSchema = z.object({ - website: z.string().url().max(255), + website: z.string().url().max(255), }); // Risks export const createRiskSchema = z.object({ - title: z - .string({ - required_error: "Risk name is required", - }) - .min(1, { - message: "Risk name should be at least 1 character", - }) - .max(100, { - message: "Risk name should be at most 100 characters", - }), - description: z - .string({ - required_error: "Risk description is required", - }) - .min(1, { - message: "Risk description should be at least 1 character", - }) - .max(255, { - message: "Risk description should be at most 255 characters", - }), - category: z.nativeEnum(RiskCategory, { - required_error: "Risk category is required", - }), - department: z.nativeEnum(Departments, { - required_error: "Risk department is required", - }), - ownerId: z.string({ - required_error: "You must assign an owner to the risk", - }), + title: z + .string({ + required_error: "Risk name is required", + }) + .min(1, { + message: "Risk name should be at least 1 character", + }) + .max(100, { + message: "Risk name should be at most 100 characters", + }), + description: z + .string({ + required_error: "Risk description is required", + }) + .min(1, { + message: "Risk description should be at least 1 character", + }) + .max(255, { + message: "Risk description should be at most 255 characters", + }), + category: z.nativeEnum(RiskCategory, { + required_error: "Risk category is required", + }), + department: z.nativeEnum(Departments, { + required_error: "Risk department is required", + }), + ownerId: z.string({ + required_error: "You must assign an owner to the risk", + }), }); export const updateRiskSchema = z.object({ - id: z.string().min(1, { - message: "Risk ID is required", - }), - title: z.string().min(1, { - message: "Risk title is required", - }), - description: z.string().min(1, { - message: "Risk description is required", - }), - category: z.nativeEnum(RiskCategory, { - required_error: "Risk category is required", - }), - department: z.nativeEnum(Departments, { - required_error: "Risk department is required", - }), - ownerId: z.string({ - required_error: "You must assign an owner to the risk", - }), - status: z.nativeEnum(RiskStatus, { - required_error: "Risk status is required", - }), + id: z.string().min(1, { + message: "Risk ID is required", + }), + title: z.string().min(1, { + message: "Risk title is required", + }), + description: z.string().min(1, { + message: "Risk description is required", + }), + category: z.nativeEnum(RiskCategory, { + required_error: "Risk category is required", + }), + department: z.nativeEnum(Departments, { + required_error: "Risk department is required", + }), + ownerId: z.string({ + required_error: "You must assign an owner to the risk", + }), + status: z.nativeEnum(RiskStatus, { + required_error: "Risk status is required", + }), }); export const createRiskCommentSchema = z.object({ - riskId: z.string().min(1, { - message: "Risk ID is required", - }), - content: z - .string() - .min(1, { - message: "Comment content is required", - }) - .max(1000, { - message: "Comment content should be at most 1000 characters", - }), + riskId: z.string().min(1, { + message: "Risk ID is required", + }), + content: z + .string() + .min(1, { + message: "Comment content is required", + }) + .max(1000, { + message: "Comment content should be at most 1000 characters", + }), }); export const createTaskSchema = z.object({ - riskId: z.string().min(1, { - message: "Risk ID is required", - }), - title: z.string().min(1, { - message: "Task title is required", - }), - description: z.string().min(1, { - message: "Task description is required", - }), - dueDate: z.date().optional(), - ownerId: z - .string({ - required_error: "You must assign an owner to the task", - }) - .min(1, { - message: "You must assign an owner to the task", - }), + riskId: z.string().min(1, { + message: "Risk ID is required", + }), + title: z.string().min(1, { + message: "Task title is required", + }), + description: z.string().min(1, { + message: "Task description is required", + }), + dueDate: z.date().optional(), + ownerId: z + .string({ + required_error: "You must assign an owner to the task", + }) + .min(1, { + message: "You must assign an owner to the task", + }), }); export const updateTaskSchema = z.object({ - id: z.string().min(1, { - message: "Task ID is required", - }), - title: z.string().optional(), - description: z.string().optional(), - dueDate: z.date().optional(), - status: z.nativeEnum(RiskTaskStatus, { - required_error: "Task status is required", - }), - ownerId: z.string({ - required_error: "You must assign an owner to the task", - }), + id: z.string().min(1, { + message: "Task ID is required", + }), + title: z.string().optional(), + description: z.string().optional(), + dueDate: z.date().optional(), + status: z.nativeEnum(RiskTaskStatus, { + required_error: "Task status is required", + }), + ownerId: z.string({ + required_error: "You must assign an owner to the task", + }), }); export const createTaskCommentSchema = z.object({ - riskId: z.string().min(1, { - message: "Risk ID is required", - }), - taskId: z.string().min(1, { - message: "Task ID is required", - }), - content: z - .string() - .min(1, { - message: "Comment content is required", - }) - .max(1000, { - message: "Comment content should be at most 1000 characters", - }), + riskId: z.string().min(1, { + message: "Risk ID is required", + }), + taskId: z.string().min(1, { + message: "Task ID is required", + }), + content: z + .string() + .min(1, { + message: "Comment content is required", + }) + .max(1000, { + message: "Comment content should be at most 1000 characters", + }), }); export const uploadTaskFileSchema = z.object({ - riskId: z.string().min(1, { - message: "Risk ID is required", - }), - taskId: z.string().min(1, { - message: "Task ID is required", - }), + riskId: z.string().min(1, { + message: "Risk ID is required", + }), + taskId: z.string().min(1, { + message: "Task ID is required", + }), }); // Integrations export const deleteIntegrationConnectionSchema = z.object({ - integrationId: z.string().min(1, { - message: "Integration ID is required", - }), + integrationId: z.string().min(1, { + message: "Integration ID is required", + }), }); export const createIntegrationSchema = z.object({ - integrationId: z.string().min(1, { - message: "Integration ID is required", - }), + integrationId: z.string().min(1, { + message: "Integration ID is required", + }), }); // Seed Data export const seedDataSchema = z.object({ - organizationId: z.string(), + organizationId: z.string(), }); export const updateInherentRiskSchema = z.object({ - id: z.string().min(1, { - message: "Risk ID is required", - }), - probability: z.number().min(1).max(10), - impact: z.number().min(1).max(10), + id: z.string().min(1, { + message: "Risk ID is required", + }), + probability: z.number().min(1).max(10), + impact: z.number().min(1).max(10), }); export const updateResidualRiskSchema = z.object({ - id: z.string().min(1, { - message: "Risk ID is required", - }), - probability: z.number().min(1).max(10), - impact: z.number().min(1).max(10), + id: z.string().min(1, { + message: "Risk ID is required", + }), + probability: z.number().min(1).max(10), + impact: z.number().min(1).max(10), }); export const vendorContactSchema = z.object({ - name: z.string().min(1, "Name is required"), - email: z.string().email("Invalid email address"), - role: z.string().min(1, "Role is required"), + name: z.string().min(1, "Name is required"), + email: z.string().email("Invalid email address"), + role: z.string().min(1, "Role is required"), }); export const createVendorSchema = z.object({ - name: z.string().min(1, "Name is required"), - website: z.string().url("Must be a valid URL"), - description: z.string().min(1, "Description is required"), - category: z.nativeEnum(VendorCategory), - ownerId: z.string().optional(), - contacts: z - .array(vendorContactSchema) - .min(1, "At least one contact is required"), + name: z.string().min(1, "Name is required"), + website: z.string().url("Must be a valid URL"), + description: z.string().min(1, "Description is required"), + category: z.nativeEnum(VendorCategory), + ownerId: z.string().optional(), + contacts: z + .array(vendorContactSchema) + .min(1, "At least one contact is required"), }); export const updateVendorSchema = z.object({ - id: z.string(), - name: z.string().min(1, "Name is required"), - website: z.string().url("Must be a valid URL"), - description: z.string().min(1, "Description is required"), - category: z.nativeEnum(VendorCategory), - status: z.nativeEnum(VendorStatus), - ownerId: z.string().optional(), - contacts: z - .array(vendorContactSchema) - .min(1, "At least one contact is required"), + id: z.string(), + name: z.string().min(1, "Name is required"), + website: z.string().url("Must be a valid URL"), + description: z.string().min(1, "Description is required"), + category: z.nativeEnum(VendorCategory), + status: z.nativeEnum(VendorStatus), + ownerId: z.string().optional(), + contacts: z + .array(vendorContactSchema) + .min(1, "At least one contact is required"), }); export const createVendorCommentSchema = z.object({ - vendorId: z.string(), - content: z.string().min(1), + vendorId: z.string(), + content: z.string().min(1), }); export const updateVendorRiskSchema = z.object({ - id: z.string(), - inherent_risk: z.enum(["low", "medium", "high", "unknown"]).optional(), - residual_risk: z.enum(["low", "medium", "high", "unknown"]).optional(), + id: z.string(), + inherent_risk: z.enum(["low", "medium", "high", "unknown"]).optional(), + residual_risk: z.enum(["low", "medium", "high", "unknown"]).optional(), }); // Policies +export const createPolicySchema = z.object({ + title: z.string(), + description: z.string(), + frameworkIds: z.array(z.string()), + controlIds: z.array(z.string()), +}); + export const updatePolicySchema = z.object({ - id: z.string(), - content: z.any(), + id: z.string(), + content: z.any(), }); export const assistantSettingsSchema = z.object({ - enabled: z.boolean().optional(), + enabled: z.boolean().optional(), }); export const createEmployeeSchema = z.object({ - name: z.string().min(1, "Name is required"), - email: z.string().email("Invalid email address"), - department: z.nativeEnum(Departments, { - required_error: "Department is required", - }), - externalEmployeeId: z.string().optional(), - isActive: z.boolean().default(true), + name: z.string().min(1, "Name is required"), + email: z.string().email("Invalid email address"), + department: z.nativeEnum(Departments, { + required_error: "Department is required", + }), + externalEmployeeId: z.string().optional(), + isActive: z.boolean().default(true), }); export const updatePolicyOverviewSchema = z.object({ - id: z.string(), - title: z.string(), - description: z.string(), - isRequiredToSign: z.enum(["required", "not_required"]).optional(), + id: z.string(), + title: z.string(), + description: z.string(), + isRequiredToSign: z.enum(["required", "not_required"]).optional(), }); export const updatePolicyFormSchema = z.object({ - id: z.string(), - status: z.nativeEnum(PolicyStatus), - ownerId: z.string(), - department: z.nativeEnum(Departments), - review_frequency: z.nativeEnum(Frequency), - review_date: z.date(), - isRequiredToSign: z.enum(["required", "not_required"]).optional(), + id: z.string(), + status: z.nativeEnum(PolicyStatus), + ownerId: z.string(), + department: z.nativeEnum(Departments), + review_frequency: z.nativeEnum(Frequency), + review_date: z.date(), + isRequiredToSign: z.enum(["required", "not_required"]).optional(), }); export const apiKeySchema = z.object({ - name: z - .string() - .min(1, { message: "Name is required" }) - .max(64, { message: "Name must be less than 64 characters" }), - expiresAt: z.enum(["30days", "90days", "1year", "never"]), + name: z + .string() + .min(1, { message: "Name is required" }) + .max(64, { message: "Name must be less than 64 characters" }), + expiresAt: z.enum(["30days", "90days", "1year", "never"]), }); export const createPolicyCommentSchema = z.object({ - policyId: z.string().min(1, { - message: "Policy ID is required", - }), - content: z - .string() - .min(1, { - message: "Comment content is required", - }) - .max(1000, { - message: "Comment content should be at most 1000 characters", - }), + policyId: z.string().min(1, { + message: "Policy ID is required", + }), + content: z + .string() + .min(1, { + message: "Comment content is required", + }) + .max(1000, { + message: "Comment content should be at most 1000 characters", + }), }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/SingleControlSkeleton.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/SingleControlSkeleton.tsx index c591f2589e..fb8a0d051f 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/SingleControlSkeleton.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/(home)/overview/frameworks/controls/[id]/components/SingleControlSkeleton.tsx @@ -3,32 +3,32 @@ import { CardHeader, CardContent, CardTitle } from "@bubba/ui/card"; import { Card } from "@bubba/ui/card"; export const SingleControlSkeleton = () => { - return ( -
-
-
- - -
- - -
- - - - - Domain - - -
- - -
+ return ( +
+
+
+ + +
+ + +
+ + + + + Domain + + +
+ + +
-
-
-
-
-
- ); +
+
+
+
+
+ ); }; diff --git a/apps/app/src/components/forms/policies/create-new-policy.tsx b/apps/app/src/components/forms/policies/create-new-policy.tsx new file mode 100644 index 0000000000..24135e6bf0 --- /dev/null +++ b/apps/app/src/components/forms/policies/create-new-policy.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { createPolicyAction } from "@/actions/policies/create-new-policy"; +import { createPolicySchema } from "@/actions/schema"; +import { useI18n } from "@/locales/client"; +import { Button } from "@bubba/ui/button"; +import { Textarea } from "@bubba/ui/textarea"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useAction } from "next-safe-action/hooks"; +import { useQueryState } from "nuqs"; +import { Input } from "@bubba/ui/input"; +import { ArrowRightIcon } from "lucide-react"; +import { toast } from "sonner"; +import type { z } from "zod"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@bubba/ui/accordion"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@bubba/ui/form"; +import React from "react"; + +export function CreateNewPolicyForm() { + const t = useI18n(); + const [_, setCreatePolicySheet] = useQueryState("create-policy-sheet"); + + const createPolicy = useAction(createPolicyAction, { + onSuccess: () => { + toast.success(t("policies.new.success")); + setCreatePolicySheet(null); + }, + onError: () => { + toast.error(t("policies.new.error")); + } + }) + + const form = useForm>({ + resolver: zodResolver(createPolicySchema), + defaultValues: { + title: "", + description: "", + frameworkIds: [], + controlIds: [], + } + }); + + const onSubmit = (data: z.infer) => { + createPolicy.execute(data); + } + + return ( +
+ +
+
+ + + {t("policies.new.details")} + +
+ ( + + {t("policies.new.title")} + + + + + + )} + /> + ( + + {t("policies.new.description")} + +