From a18a861fca705463cb79faf58228aefb4841659c Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Thu, 20 Mar 2025 15:05:07 -0400 Subject: [PATCH 01/13] feat: add unauthorized access page and enable auth interrupts - Introduced a new `UnauthorizedPage` component for handling unauthorized access, featuring localized messages. - Updated the `en.ts` locale file with translations for unauthorized access errors. - Enabled experimental `authInterrupts` in the Next.js configuration for improved authentication handling. --- apps/app/next.config.ts | 3 +++ apps/app/src/app/[locale]/unauthorized.tsx | 29 ++++++++++++++++++++++ apps/app/src/locales/en.ts | 5 ++++ 3 files changed, 37 insertions(+) create mode 100644 apps/app/src/app/[locale]/unauthorized.tsx diff --git a/apps/app/next.config.ts b/apps/app/next.config.ts index 74bcbeda2b..1b492893e2 100644 --- a/apps/app/next.config.ts +++ b/apps/app/next.config.ts @@ -17,6 +17,9 @@ const config = { fullUrl: process.env.LOG_FETCHES === "true", }, }, + experimental: { + authInterrupts: true, + }, async rewrites() { return [ { diff --git a/apps/app/src/app/[locale]/unauthorized.tsx b/apps/app/src/app/[locale]/unauthorized.tsx new file mode 100644 index 0000000000..7a8e326ce9 --- /dev/null +++ b/apps/app/src/app/[locale]/unauthorized.tsx @@ -0,0 +1,29 @@ +import { getI18n } from "@/locales/server"; +import { Button } from "@bubba/ui/button"; +import { Card } from "@bubba/ui/card"; +import Link from "next/link"; + +export default async function UnauthorizedPage() { + const t = await getI18n(); + + return ( +
+ +
+

+ {t("errors.unauthorized.title")} +

+

+ {t("errors.unauthorized.description")} +

+
+ +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/app/src/locales/en.ts b/apps/app/src/locales/en.ts index 8714dbce38..c05135f573 100644 --- a/apps/app/src/locales/en.ts +++ b/apps/app/src/locales/en.ts @@ -1040,6 +1040,11 @@ export default { }, errors: { unexpected: "Something went wrong, please try again", + unauthorized: { + title: "Unauthorized Access", + description: "You don't have permission to access this resource. Please contact your administrator if you believe this is a mistake.", + back: "Return to Home" + } }, editor: { ai: { From 758594bcc9913021357f3511bc4c95d8d991cdee Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Thu, 20 Mar 2025 15:05:18 -0400 Subject: [PATCH 02/13] refactor: clean up layout component imports - Removed unused imports for `db` and `cache` in the layout component. - Improved code clarity by streamlining the import statements. --- apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/layout.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/layout.tsx index b14581486d..e7ad4948ad 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/layout.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/layout.tsx @@ -1,10 +1,8 @@ import { auth } from "@/auth"; import { Header } from "@/components/header"; import { Sidebar } from "@/components/sidebar"; -import { db } from "@bubba/db"; import dynamic from "next/dynamic"; import { redirect } from "next/navigation"; -import { cache } from "react"; const HotKeys = dynamic( () => import("@/components/hot-keys").then((mod) => mod.HotKeys), From 731e347a876213cfb0da16d919f6cc379e7cd184 Mon Sep 17 00:00:00 2001 From: Languine Bot Date: Thu, 20 Mar 2025 19:05:58 +0000 Subject: [PATCH 03/13] chore: (i18n) update translations using Languine.ai --- apps/app/languine.lock | 3 +++ apps/app/src/locales/es.ts | 7 ++++++- apps/app/src/locales/fr.ts | 7 ++++++- apps/app/src/locales/no.ts | 7 ++++++- apps/app/src/locales/pt.ts | 7 ++++++- 5 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/app/languine.lock b/apps/app/languine.lock index dc6a2e67d7..b58ac56192 100644 --- a/apps/app/languine.lock +++ b/apps/app/languine.lock @@ -669,6 +669,9 @@ files: frameworks.controls.statuses.in_progress: c9413dd7682a45da9ecc9efa8cea47d7 frameworks.controls.statuses.not_started: 10db56f8b10dc989c43fbe33202e5339 errors.unexpected: 325a357f60b95f37fec18b15cb39fbb0 + errors.unauthorized.title: 2a5277746841d80f9489dd85fabc769d + errors.unauthorized.description: 118ce881f1bd6df5785ce15698255334 + errors.unauthorized.back: 56cf8b33ab527ab81b4f6b3ceac090dd editor.ai.thinking: aba464039b337eb2e75c2520039de58f editor.ai.thinking_spinner: aba464039b337eb2e75c2520039de58f editor.ai.edit_or_generate: 7d46fd8c65c35acb13973849b035cc53 diff --git a/apps/app/src/locales/es.ts b/apps/app/src/locales/es.ts index 476a6e2105..19ebf4fbf2 100644 --- a/apps/app/src/locales/es.ts +++ b/apps/app/src/locales/es.ts @@ -951,7 +951,12 @@ export default { } }, errors: { - unexpected: "Algo salió mal, por favor intenta de nuevo" + unexpected: "Algo salió mal, por favor intenta de nuevo", + unauthorized: { + title: "Acceso no autorizado", + description: "No tienes permiso para acceder a este recurso. Por favor, contacta a tu administrador si crees que esto es un error.", + back: "Regresar a Inicio" + } }, sub_pages: { risk: { diff --git a/apps/app/src/locales/fr.ts b/apps/app/src/locales/fr.ts index 1cea34010d..f03d77d071 100644 --- a/apps/app/src/locales/fr.ts +++ b/apps/app/src/locales/fr.ts @@ -951,7 +951,12 @@ export default { } }, errors: { - unexpected: "Quelque chose s'est mal passé, veuillez réessayer" + unexpected: "Quelque chose s'est mal passé, veuillez réessayer", + unauthorized: { + title: "Accès non autorisé", + description: "Vous n'avez pas la permission d'accéder à cette ressource. Veuillez contacter votre administrateur si vous pensez qu'il s'agit d'une erreur.", + back: "Retour à l'accueil" + } }, sub_pages: { risk: { diff --git a/apps/app/src/locales/no.ts b/apps/app/src/locales/no.ts index 7f16a77d21..1e309c4510 100644 --- a/apps/app/src/locales/no.ts +++ b/apps/app/src/locales/no.ts @@ -951,7 +951,12 @@ export default { } }, errors: { - unexpected: "Noe gikk galt, vennligst prøv igjen" + unexpected: "Noe gikk galt, vennligst prøv igjen", + unauthorized: { + title: "Uautorisert tilgang", + description: "Du har ikke tillatelse til å få tilgang til denne ressursen. Vennligst kontakt administratoren din hvis du mener dette er en feil.", + back: "Gå tilbake til Hjem" + } }, sub_pages: { risk: { diff --git a/apps/app/src/locales/pt.ts b/apps/app/src/locales/pt.ts index 074844856c..a345164c60 100644 --- a/apps/app/src/locales/pt.ts +++ b/apps/app/src/locales/pt.ts @@ -951,7 +951,12 @@ export default { } }, errors: { - unexpected: "Algo deu errado, por favor tente novamente" + unexpected: "Algo deu errado, por favor tente novamente", + unauthorized: { + title: "Acesso Não Autorizado", + description: "Você não tem permissão para acessar este recurso. Por favor, entre em contato com seu administrador se você acreditar que isso é um erro.", + back: "Voltar para a Página Inicial" + } }, sub_pages: { risk: { From bedb9b75b92423ec003add44824a7996fa464d3b Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 21 Mar 2025 11:03:52 -0400 Subject: [PATCH 04/13] feat: implement vendor management features - Renamed `RiskManagement` to `VendorManagement` for clarity. - Added `CreateVendor` component for vendor creation with form validation. - Introduced `createVendorAction` for handling vendor creation logic with type-safe server actions. - Updated locale file with new translations for vendor-related terms and messages. - Enhanced the UI to display a message when no vendors are found, prompting users to add a vendor. - Refactored database queries to count vendors instead of risks. --- .../[orgId]/vendors/(overview)/page.tsx | 63 ++++--- .../vendors/actions/create-vendor-action.ts | 50 ++++++ ...e-risk-form.tsx => create-vendor-form.tsx} | 165 ++++++++++-------- .../components/create-vendor-sheet.tsx | 10 +- apps/app/src/locales/en.ts | 61 +++++++ 5 files changed, 245 insertions(+), 104 deletions(-) create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/actions/create-vendor-action.ts rename apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/components/{create-risk-form.tsx => create-vendor-form.tsx} (61%) diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/(overview)/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/(overview)/page.tsx index 50bff17745..bceac02896 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/(overview)/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/(overview)/page.tsx @@ -1,14 +1,15 @@ import { auth } from "@/auth"; -import { RiskOverview } from "@/components/risks/charts/risk-overview"; -import { RisksAssignee } from "@/components/risks/charts/risks-assignee"; import { getI18n } from "@/locales/server"; import { db } from "@bubba/db"; +import { Button } from "@bubba/ui/button"; +import { Card } from "@bubba/ui/card"; +import Link from "next/link"; import type { Metadata } from "next"; import { setStaticParamsLocale } from "next-international/server"; -import { unstable_cache } from "next/cache"; import { redirect } from "next/navigation"; +import { CreateVendorSheet } from "../components/create-vendor-sheet"; -export default async function RiskManagement({ +export default async function VendorManagement({ params, }: { params: Promise<{ locale: string }>; @@ -22,10 +23,31 @@ export default async function RiskManagement({ redirect("/onboarding"); } - const overview = await getRiskOverview(session.user.organizationId); + const overview = await getVendorOverview(session.user.organizationId); - if (overview?.risks === 0) { - redirect(`/${session.user.organizationId}/risk/register`); + if (overview?.vendors === 0) { + return ( +
+ +
+

+ No vendors found +

+

+ Get started by adding your first vendor +

+
+ +
+ +
+
+
+ ); } return ( @@ -42,22 +64,19 @@ export default async function RiskManagement({ ); } -const getRiskOverview = unstable_cache( - async (organizationId: string) => { - return await db.$transaction(async (tx) => { - const [risks] = await Promise.all([ - tx.risk.count({ - where: { organizationId }, - }), - ]); +async function getVendorOverview(organizationId: string) { + return await db.$transaction(async (tx) => { + const [vendors] = await Promise.all([ + tx.vendor.count({ + where: { organizationId }, + }), + ]); - return { - risks, - }; - }); - }, - ["risk-overview-cache"], -); + return { + vendors, + }; + }); +} export async function generateMetadata({ params, diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/actions/create-vendor-action.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/actions/create-vendor-action.ts new file mode 100644 index 0000000000..58394ca105 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/actions/create-vendor-action.ts @@ -0,0 +1,50 @@ +'use server' + +import { createSafeActionClient } from "next-safe-action"; +import { z } from "zod"; +import type { ActionResponse } from '@/types/actions'; +import { VendorStatus, VendorCategory } from "@bubba/db/types"; +import { revalidatePath } from "next/cache"; +import { auth } from "@/auth"; +import { db } from "@bubba/db"; + +const schema = z.object({ + name: z.string().min(1, "Name is required"), + website: z.string().url("Must be a valid URL").optional(), + description: z.string().optional(), + category: z.nativeEnum(VendorCategory), + status: z.nativeEnum(VendorStatus).default(VendorStatus.not_assessed), + ownerId: z.string().optional(), +}); + +export const createVendorAction = createSafeActionClient() + .schema(schema) + .action(async (input): Promise => { + try { + const session = await auth(); + + if (!session?.user?.organizationId) { + throw new Error("Unauthorized"); + } + + const vendor = await db.vendor.create({ + data: { + name: input.parsedInput.name, + description: input.parsedInput.description || "", + category: input.parsedInput.category, + status: input.parsedInput.status, + ownerId: input.parsedInput.ownerId, + organizationId: session.user.organizationId, + } + }); + + revalidatePath(`/${session.user.organizationId}/vendors`); + + return { success: true, data: vendor }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Failed to create vendor" + }; + } + }); \ No newline at end of file diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/components/create-risk-form.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/components/create-vendor-form.tsx similarity index 61% rename from apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/components/create-risk-form.tsx rename to apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/components/create-vendor-form.tsx index d79fce263d..de7aa7b4b3 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/components/create-risk-form.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/components/create-vendor-form.tsx @@ -1,13 +1,9 @@ "use client"; -import { createRiskAction } from "@/actions/risk/create-risk-action"; -import { createRiskSchema } from "@/actions/schema"; import { useOrganizationAdmins } from "@/app/[locale]/(app)/(dashboard)/[orgId]/hooks/useOrganizationAdmins"; -import { useRisks } from "@/app/[locale]/(app)/(dashboard)/[orgId]/risk/register/hooks/useRisks"; import { SelectUser } from "@/components/select-user"; import { useI18n } from "@/locales/client"; -import type { RiskStatus } from "@bubba/db/types"; -import { Departments, RiskCategory } from "@bubba/db/types"; +import { VendorCategory, VendorStatus } from "@bubba/db/types"; import { Accordion, AccordionContent, @@ -34,11 +30,13 @@ import { import { Textarea } from "@bubba/ui/textarea"; import { zodResolver } from "@hookform/resolvers/zod"; import { ArrowRightIcon } from "lucide-react"; +import { useSession } from "next-auth/react"; import { useAction } from "next-safe-action/hooks"; import { useQueryState } from "nuqs"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import type { z } from "zod"; +import { z } from "zod"; +import { createVendorAction } from "../actions/create-vendor-action"; interface User { id: string; @@ -46,8 +44,18 @@ interface User { name: string | null; } -export function CreateRisk() { +const createVendorSchema = z.object({ + name: z.string().min(1, "Name is required"), + website: z.string().url("Must be a valid URL").optional(), + description: z.string().optional(), + category: z.nativeEnum(VendorCategory), + status: z.nativeEnum(VendorStatus).default(VendorStatus.not_assessed), + ownerId: z.string().optional(), +}); + +export function CreateVendor() { const t = useI18n(); + const session = useSession(); // Get the same query parameters as the table const [search] = useQueryState("search"); @@ -59,55 +67,51 @@ export function CreateRisk() { defaultValue: 10, parse: Number, }); - const [status] = useQueryState("status", { + const [status] = useQueryState("status", { defaultValue: null, - parse: (value) => value as RiskStatus | null, + parse: (value) => value as VendorStatus | null, }); - const [department] = useQueryState("department", { + const [category] = useQueryState("category", { defaultValue: null, - parse: (value) => value as Departments | null, + parse: (value) => value as VendorCategory | null, }); const [assigneeId] = useQueryState("assigneeId", { defaultValue: null, parse: (value) => value, }); - const { mutate: mutateRisks } = useRisks({ - search: search || "", - page: Number(page), - pageSize: Number(pageSize), - status, - department, - assigneeId, - }); - const { data: admins, isLoading: isLoadingAdmins } = useOrganizationAdmins(); - const [_, setCreateRiskSheet] = useQueryState("create-risk-sheet"); + const [_, setCreateVendorSheet] = useQueryState("create-vendor-sheet"); - const createRisk = useAction(createRiskAction, { + const createVendor = useAction(createVendorAction, { onSuccess: async () => { - toast.success(t("risk.form.create_risk_success")); - setCreateRiskSheet(null); - // Force invalidate and revalidate the risks list - await mutateRisks(); + const organizationId = session.data?.user?.organizationId; + + if (!organizationId) { + toast.error(t("vendors.form.create_vendor_error")); + return; + } + + toast.success(t("vendors.form.create_vendor_success")); + setCreateVendorSheet(null); }, onError: () => { - toast.error(t("risk.form.create_risk_error")); + toast.error(t("vendors.form.create_vendor_error")); }, }); - const form = useForm>({ - resolver: zodResolver(createRiskSchema), + const form = useForm>({ + resolver: zodResolver(createVendorSchema), defaultValues: { - title: "", + name: "", description: "", - category: RiskCategory.operations, - department: Departments.admin, + category: VendorCategory.cloud, + status: VendorStatus.not_assessed, }, }); - const onSubmit = (data: z.infer) => { - createRisk.execute(data); + const onSubmit = (data: z.infer) => { + createVendor.execute(data); }; return ( @@ -115,27 +119,25 @@ export function CreateRisk() {
- - + + - {t("risk.form.risk_details")} + {t("vendors.form.vendor_details")}
( - {t("risk.form.risk_title")} + {t("vendors.form.vendor_name")} @@ -143,21 +145,35 @@ export function CreateRisk() { )} /> + ( + + {t("vendors.form.vendor_website")} + + + + + + )} + /> ( - - {t("risk.form.risk_description")} - + {t("vendors.form.vendor_description")}