diff --git a/apps/customer-portal/backend/Dependencies.toml b/apps/customer-portal/backend/Dependencies.toml index f6b9c3eeaf..01550bfbc1 100644 --- a/apps/customer-portal/backend/Dependencies.toml +++ b/apps/customer-portal/backend/Dependencies.toml @@ -29,6 +29,9 @@ dependencies = [ {org = "ballerina", name = "task"}, {org = "ballerina", name = "time"} ] +modules = [ + {org = "ballerina", packageName = "cache", moduleName = "cache"} +] [[package]] org = "ballerina" @@ -347,6 +350,7 @@ org = "wso2" name = "customer_portal" version = "1.0.0-rc.3" dependencies = [ + {org = "ballerina", name = "cache"}, {org = "ballerina", name = "constraint"}, {org = "ballerina", name = "http"}, {org = "ballerina", name = "jwt"}, diff --git a/apps/customer-portal/backend/modules/entity/types.bal b/apps/customer-portal/backend/modules/entity/types.bal index 28342fd75c..51c3122817 100644 --- a/apps/customer-portal/backend/modules/entity/types.bal +++ b/apps/customer-portal/backend/modules/entity/types.bal @@ -42,8 +42,7 @@ public type Pagination record {| int offset = DEFAULT_OFFSET; # Limit for pagination @constraint:Int { - minValue: 1, - maxValue: 50 + minValue: 1 } int 'limit = DEFAULT_LIMIT; json...; @@ -1407,6 +1406,10 @@ public type ProductVulnerabilitySearchPayload record {| int statusId?; # Severity ID int severityId?; + # Product name filter + string productName?; + # Product version filter + string productVersion?; } filters?; # Sort configuration SortBy sortBy?; // TODO: Check the correct sort by fields for vulnerabilities @@ -1424,12 +1427,20 @@ public type ProductVulnerability record {| string vulnerabilityId; # Severity level ChoiceListItem severity; + # Name of the product + string productName?; + # Version of the product + string productVersion?; # Name of the component string componentName; # Version of the component string version; # Type string 'type; + # Type of the component + string componentType?; + # Update level for the vulnerability + string updateLevel?; # Use case description string? useCase; # Justification for the vulnerability @@ -1442,10 +1453,6 @@ public type ProductVulnerability record {| # Product vulnerability information. public type ProductVulnerabilityResponse record {| *ProductVulnerability; - # Type of the component - string componentType?; - # Update level for the vulnerability - string updateLevel; json...; |}; diff --git a/apps/customer-portal/backend/modules/types/types.bal b/apps/customer-portal/backend/modules/types/types.bal index 86f5007db0..d448705a4c 100644 --- a/apps/customer-portal/backend/modules/types/types.bal +++ b/apps/customer-portal/backend/modules/types/types.bal @@ -814,6 +814,10 @@ public type ProductVulnerabilitySearchFilters record {| int statusId?; # Severity ID int severityId?; + # Product name filter + string productName?; + # Product version filter + string productVersion?; |}; # Base product vulnerability. @@ -826,12 +830,20 @@ public type ProductVulnerability record {| string vulnerabilityId; # Severity level ReferenceItem severity; + # Name of the product + string productName?; + # Version of the product + string productVersion?; # Name of the component string componentName; # Version of the component string version; # Type string 'type; + # Type of the component + string componentType?; + # Update level for the vulnerability + string updateLevel?; # Use case description string? useCase; # Justification @@ -844,10 +856,6 @@ public type ProductVulnerability record {| # Product vulnerability information. public type ProductVulnerabilityResponse record {| *ProductVulnerability; - # Type of the component - string componentType?; - # Update level for the vulnerability - string updateLevel?; |}; # Product vulnerabilities response with pagination. diff --git a/apps/customer-portal/backend/openapi.yaml b/apps/customer-portal/backend/openapi.yaml index fdc79e00ab..66b0ce877b 100644 --- a/apps/customer-portal/backend/openapi.yaml +++ b/apps/customer-portal/backend/openapi.yaml @@ -4636,9 +4636,21 @@ components: description: Vulnerability identifier severity: $ref: '#/components/schemas/ReferenceItem' + productName: + type: string + description: Name of the product + productVersion: + type: string + description: Version of the product componentName: type: string description: Name of the component + componentType: + type: string + description: Type of the component + updateLevel: + type: string + description: Update level for the vulnerability version: type: string description: Version of the component @@ -4674,14 +4686,6 @@ components: description: Product vulnerability information. allOf: - $ref: '#/components/schemas/ProductVulnerability' - - type: object - properties: - componentType: - type: string - description: Type of the component - updateLevel: - type: string - description: Update level for the vulnerability ProductVulnerabilitySearchPayload: type: object properties: @@ -4700,6 +4704,12 @@ components: type: integer description: Severity ID format: int64 + productName: + type: string + description: Product name + productVersion: + type: string + description: Product version description: Filter criteria sortBy: $ref: '#/components/schemas/SortBy' diff --git a/apps/customer-portal/backend/service.bal b/apps/customer-portal/backend/service.bal index 4cedc88a1d..2c1862b958 100644 --- a/apps/customer-portal/backend/service.bal +++ b/apps/customer-portal/backend/service.bal @@ -24,6 +24,7 @@ import customer_portal.types; import customer_portal.updates; import customer_portal.user_management; +import ballerina/cache; import ballerina/http; import ballerina/log; import ballerina/time; @@ -52,6 +53,15 @@ service class ErrorInterceptor { } } +// In-memory cache for bulk product-vulnerability fetches (limit > 50). +// The dataset is org-wide — identical for every authenticated user — so a single +// shared cache is safe. TTL: 12 hours. +final cache:Cache productVulnerabilityCache = new ({ + capacity: 20, + evictionFactor: 0.2, + defaultMaxAge: 12 * 60 * 60 +}); + configurable int wsPort = 9091; http:ListenerConfiguration listenerConf = { @@ -2971,6 +2981,138 @@ service http:InterceptableService / on new http:Listener(9090, listenerConf) { }; } + int reqLimit = payload.pagination?.'limit ?: 10; + + // The entity service has a hard limit of 50 records per request. + // When the caller requests more than 50 (e.g. a "fetch all" call from the frontend), + // we transparently batch through the entity service and aggregate the results here. + if reqLimit > 50 { + int reqOffset = payload.pagination?.offset ?: 0; + + // Cache key covers all filter and sort dimensions so distinct queries + // never share a cache entry. + string cacheKey = string `sq=${payload.filters?.searchQuery ?: ""},` + + string `sv=${payload.filters?.severityId ?: ""},` + + string `st=${payload.filters?.statusId ?: ""},` + + string `pn=${payload.filters?.productName ?: ""},` + + string `pv=${payload.filters?.productVersion ?: ""},` + + string `sb=${payload.sortBy?.'field ?: ""}-${payload.sortBy?.'order ?: ""}`; + + // Return cached result if available (TTL: 12 hours). + any|cache:Error cachedEntry = productVulnerabilityCache.get(cacheKey); + if cachedEntry is types:ProductVulnerability[] { + log:printDebug(string `[vulnerabilities] Cache hit for key: ${cacheKey}`); + int totalCached = cachedEntry.length(); + types:ProductVulnerability[] cachedPage = reqOffset >= totalCached + ? [] + : (reqOffset + reqLimit >= totalCached + ? cachedEntry.slice(reqOffset) + : cachedEntry.slice(reqOffset, reqOffset + reqLimit)); + return { + body: { + productVulnerabilities: cachedPage, + totalRecords: totalCached, + 'limit: reqLimit, + offset: reqOffset + } + }; + } + + // First call with limit=1 to get the total number of available records. + entity:ProductVulnerabilitySearchPayload countPayload = { + filters: payload.filters, + sortBy: payload.sortBy, + pagination: {offset: 0, 'limit: 1} + }; + + entity:ProductVulnerabilitySearchResponse|error countResponse = + entity:searchProductVulnerabilities(userInfo.idToken, countPayload); + if countResponse is error { + if getStatusCode(countResponse) == http:STATUS_FORBIDDEN { + log:printWarn(string `Access to product vulnerabilities information is forbidden for user: ${ + userInfo.userId}`); + return { + body: { + message: "Access to product vulnerabilities information is forbidden for the user!" + } + }; + } + string customError = "Failed to search product vulnerabilities."; + log:printError(customError, countResponse); + return {body: {message: customError}}; + } + + int totalFromEntity = countResponse.totalRecords; + if totalFromEntity == 0 { + return { + body: { + productVulnerabilities: [], + totalRecords: 0, + 'limit: reqLimit, + offset: reqOffset + } + }; + } + + // Fetch all records in batches of 50. + int batchSize = 50; + int batchCount = (totalFromEntity + batchSize - 1) / batchSize; + types:ProductVulnerability[] allVulnerabilities = []; + + foreach int batchIndex in 0 ..< batchCount { + entity:ProductVulnerabilitySearchPayload fetchPayload = { + filters: payload.filters, + sortBy: payload.sortBy, + pagination: {offset: batchIndex * batchSize, 'limit: batchSize} + }; + + entity:ProductVulnerabilitySearchResponse|error batchResponse = + entity:searchProductVulnerabilities(userInfo.idToken, fetchPayload); + if batchResponse is error { + if getStatusCode(batchResponse) == http:STATUS_FORBIDDEN { + log:printWarn(string `Access to product vulnerabilities information is forbidden for user: ${ + userInfo.userId}`); + return { + body: { + message: "Access to product vulnerabilities information is forbidden for the user!" + } + }; + } + string customError = "Failed to search product vulnerabilities."; + log:printError(customError, batchResponse); + return {body: {message: customError}}; + } + + types:ProductVulnerabilitySearchResponse mappedBatch = + mapProductVulnerabilitySearchResponse(batchResponse); + foreach var v in mappedBatch.productVulnerabilities { + allVulnerabilities.push(v); + } + } + + // Store the full aggregated set in cache for 12 hours. + cache:Error? putErr = productVulnerabilityCache.put(cacheKey, allVulnerabilities); + if putErr is cache:Error { + log:printWarn("Failed to store product vulnerabilities in cache.", putErr); + } + + int totalAll = allVulnerabilities.length(); + types:ProductVulnerability[] responsePage = reqOffset >= totalAll + ? [] + : (reqOffset + reqLimit >= totalAll + ? allVulnerabilities.slice(reqOffset) + : allVulnerabilities.slice(reqOffset, reqOffset + reqLimit)); + + return { + body: { + productVulnerabilities: responsePage, + totalRecords: totalAll, + 'limit: reqLimit, + offset: reqOffset + } + }; + } + entity:ProductVulnerabilitySearchResponse|error response = entity:searchProductVulnerabilities(userInfo.idToken, payload); if response is error { diff --git a/apps/customer-portal/backend/utils.bal b/apps/customer-portal/backend/utils.bal index e2014a7676..a49bdbca97 100644 --- a/apps/customer-portal/backend/utils.bal +++ b/apps/customer-portal/backend/utils.bal @@ -374,9 +374,13 @@ public isolated function mapProductVulnerabilitySearchResponse(entity:ProductVul cveId: vulnerability.cveId, vulnerabilityId: vulnerability.vulnerabilityId, severity: {id: vulnerability.severity.id.toString(), label: vulnerability.severity.label}, + productName: vulnerability.productName, + productVersion: vulnerability.productVersion, componentName: vulnerability.componentName, version: vulnerability.version, 'type: vulnerability.'type, + componentType: vulnerability.componentType, + updateLevel: vulnerability.updateLevel, useCase: vulnerability.useCase, justification: vulnerability.justification, resolution: vulnerability.resolution @@ -401,6 +405,8 @@ public isolated function mapProductVulnerabilityResponse(entity:ProductVulnerabi cveId: response.cveId, vulnerabilityId: response.vulnerabilityId, severity: {id: response.severity.id.toString(), label: response.severity.label}, + productName: response.productName, + productVersion: response.productVersion, componentName: response.componentName, version: response.version, 'type: response.'type, diff --git a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesFilters.tsx b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesFilters.tsx index 782b532b5d..bf6a2f78ad 100644 --- a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesFilters.tsx +++ b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesFilters.tsx @@ -15,8 +15,9 @@ // under the License. import { + Box, + Button, FormControl, - Grid, InputLabel, MenuItem, Select, @@ -25,54 +26,133 @@ import { import type { JSX } from "react"; import type { SelectChangeEvent } from "@wso2/oxygen-ui"; import { + PRODUCT_VULNERABILITIES_ALL_PRODUCTS_LABEL, + PRODUCT_VULNERABILITIES_ALL_VERSIONS_LABEL, + PRODUCT_VULNERABILITIES_CLEAR_FILTERS_LABEL, + PRODUCT_VULNERABILITIES_PRODUCT_LABEL, + PRODUCT_VULNERABILITIES_PRODUCT_VERSION_LABEL, PRODUCT_VULNERABILITIES_SEVERITY_ALL_LABEL, PRODUCT_VULNERABILITIES_SEVERITY_LABEL, } from "@features/security/constants/securityConstants"; import type { ProductVulnerabilitiesFiltersProps } from "@features/security/types/security"; /** - * ProductVulnerabilitiesFilters component to display filter dropdowns. + * ProductVulnerabilitiesFilters component to display filter dropdowns for + * severity, product name, and product version. * - * @param {ProductVulnerabilitiesFiltersProps} props - Filter values and change handler. + * @param {ProductVulnerabilitiesFiltersProps} props - Filter values and change handlers. * @returns {JSX.Element} The rendered filter dropdowns. */ export default function ProductVulnerabilitiesFilters({ filters, severityOptions = [], + productOptions = [], + productVersionOptions = [], onFilterChange, + onClearFilters, }: ProductVulnerabilitiesFiltersProps): JSX.Element { - const handleSelectChange = (event: SelectChangeEvent) => { - const val = event.target.value; - onFilterChange("severityId", val); + const handleSeverityChange = (event: SelectChangeEvent) => { + onFilterChange("severityId", event.target.value); + }; + + const handleProductChange = (event: SelectChangeEvent) => { + // Changing product resets version + onFilterChange("productVersion", ""); + onFilterChange("productName", event.target.value); + }; + + const handleVersionChange = (event: SelectChangeEvent) => { + onFilterChange("productVersion", event.target.value); }; return ( - - - - - {PRODUCT_VULNERABILITIES_SEVERITY_LABEL} - - + + + {PRODUCT_VULNERABILITIES_SEVERITY_ALL_LABEL} + + + {severityOptions.map((option) => ( + + {option.label} - {severityOptions.map((option) => ( - - {option.label} - - ))} - - - - + ))} + + + + {/* Product */} + + + {PRODUCT_VULNERABILITIES_PRODUCT_LABEL} + + + + + {/* Product Version */} + + + {PRODUCT_VULNERABILITIES_PRODUCT_VERSION_LABEL} + + + + + {/* Clear Filters */} + + ); } diff --git a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesList.tsx b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesList.tsx index cde9a01664..9a32bc2e0b 100644 --- a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesList.tsx +++ b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesList.tsx @@ -59,12 +59,18 @@ const ProductVulnerabilitiesList = ({ - CVE ID + CVE Vulnerability ID Severity + Product Name + Product Version Component Name - Version - Type + Component Version + Component Type + Update Level + Use Case + Justification + Resolution @@ -135,6 +141,16 @@ const ProductVulnerabilitiesList = ({ }} /> + + + {row.productName ?? "--"} + + + + + {row.productVersion ?? "--"} + + - + + {row.componentType ?? "--"} + + + + + {row.updateLevel ?? "--"} + + + + + {row.useCase ?? "--"} + + + + + {row.justification ?? "--"} + + + + + {row.resolution ?? "--"} + ); diff --git a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTable.tsx b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTable.tsx index 24f7fa59d5..82b21f0e1d 100644 --- a/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTable.tsx +++ b/apps/customer-portal/webapp/src/features/security/components/ProductVulnerabilitiesTable.tsx @@ -23,18 +23,31 @@ import ProductVulnerabilitiesTableHeader from "@features/security/components/Pro import ProductVulnerabilitiesFilters from "@features/security/components/ProductVulnerabilitiesFilters"; import ProductVulnerabilitiesList from "@features/security/components/ProductVulnerabilitiesList"; import { + PRODUCT_VULNERABILITIES_ALL_FETCH_LIMIT, PRODUCT_VULNERABILITIES_DEFAULT_ROWS_PER_PAGE, PRODUCT_VULNERABILITIES_SEARCH_DEBOUNCE_MS, } from "@features/security/constants/securityConstants"; -import type { ProductVulnerabilitiesTableProps } from "@features/security/types/security"; -import { - countProductVulnerabilityTableActiveFilters, -} from "@features/security/utils/productVulnerabilitiesTable"; +import type { + ProductVulnerabilitiesTableProps, + ProductVulnerability, +} from "@features/security/types/security"; +import { countProductVulnerabilityTableActiveFilters } from "@features/security/utils/productVulnerabilitiesTable"; + +// Stable request object — fetches the full dataset once. +// The BFF transparently paginates through the entity service in batches of 50. +// Kept at module level so the React Query cache key is stable across re-renders. +const FETCH_ALL_REQUEST = { + filters: {}, + pagination: { offset: 0, limit: PRODUCT_VULNERABILITIES_ALL_FETCH_LIMIT }, +}; /** - * Product Vulnerabilities table using the same structure as the dashboard outstanding cases table. - * Fetches data via POST /products/vulnerabilities/search. - * @returns {JSX.Element} + * Product Vulnerabilities table. + * + * Architecture note: + * - All ~1500 records are fetched once via a single "fetch all" request. + * - The BFF handles batching to the entity service (which is capped at 50/request). + * - All filtering, sorting, and pagination are performed client-side for instant response. */ const ProductVulnerabilitiesTable = ({ onTotalRecordsChange, @@ -53,6 +66,7 @@ const ProductVulnerabilitiesTable = ({ PRODUCT_VULNERABILITIES_DEFAULT_ROWS_PER_PAGE, ); + // ── Severity dropdown metadata ────────────────────────────────────────────── const { data: metaData } = useGetVulnerabilitiesMetaData(); const severityOptions = useMemo( () => @@ -60,45 +74,111 @@ const ProductVulnerabilitiesTable = ({ [metaData?.severities], ); - const searchRequest = useMemo( - () => ({ - filters: { - searchQuery: (debouncedSearch?.trim() || undefined) as - | string - | undefined, - severityId: filters.severityId ? Number(filters.severityId) : undefined, - }, - pagination: { - offset: page * rowsPerPage, - limit: rowsPerPage, - }, - }), - [debouncedSearch, filters, page, rowsPerPage], + // ── Full dataset (fetched once, all filtering/pagination done client-side) ─── + const { + data: allData, + isLoading, + isError, + } = usePostProductVulnerabilitiesSearch(FETCH_ALL_REQUEST); + + const allVulnerabilities = useMemo( + (): ProductVulnerability[] => allData?.productVulnerabilities ?? [], + [allData], ); - const { data, isLoading, isError } = - usePostProductVulnerabilitiesSearch(searchRequest); + // ── Dropdown options ───────────────────────────────────────────────────────── + const productOptions = useMemo(() => { + const names = new Set(); + allVulnerabilities.forEach((v) => { + if (v.productName) names.add(v.productName); + }); + return Array.from(names) + .sort() + .map((name) => ({ value: name, label: name })); + }, [allVulnerabilities]); - useEffect(() => { - if (data?.totalRecords !== undefined) { - onTotalRecordsChange?.(data.totalRecords); + const productVersionOptions = useMemo(() => { + if (!filters.productName) return []; + const versions = new Set(); + allVulnerabilities + .filter((v) => v.productName === filters.productName) + .forEach((v) => { + if (v.productVersion) versions.add(v.productVersion); + }); + return Array.from(versions) + .sort() + .map((v) => ({ value: v, label: v })); + }, [allVulnerabilities, filters.productName]); + + // ── Client-side filtering ──────────────────────────────────────────────────── + const filteredVulnerabilities = useMemo((): ProductVulnerability[] => { + let items = allVulnerabilities; + + if (filters.severityId) { + items = items.filter( + (v) => String(v.severity?.id) === String(filters.severityId), + ); + } + if (filters.productName) { + items = items.filter( + (v) => v.productName === (filters.productName as string), + ); + } + if (filters.productVersion) { + items = items.filter( + (v) => v.productVersion === (filters.productVersion as string), + ); + } + if (debouncedSearch?.trim()) { + const q = debouncedSearch.trim().toLowerCase(); + items = items.filter( + (v) => + v.cveId?.toLowerCase().includes(q) || + v.componentName?.toLowerCase().includes(q) || + v.vulnerabilityId?.toLowerCase().includes(q), + ); } - }, [data?.totalRecords, onTotalRecordsChange]); - useEffect(() => { - if (isError) onError?.(true); - }, [isError, onError]); + return items; + }, [allVulnerabilities, filters, debouncedSearch]); + // ── Client-side pagination ─────────────────────────────────────────────────── const paginatedData = useMemo(() => { - if (!data) return undefined; + const total = filteredVulnerabilities.length; + const offset = page * rowsPerPage; return { - vulnerabilities: data.productVulnerabilities, - totalRecords: data.totalRecords, + vulnerabilities: filteredVulnerabilities.slice(offset, offset + rowsPerPage), + totalRecords: total, }; - }, [data]); + }, [filteredVulnerabilities, page, rowsPerPage]); + + // Clamp the current page when the filtered dataset shrinks (e.g. after a + // filter change) so the table never shows an empty page while valid rows exist. + useEffect(() => { + const maxPage = Math.max( + Math.ceil(filteredVulnerabilities.length / rowsPerPage) - 1, + 0, + ); + if (page > maxPage) { + setPage(maxPage); + } + }, [filteredVulnerabilities.length, rowsPerPage, page]); + + useEffect(() => { + onTotalRecordsChange?.(paginatedData.totalRecords); + }, [paginatedData.totalRecords, onTotalRecordsChange]); + useEffect(() => { + onError?.(isError); + }, [isError, onError]); + + // ── Handlers ──────────────────────────────────────────────────────────────── const handleUpdateFilter = (field: string, value: string | number) => { - setFilters((prev) => ({ ...prev, [field]: value })); + setFilters((prev) => ({ + ...prev, + [field]: value, + ...(field === "productName" ? { productVersion: "" } : {}), + })); setPage(0); }; @@ -120,11 +200,11 @@ const ProductVulnerabilitiesTable = ({ }; const activeFilterCount = useMemo( - () => - countProductVulnerabilityTableActiveFilters(searchInput, filters), + () => countProductVulnerabilityTableActiveFilters(searchInput, filters), [filters, searchInput], ); + // ── Render ─────────────────────────────────────────────────────────────────── return ( - {/* Filter dropdowns section */} {isFilterOpen && ( <> @@ -152,14 +231,17 @@ const ProductVulnerabilitiesTable = ({ )} {Array.from({ length: rowsPerPage }).map((_, index) => ( - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + ))} diff --git a/apps/customer-portal/webapp/src/features/security/components/VulnerabilityDetailsContent.tsx b/apps/customer-portal/webapp/src/features/security/components/VulnerabilityDetailsContent.tsx index 48475445f0..06271a572a 100644 --- a/apps/customer-portal/webapp/src/features/security/components/VulnerabilityDetailsContent.tsx +++ b/apps/customer-portal/webapp/src/features/security/components/VulnerabilityDetailsContent.tsx @@ -20,6 +20,7 @@ import { Card, CardContent, Chip, + Divider, IconButton, Paper, Stack, @@ -27,29 +28,31 @@ import { } from "@wso2/oxygen-ui"; import { ArrowLeft, - Shield, - Package, - Code, ExternalLink, + Shield, } from "@wso2/oxygen-ui-icons-react"; import { type JSX } from "react"; import { + VULNERABILITY_DETAILS_CARD_TITLE, VULNERABILITY_DETAILS_COMPONENT_NAME_LABEL, - VULNERABILITY_DETAILS_COMPONENT_SECTION_TITLE, VULNERABILITY_DETAILS_COMPONENT_TYPE_LABEL, VULNERABILITY_DETAILS_COMPONENT_VERSION_LABEL, + VULNERABILITY_DETAILS_CVE_LABEL, VULNERABILITY_DETAILS_HEADER_SUBTITLE, VULNERABILITY_DETAILS_JUSTIFICATION_LABEL, VULNERABILITY_DETAILS_LOAD_ERROR_CAPTION, VULNERABILITY_DETAILS_LOAD_ERROR_TITLE, VULNERABILITY_DETAILS_NOT_APPLICABLE_LABEL, - VULNERABILITY_DETAILS_NO_RESOLUTION_MESSAGE, VULNERABILITY_DETAILS_NVD_BASE_URL, VULNERABILITY_DETAILS_NVD_BUTTON_LABEL, + VULNERABILITY_DETAILS_PRODUCT_NAME_LABEL, + VULNERABILITY_DETAILS_PRODUCT_VERSION_LABEL, VULNERABILITY_DETAILS_RESOLUTION_LABEL, - VULNERABILITY_DETAILS_RESOLUTION_SECTION_TITLE, + VULNERABILITY_DETAILS_SEVERITY_LABEL, VULNERABILITY_DETAILS_UPDATE_LEVEL_LABEL, VULNERABILITY_DETAILS_USE_CASE_LABEL, + VULNERABILITY_DETAILS_VULNERABILITY_ID_LABEL, + VULNERABILITY_DETAILS_WSO2_RESOLUTION_LABEL, } from "@features/security/constants/securityConstants"; import type { VulnerabilityDetailsContentProps } from "@features/security/types/security"; import { @@ -59,6 +62,46 @@ import { import ErrorIndicator from "@components/error-indicator/ErrorIndicator"; import VulnerabilityDetailsSkeleton from "@features/security/components/VulnerabilityDetailsSkeleton"; +/** Renders a labelled read-only field matching the form style in the image. */ +function FieldBox({ + label, + value, + multiline = false, +}: { + label: string; + value: string; + multiline?: boolean; +}): JSX.Element { + return ( + + + {label} + + + + {value} + + + + ); +} + /** * VulnerabilityDetailsContent displays vulnerability header and detail cards. * @@ -229,296 +272,169 @@ export default function VulnerabilityDetailsContent({ pt: 2, }} > - - {/* Main details card */} - - - - - - - - {cveId} - - - {statusLabel && ( - - )} - - - {vulnerabilityId} - - - {data.cveId && ( - - )} - - - - + + + - {/* Component Information card */} - - - - - - - - + + {VULNERABILITY_DETAILS_CARD_TITLE} + + {data.cveId && ( + + )} + + + + + {/* Product Name | Product Version */} + + + + - + + + + + {/* Severity */} + + - - - {VULNERABILITY_DETAILS_COMPONENT_NAME_LABEL} - - - {data.componentName || "--"} - - - - - {VULNERABILITY_DETAILS_COMPONENT_VERSION_LABEL} - - - {data.version || "--"} - - - - - {VULNERABILITY_DETAILS_COMPONENT_TYPE_LABEL} - + {VULNERABILITY_DETAILS_SEVERITY_LABEL} + + + + {statusLabel && ( - - {data.updateLevel && ( - - - {VULNERABILITY_DETAILS_UPDATE_LEVEL_LABEL} - - - {data.updateLevel} - - )} - - - + - {/* Resolution & Mitigation card */} - - - - - - - - - {VULNERABILITY_DETAILS_RESOLUTION_SECTION_TITLE} - - + {/* Component Name | Component Type */} + + + + - - - - {VULNERABILITY_DETAILS_USE_CASE_LABEL} - - - {data.useCase ?? VULNERABILITY_DETAILS_NOT_APPLICABLE_LABEL} - - - - - {VULNERABILITY_DETAILS_COMPONENT_TYPE_LABEL} - - - - + {/* Component Version | Update Level */} + + + + - {data.justification && ( - - - {VULNERABILITY_DETAILS_JUSTIFICATION_LABEL} - - - - {data.justification} - - - - )} + {/* WSO2 Resolution (full width) */} + - {data.resolution && ( - - - {VULNERABILITY_DETAILS_RESOLUTION_LABEL} - - - - {data.resolution} - - - - )} + {/* Justification (full width, multi-line) */} + - {!data.justification && !data.resolution && ( - - {VULNERABILITY_DETAILS_NO_RESOLUTION_MESSAGE} - - )} - - - - + {/* Usecase (full width, multi-line) */} + + + {/* Resolution (full width, multi-line) */} + + + + + )} diff --git a/apps/customer-portal/webapp/src/features/security/constants/securityConstants.ts b/apps/customer-portal/webapp/src/features/security/constants/securityConstants.ts index e8efc23e10..ae7d869ae2 100644 --- a/apps/customer-portal/webapp/src/features/security/constants/securityConstants.ts +++ b/apps/customer-portal/webapp/src/features/security/constants/securityConstants.ts @@ -72,7 +72,7 @@ export const PRODUCT_VULNERABILITIES_SEARCH_DEBOUNCE_MS = 350; export const PRODUCT_VULNERABILITIES_DEFAULT_ROWS_PER_PAGE = 10; -export const PRODUCT_VULNERABILITIES_TABLE_COLUMN_COUNT = 6; +export const PRODUCT_VULNERABILITIES_TABLE_COLUMN_COUNT = 12; export const PRODUCT_VULNERABILITIES_TABLE_PAGINATION_OPTIONS = [5, 10, 25, 50] as const; @@ -96,6 +96,20 @@ export const PRODUCT_VULNERABILITIES_SEVERITY_LABEL = "Severity"; export const PRODUCT_VULNERABILITIES_SEVERITY_ALL_LABEL = "All Severity"; +export const PRODUCT_VULNERABILITIES_PRODUCT_LABEL = "Product"; + +export const PRODUCT_VULNERABILITIES_PRODUCT_PLACEHOLDER = "Select a Product"; + +export const PRODUCT_VULNERABILITIES_PRODUCT_VERSION_LABEL = "Product Version"; + +export const PRODUCT_VULNERABILITIES_PRODUCT_VERSION_PLACEHOLDER = "Select a Product Version"; + +export const PRODUCT_VULNERABILITIES_ALL_PRODUCTS_LABEL = "All Products"; + +export const PRODUCT_VULNERABILITIES_ALL_VERSIONS_LABEL = "All Versions"; + +export const PRODUCT_VULNERABILITIES_ALL_FETCH_LIMIT = 5000; + export const SECURITY_REPORT_ANALYSIS_PAGE_SIZE = 10; export const SECURITY_REPORT_ANALYSIS_TITLE = "Security Report Analysis"; @@ -131,6 +145,14 @@ export const SECURITY_REPORT_FILTER_DEFINITIONS: FilterDefinition[] = [ }, ]; +export const VULNERABILITY_DETAILS_CARD_TITLE = "Product Vulnerabilities"; + +export const VULNERABILITY_DETAILS_CVE_LABEL = "CVE"; + +export const VULNERABILITY_DETAILS_VULNERABILITY_ID_LABEL = "Vulnerability ID"; + +export const VULNERABILITY_DETAILS_SEVERITY_LABEL = "Severity"; + export const VULNERABILITY_DETAILS_NVD_BASE_URL = "https://nvd.nist.gov/vuln/detail"; @@ -138,6 +160,12 @@ export const VULNERABILITY_DETAILS_NVD_BUTTON_LABEL = "NVD"; export const VULNERABILITY_DETAILS_HEADER_SUBTITLE = "Vulnerability details"; +export const VULNERABILITY_DETAILS_PRODUCT_NAME_LABEL = "Product Name"; + +export const VULNERABILITY_DETAILS_PRODUCT_VERSION_LABEL = "Product Version"; + +export const VULNERABILITY_DETAILS_WSO2_RESOLUTION_LABEL = "WSO2 Resolution"; + export const VULNERABILITY_DETAILS_COMPONENT_SECTION_TITLE = "Component Information"; export const VULNERABILITY_DETAILS_RESOLUTION_SECTION_TITLE = diff --git a/apps/customer-portal/webapp/src/features/security/types/security.ts b/apps/customer-portal/webapp/src/features/security/types/security.ts index e1a1eeab4f..0976f09b7f 100644 --- a/apps/customer-portal/webapp/src/features/security/types/security.ts +++ b/apps/customer-portal/webapp/src/features/security/types/security.ts @@ -80,6 +80,8 @@ export type ProductVulnerability = { cveId: string; vulnerabilityId: string; severity: MetadataItem; + productName?: string; + productVersion?: string; componentName: string; version: string; type: string; @@ -100,7 +102,10 @@ export type ProductVulnerabilitiesTableProps = { export type ProductVulnerabilitiesFiltersProps = { filters: Record; severityOptions?: VulnerabilitySelectOption[]; + productOptions?: VulnerabilitySelectOption[]; + productVersionOptions?: VulnerabilitySelectOption[]; onFilterChange: (field: string, value: string | number) => void; + onClearFilters: () => void; }; export type ProductVulnerabilitiesListData = { @@ -149,6 +154,8 @@ export type ProductVulnerabilitiesSearchFilters = { searchQuery?: string; severityId?: number; statusId?: number; + productName?: string; + productVersion?: string; }; // Request type for searching product vulnerabilities. diff --git a/apps/customer-portal/webapp/src/features/security/utils/productVulnerabilitiesTable.ts b/apps/customer-portal/webapp/src/features/security/utils/productVulnerabilitiesTable.ts index 36e72b4901..9415c88a47 100644 --- a/apps/customer-portal/webapp/src/features/security/utils/productVulnerabilitiesTable.ts +++ b/apps/customer-portal/webapp/src/features/security/utils/productVulnerabilitiesTable.ts @@ -19,7 +19,7 @@ import { } from "@features/security/constants/securityConstants"; /** - * Counts search + severity filters for the product vulnerabilities table header. + * Counts search, severity, product name, and product version filters for the product vulnerabilities table header. * * @param searchInput - Current search text. * @param filters - Raw filter state. @@ -31,7 +31,9 @@ export function countProductVulnerabilityTableActiveFilters( ): number { const searchCount = searchInput.trim() ? 1 : 0; const severityCount = filters.severityId ? 1 : 0; - return searchCount + severityCount; + const productCount = filters.productName ? 1 : 0; + const productVersionCount = filters.productVersion ? 1 : 0; + return searchCount + severityCount + productCount + productVersionCount; } /**