From e2e8f38f974c64e5c23dc0fc7beb7bde8ebd9dbf Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Tue, 22 Apr 2025 08:03:35 -0700 Subject: [PATCH] Add vendor risk assement Signed-off-by: Bryan Frimin --- CHANGELOG.md | 18 + .../organizations/vendors/ListVendorView.tsx | 4 - .../organizations/vendors/VendorView.tsx | 1169 +++++++++++------ ...tVendorViewCreateVendorMutation.graphql.ts | 20 +- .../ListVendorViewPaginationQuery.graphql.ts | 15 +- .../ListVendorViewQuery.graphql.ts | 13 +- .../ListVendorView_vendors.graphql.ts | 13 +- ...iewCreateRiskAssessmentMutation.graphql.ts | 238 ++++ .../__generated__/VendorViewQuery.graphql.ts | 263 ++-- .../VendorViewUpdateVendorMutation.graphql.ts | 28 +- pkg/coredata/migrations/20250422T004000Z.sql | 3 + pkg/coredata/migrations/20250422T004300Z.sql | 1 + pkg/coredata/vendor_risk_assessment.go | 8 +- .../v1/invitation_confirmation_handler.go | 5 +- pkg/server/api/console/v1/resolver.go | 2 +- pkg/server/api/console/v1/v1_resolver.go | 22 +- pkg/usrmgr/usrmgr.go | 35 +- 17 files changed, 1275 insertions(+), 582 deletions(-) create mode 100644 apps/console/src/pages/organizations/vendors/__generated__/VendorViewCreateRiskAssessmentMutation.graphql.ts create mode 100644 pkg/coredata/migrations/20250422T004000Z.sql create mode 100644 pkg/coredata/migrations/20250422T004300Z.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bff4631e..fe350380a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- New "Risk assessments" tab for vendors that allows you to: + - View all risk assessments for a vendor in one place + - Create new risk assessments with data sensitivity and business impact ratings + - Track assessment expiration dates +- Automatic people record creation when accepting invitations +- New vendors in the built-in lists +- Introduced a connector framework enabling integration with external + services: + - Add OAuth2 connector implementation + +### Changed + +- Completely redesigned vendor detail page with a cleaner, more intuitive layout +- Improved compliance reports table with better file size formatting and date display +- People may be linked to user + ## [0.12.0] - 2025-04-20 ### Added diff --git a/apps/console/src/pages/organizations/vendors/ListVendorView.tsx b/apps/console/src/pages/organizations/vendors/ListVendorView.tsx index 57ae99b4e..045418f3e 100644 --- a/apps/console/src/pages/organizations/vendors/ListVendorView.tsx +++ b/apps/console/src/pages/organizations/vendors/ListVendorView.tsx @@ -85,7 +85,6 @@ const vendorListFragment = graphql` description createdAt updatedAt - riskTier } } pageInfo { @@ -111,7 +110,6 @@ const createVendorMutation = graphql` description createdAt updatedAt - riskTier } } } @@ -322,8 +320,6 @@ function ListVendorContent({ statusPageUrl: vendor.statusPageUrl, termsOfServiceUrl: vendor.termsOfServiceUrl, serviceStartAt: new Date().toISOString(), - serviceCriticality: "LOW", - riskTier: "GENERAL", }, }, onCompleted() { diff --git a/apps/console/src/pages/organizations/vendors/VendorView.tsx b/apps/console/src/pages/organizations/vendors/VendorView.tsx index b63f13ebe..96b9950e8 100644 --- a/apps/console/src/pages/organizations/vendors/VendorView.tsx +++ b/apps/console/src/pages/organizations/vendors/VendorView.tsx @@ -19,6 +19,7 @@ import type { VendorViewQuery as VendorViewQueryType } from "./__generated__/Ven import type { VendorViewDeleteComplianceReportMutation as DeleteComplianceReportMutationType } from "./__generated__/VendorViewDeleteComplianceReportMutation.graphql"; import type { VendorViewUploadComplianceReportMutation as UploadComplianceReportMutationType } from "./__generated__/VendorViewUploadComplianceReportMutation.graphql"; import type { VendorViewUpdateVendorMutation } from "./__generated__/VendorViewUpdateVendorMutation.graphql"; +import type { VendorViewCreateRiskAssessmentMutation } from "./__generated__/VendorViewCreateRiskAssessmentMutation.graphql"; import { useParams } from "react-router"; import { cn } from "@/lib/utils"; import { PageTemplate } from "@/components/PageTemplate"; @@ -34,8 +35,6 @@ const vendorViewQuery = graphql` description serviceStartAt serviceTerminationAt - serviceCriticality - riskTier statusPageUrl termsOfServiceUrl privacyPolicyUrl @@ -71,6 +70,24 @@ const vendorViewQuery = graphql` } } } + riskAssessments(first: 100) + @connection(key: "VendorView_riskAssessments") { + edges { + node { + id + assessedAt + expiresAt + dataSensitivity + businessImpact + notes + assessedBy { + id + fullName + } + createdAt + } + } + } } } organization: node(id: $organizationId) { @@ -88,8 +105,6 @@ const updateVendorMutation = graphql` description serviceStartAt serviceTerminationAt - serviceCriticality - riskTier statusPageUrl termsOfServiceUrl privacyPolicyUrl @@ -147,6 +162,31 @@ const uploadComplianceReportMutation = graphql` } `; +const createRiskAssessmentMutation = graphql` + mutation VendorViewCreateRiskAssessmentMutation( + $input: CreateVendorRiskAssessmentInput! + $connections: [ID!]! + ) { + createVendorRiskAssessment(input: $input) { + vendorRiskAssessmentEdge @appendEdge(connections: $connections) { + node { + id + assessedAt + expiresAt + dataSensitivity + businessImpact + notes + assessedBy { + id + fullName + } + createdAt + } + } + } + } +`; + function EditableField({ label, value, @@ -214,86 +254,130 @@ function ComplianceReportsTable({ onUpload: (event: React.ChangeEvent) => void; }) { const fileInputRef = useRef(null); + const [showDropdown, setShowDropdown] = useState(null); + const dropdownRef = useRef(null); - // Truncate filename to a reasonable length if needed - const truncateFilename = (filename: string, maxLength = 40) => { - if (filename.length <= maxLength) return filename; - const extension = filename.split(".").pop(); - const name = filename.substring(0, maxLength - extension!.length - 3); - return `${name}...${extension}`; + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setShowDropdown(null); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + // Format file size to human-readable format + const formatFileSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + }; + + // Format date to match Figma design + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + weekday: 'short', + day: 'numeric', + month: 'short', + year: 'numeric', + }); }; return (
-
-

Compliance Reports

-

- Upload and manage compliance reports for this vendor -

-
-
-
- - - - - - - - - - - - {reports.map((report) => ( - - + + ))} + {reports.length === 0 && ( + + + + )} + +
- Report Name - - Report Date - - Valid Until - - File Size - - Actions -
+
+ + + + + + + + + + + {reports.map((report) => ( + + - - - - + + + - - ))} - -
+
+ Report name +
+
+
+ Report date +
+
+
+ Valid until +
+
+ - {new Date(report.reportDate).toLocaleDateString()} - - {report.validUntil - ? new Date(report.validUntil).toLocaleDateString() - : "N/A"} - - {(report.fileSize / 1024 / 1024).toFixed(2)} MB - - + + {formatDate(report.reportDate)} + + + + {report.validUntil ? formatDate(report.validUntil) : 'N/A'} + + +
+ -
-
+ + + + + + + {showDropdown === report.id && ( +
+ +
+ )} + +
+ No compliance reports uploaded yet +
-

+

Only PDF files up to 10MB are allowed

@@ -342,9 +427,9 @@ function TagList({ {tags.map((tag) => (
- {tag} + {tag}
))}
- setNewTag(e.target.value)} - onKeyDown={handleAddTag} - placeholder="Type and press Enter to add a certification" - className="mt-2" - /> +
+ setNewTag(e.target.value)} + onKeyDown={handleAddTag} + placeholder="Type and press Enter to add a certification" + className="border-0 bg-transparent p-1 shadow-none focus-visible:ring-0" + /> +
+
+ ); +} + +interface RiskAssessment { + id: string; + assessedAt: string; + expiresAt: string; + dataSensitivity: string; + businessImpact: string; + notes: string | null; + assessedBy: { + id: string; + fullName: string; + } | null; + createdAt: string; +} + +function RiskAssessmentsTable({ + assessments, + onCreateAssessment, +}: { + assessments: RiskAssessment[]; + onCreateAssessment: () => void; +}) { + const [showDropdown, setShowDropdown] = useState(null); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setShowDropdown(null); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + // Format date to match Figma design + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + weekday: 'short', + day: 'numeric', + month: 'short', + year: 'numeric', + }); + }; + + // Get severity label based on data sensitivity and business impact + const getSeverityLabel = (dataSensitivity: string, businessImpact: string) => { + // Simple logic to determine severity - can be adjusted based on requirements + if (dataSensitivity === 'CRITICAL' || businessImpact === 'CRITICAL') { + return { label: 'Critical', color: 'bg-red-100 text-red-800' }; + } else if (dataSensitivity === 'HIGH' || businessImpact === 'HIGH') { + return { label: 'High', color: 'bg-orange-100 text-orange-800' }; + } else if (dataSensitivity === 'MEDIUM' || businessImpact === 'MEDIUM') { + return { label: 'Medium', color: 'bg-yellow-100 text-yellow-800' }; + } else { + return { label: 'Low', color: 'bg-green-100 text-green-800' }; + } + }; + + return ( +
+
+ + + + + + + + + + + + + + {assessments.map((assessment) => { + const severity = getSeverityLabel(assessment.dataSensitivity, assessment.businessImpact); + return ( + + + + + + + + + + ); + })} + {assessments.length === 0 && ( + + + + )} + +
+
+ Assessed Date +
+
+
+ Expires +
+
+
+ Data Sensitivity +
+
+
+ Business Impact +
+
+
+ Severity +
+
+
+ Assessed By +
+
+ + {formatDate(assessment.assessedAt)} + + + + {formatDate(assessment.expiresAt)} + + + + {assessment.dataSensitivity.charAt(0) + assessment.dataSensitivity.slice(1).toLowerCase()} + + + + {assessment.businessImpact.charAt(0) + assessment.businessImpact.slice(1).toLowerCase()} + + + + {severity.label} + + + + {assessment.assessedBy?.fullName || 'N/A'} + + +
+ + {showDropdown === assessment.id && ( +
+ +
+ )} +
+
+ No risk assessments created yet +
+
+
+ +

+ Create a new risk assessment for this vendor +

+
); } @@ -373,14 +652,13 @@ function VendorViewContent({ }) { const { organizationId } = useParams(); const data = usePreloadedQuery(vendorViewQuery, queryRef); + const [activeTab, setActiveTab] = useState<'overview' | 'certifications' | 'complianceReports' | 'riskAssessments'>('overview'); const [editedFields, setEditedFields] = useState>(new Set()); const [formData, setFormData] = useState({ name: data.node.name || "", description: data.node.description || "", serviceStartAt: formatDateForInput(data.node.serviceStartAt), serviceTerminationAt: formatDateForInput(data.node.serviceTerminationAt), - serviceCriticality: data.node.serviceCriticality, - riskTier: data.node.riskTier, statusPageUrl: data.node.statusPageUrl || "", termsOfServiceUrl: data.node.termsOfServiceUrl || "", privacyPolicyUrl: data.node.privacyPolicyUrl || "", @@ -394,6 +672,8 @@ function VendorViewContent({ websiteUrl: data.node.websiteUrl || "", businessOwnerId: data.node.businessOwner?.id || null, securityOwnerId: data.node.securityOwner?.id || null, + riskTier: "GENERAL" as "GENERAL" | "SIGNIFICANT" | "CRITICAL", + serviceCriticality: "LOW" as "LOW" | "MEDIUM" | "HIGH", }); const [updateVendor] = useMutation(updateVendorMutation); @@ -405,6 +685,10 @@ function VendorViewContent({ useMutation( uploadComplianceReportMutation ); + const [createRiskAssessment] = + useMutation( + createRiskAssessmentMutation + ); const [, loadQuery] = useQueryLoader(vendorViewQuery); const { toast } = useToast(); @@ -475,8 +759,6 @@ function VendorViewContent({ description: data.node.description || "", serviceStartAt: formatDateForInput(data.node.serviceStartAt), serviceTerminationAt: formatDateForInput(data.node.serviceTerminationAt), - serviceCriticality: data.node.serviceCriticality, - riskTier: data.node.riskTier, statusPageUrl: data.node.statusPageUrl || "", termsOfServiceUrl: data.node.termsOfServiceUrl || "", privacyPolicyUrl: data.node.privacyPolicyUrl || "", @@ -490,6 +772,8 @@ function VendorViewContent({ websiteUrl: data.node.websiteUrl || "", businessOwnerId: data.node.businessOwner?.id || null, securityOwnerId: data.node.securityOwner?.id || null, + riskTier: "GENERAL" as "GENERAL" | "SIGNIFICANT" | "CRITICAL", + serviceCriticality: "LOW" as "LOW" | "MEDIUM" | "HIGH", }); setEditedFields(new Set()); }; @@ -616,321 +900,308 @@ function VendorViewContent({ ] ); - return ( - -
- -
-
-

Basic Information

-

- General information about the vendor -

+ const handleCreateRiskAssessment = useCallback(() => { + // Current date for assessedAt, and 1 year later for expiresAt + const today = new Date(); + const nextYear = new Date(today); + nextYear.setFullYear(nextYear.getFullYear() + 1); + + createRiskAssessment({ + variables: { + connections: [ + ConnectionHandler.getConnectionID( + data.node.id!, + "VendorView_riskAssessments" + ), + ], + input: { + vendorId: data.node.id!, + assessedBy: data.node.businessOwner?.id || "", + expiresAt: nextYear.toISOString(), + dataSensitivity: "LOW", + businessImpact: "LOW", + notes: "Initial risk assessment", + attachments: [] + }, + }, + onCompleted: () => { + toast({ + title: "Success", + description: "Risk assessment created successfully", + variant: "default", + }); + loadQuery({ + vendorId: data.node.id!, + organizationId: organizationId!, + }); + }, + onError: (error) => { + toast({ + title: "Error", + description: error.message || "Failed to create risk assessment", + variant: "destructive", + }); + }, + }); + }, [createRiskAssessment, data.node.id, data.node.businessOwner?.id, loadQuery, toast, organizationId]); + + const formatDate = (dateStr: string | null | undefined) => { + if (!dateStr) return "N/A"; + return new Date(dateStr).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + }; + + const getVendorId = () => { + const id = data.node.id; + if (!id) return ""; + return `vendor_${id.split(':')[1] || id}`; + }; + + const renderTabContent = () => { + switch (activeTab) { + case 'overview': + return ( +
+
+
+

Vendor ID

+

{getVendorId()}

+
+
+

Joined

+

{formatDate(data.node.createdAt)}

+
-
- handleFieldChange("name", value)} - /> - - handleFieldChange("description", value)} - /> - - handleFieldChange("legalName", value)} - /> - - - handleFieldChange("headquarterAddress", value) - } - /> - - handleFieldChange("websiteUrl", value)} - /> -
-
- - - -
-
-

Ownership Information

-

- Individuals responsible for this vendor -

-
- -
-
-
- - +
+ {/* Vendor details card */} +
+
+

Vendor details

+
+
+
+

Name

+
+ handleFieldChange("name", e.target.value)} + className="w-full font-geist font-medium text-[16px] leading-[1.5em] text-[#141E12] bg-transparent border-0 outline-none focus:ring-0 focus-visible:ring-0 p-0" + /> +
+
+ +
+

Description

+
+