diff --git a/apps/csm-portal/microapp/src/App.tsx b/apps/csm-portal/microapp/src/App.tsx index 0f5909ad86..92e0403943 100644 --- a/apps/csm-portal/microapp/src/App.tsx +++ b/apps/csm-portal/microapp/src/App.tsx @@ -30,11 +30,13 @@ import MorePage from "@pages/MorePage"; import AnnouncementsPage from "@pages/AnnouncementsPage"; import TimeCardsPage from "@pages/TimeCardsPage"; import SecurityCenterPage from "@pages/SecurityCenterPage"; +import NewSecurityReportPage from "@pages/NewSecurityReportPage"; import UpdatesPage from "@pages/UpdatesPage"; import EngagementsPage from "@pages/EngagementsPage"; import CustomersPage from "@pages/CustomersPage"; import AccountDetailPage from "@pages/AccountDetailPage"; import ProjectDetailPage from "@pages/ProjectDetailPage"; +import VulnerabilityDetailPage from "@pages/VulnerabilityDetailPage"; import SettingsPage from "@pages/SettingsPage"; import ProfilePage from "@pages/ProfilePage"; @@ -74,6 +76,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/apps/csm-portal/microapp/src/components/customers/MetaRow.tsx b/apps/csm-portal/microapp/src/components/common/MetaRow.tsx similarity index 84% rename from apps/csm-portal/microapp/src/components/customers/MetaRow.tsx rename to apps/csm-portal/microapp/src/components/common/MetaRow.tsx index 239b86be90..ee35f94cc8 100644 --- a/apps/csm-portal/microapp/src/components/customers/MetaRow.tsx +++ b/apps/csm-portal/microapp/src/components/common/MetaRow.tsx @@ -17,9 +17,9 @@ import { Stack, Typography, type TypographyProps } from "@wso2/oxygen-ui"; import type { ReactNode } from "react"; -/** One "label / value" row in an Account/Project overview card — the mobile - * equivalent of the webapp's grid-cell MetaCell (a single narrow column reads - * better as stacked rows than as a multi-column grid). */ +/** One "label / value" row in an overview/detail card (Accounts, Projects, Deployments, + * Vulnerabilities, ...) — the mobile equivalent of the webapp's grid-cell MetaCell (a single + * narrow column reads better as stacked rows than as a multi-column grid). */ export function MetaRow({ label, children }: { label: string; children: ReactNode }) { return ( diff --git a/apps/csm-portal/microapp/src/components/customers/DeploymentDetailDialog.tsx b/apps/csm-portal/microapp/src/components/customers/DeploymentDetailDialog.tsx index b737b76096..27d0572d9d 100644 --- a/apps/csm-portal/microapp/src/components/customers/DeploymentDetailDialog.tsx +++ b/apps/csm-portal/microapp/src/components/customers/DeploymentDetailDialog.tsx @@ -19,7 +19,7 @@ import { X } from "@wso2/oxygen-ui-icons-react"; import type { Deployment } from "@src/types"; import { formatDateOnly, formatEnumLabel } from "@utils/customers"; import { DialogPaper } from "@components/common/DialogPaper"; -import { MetaRow, MetaValue } from "@components/customers/MetaRow"; +import { MetaRow, MetaValue } from "@components/common/MetaRow"; import { DeployedProductsList } from "@components/customers/DeployedProductsList"; interface DeploymentDetailDialogProps { diff --git a/apps/csm-portal/microapp/src/components/security-center/SecurityReportFiltersSheet.tsx b/apps/csm-portal/microapp/src/components/security-center/SecurityReportFiltersSheet.tsx new file mode 100644 index 0000000000..2fcf606888 --- /dev/null +++ b/apps/csm-portal/microapp/src/components/security-center/SecurityReportFiltersSheet.tsx @@ -0,0 +1,287 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useEffect, useMemo, useState } from "react"; +import { + Autocomplete, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + IconButton, + Stack, + TextField, + Tooltip, + Typography, +} from "@wso2/oxygen-ui"; +import { X } from "@wso2/oxygen-ui-icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { projects } from "@src/services/projects"; +import { adminUsers } from "@src/services/adminUsers"; +import { products } from "@src/services/products"; +import type { Project } from "@src/types"; +import { ALL_WORK_STATES, FILTERABLE_STATES, STATE_LABELS, WORK_STATE_LABEL } from "@components/support/config"; +import { useDebouncedValue } from "@utils/useDebouncedValue"; +import { + EMPTY_SECURITY_REPORT_FILTERS, + type SecurityReportAssignee, + type SecurityReportFilters, +} from "@utils/securityReports"; + +// The Acrylic theme renders popup papers translucent, so a dropdown that opens +// over the dialog reads as see-through — force the opaque `background.default`. +const OPAQUE_POPUP = { sx: { backgroundColor: "background.default", backgroundImage: "none" } }; + +function toggle(list: T[], value: T): T[] { + return list.includes(value) ? list.filter((v) => v !== value) : [...list, value]; +} + +// Async, type-to-search multi-select of projects (server-side `projectIds`). +// Mirrors the equivalent field in EngagementFiltersSheet (debounced 300ms); +// already-picked projects stay in the option list so their chips keep their labels. +function ProjectMultiSelect({ value, onChange }: { value: Project[]; onChange: (projects: Project[]) => void }) { + const [input, setInput] = useState(""); + const debounced = useDebouncedValue(input, 300); + const { data, isFetching } = useQuery(projects.search(debounced.trim())); + + const options = useMemo(() => { + const results = data ?? []; + return [...value, ...results.filter((r) => !value.some((v) => v.id === r.id))]; + }, [data, value]); + + return ( + o.name} + isOptionEqualToValue={(a, b) => a.id === b.id} + onChange={(_, next) => onChange(next)} + onInputChange={(_, next) => setInput(next)} + slotProps={{ paper: OPAQUE_POPUP }} + renderInput={(params) => } + /> + ); +} + +// Async, type-to-search multi-select of engineers (server-side `assignedUserIds`). +// Reuses adminUsers.search's internal-roles scope, same as EngagementFiltersSheet's own +// AssigneeMultiSelect. Requires at least one typed character. +function AssigneeMultiSelect({ + value, + onChange, +}: { + value: SecurityReportAssignee[]; + onChange: (assignees: SecurityReportAssignee[]) => void; +}) { + const [input, setInput] = useState(""); + const debounced = useDebouncedValue(input, 300); + const { data, isFetching } = useQuery(adminUsers.search(debounced.trim())); + + const options = useMemo(() => { + const results = data?.users ?? []; + return [...value, ...results.filter((r) => !value.some((v) => v.id === r.id))]; + }, [data, value]); + + return ( + o.name} + isOptionEqualToValue={(a, b) => a.id === b.id} + onChange={(_, next) => onChange(next.map((o) => ({ id: o.id, name: o.name })))} + onInputChange={(_, next) => setInput(next)} + slotProps={{ paper: OPAQUE_POPUP }} + renderInput={(params) => } + /> + ); +} + +// Products are a bounded catalogue, so the distinct family names are fetched +// once (products.names()) and filtered locally as the user types. +function ProductMultiSelect({ value, onChange }: { value: string[]; onChange: (names: string[]) => void }) { + const { data, isFetching } = useQuery(products.names()); + + const options = useMemo(() => { + const merged = new Set(data ?? []); + value.forEach((v) => merged.add(v)); + return [...merged].sort((a, b) => a.localeCompare(b)); + }, [data, value]); + + return ( + onChange(next)} + slotProps={{ paper: OPAQUE_POPUP }} + renderInput={(params) => } + /> + ); +} + +interface SecurityReportFiltersSheetProps { + open: boolean; + onClose: () => void; + filters: SecurityReportFilters; + onApply: (filters: SecurityReportFilters) => void; +} + +// Mobile bottom-sheet filters for the security reports list: State, Work state +// (chip multi-selects) + Assignee, Project, Product (async multi-selects). Search +// lives in the always-visible bar on the page, not here. Mirrors the webapp's +// CasesFilterBar for a view locked to `caseTypes: ["security_report_analysis"]` +// — severity and case type are left out entirely (severity's a "case"-only +// concept, case type is fixed here), and there's no engagement-type group +// (that's engagement-only). +export function SecurityReportFiltersSheet({ open, onClose, filters, onApply }: SecurityReportFiltersSheetProps) { + const [draft, setDraft] = useState(filters); + + // Re-seed the draft from the last-applied filters each time the sheet opens, so reopening it + // doesn't show stale in-progress edits from a previous open that was dismissed without applying. + useEffect(() => { + if (open) setDraft(filters); + // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-seed on open, not on every filters identity change + }, [open]); + + const workInProgressSelected = draft.states.includes("work_in_progress"); + + return ( + + + Filters + + + + + + + + + + + State + + {FILTERABLE_STATES.map((state) => { + const isSelected = draft.states.includes(state); + return ( + { + const states = toggle(draft.states, state); + // Work sub-state only applies to work_in_progress cases, so drop any + // selected work states when that state leaves the filter — keeps a + // stale, inert work-state selection from lingering. + setDraft({ + ...draft, + states, + workStates: states.includes("work_in_progress") ? draft.workStates : [], + }); + }} + /> + ); + })} + + + + + Work state + + {ALL_WORK_STATES.map((workState) => { + const isSelected = draft.workStates.includes(workState); + const chip = ( + setDraft({ ...draft, workStates: toggle(draft.workStates, workState) })} + /> + ); + // Disabled MUI chips don't fire pointer events, so the tooltip needs a + // span wrapper to still show on hover/focus. + return workInProgressSelected ? ( + {chip} + ) : ( + + {chip} + + ); + })} + + + + setDraft({ ...draft, assignees: next })} /> + + setDraft({ ...draft, projects: next })} /> + + setDraft({ ...draft, productNames: next })} + /> + + + + + + + + + + + ); +} diff --git a/apps/csm-portal/microapp/src/components/security-center/SecurityReportsTab.tsx b/apps/csm-portal/microapp/src/components/security-center/SecurityReportsTab.tsx new file mode 100644 index 0000000000..b1aa634fbe --- /dev/null +++ b/apps/csm-portal/microapp/src/components/security-center/SecurityReportsTab.tsx @@ -0,0 +1,133 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Badge, Fab, IconButton, Stack, Typography } from "@wso2/oxygen-ui"; +import { Plus, SlidersHorizontal } from "@wso2/oxygen-ui-icons-react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { securityReports } from "@src/services/securityReports"; +import { useDebouncedValue } from "@utils/useDebouncedValue"; +import { useInfiniteScrollSentinel } from "@utils/useInfiniteScrollSentinel"; +import { + countActiveSecurityReportFilters, + EMPTY_SECURITY_REPORT_FILTERS, + type SecurityReportFilters, +} from "@utils/securityReports"; +import { SearchBar } from "@components/support/SearchBar"; +import { EmptyState } from "@components/support/EmptyState"; +import { ErrorState } from "@components/support/ErrorState"; +import { CaseCard, CaseCardSkeleton } from "@components/support/CaseCard"; +import { SecurityReportFiltersSheet } from "@components/security-center/SecurityReportFiltersSheet"; + +// Cross-customer security reports list (security reports are cases of type +// "security_report_analysis"), mirroring the webapp's CsmSecurityCenterPage's +// Security reports tab — a CsmIssuesView locked to +// `caseTypes: ["security_report_analysis"]` with severity hidden. Search + +// State + Work state + Assignee + Project + Product filters, infinite-scrolled +// newest-updated first, plus a Fab into NewSecurityReportPage — mirrors +// SupportPage's own "New Case" Fab. +export function SecurityReportsTab() { + const navigate = useNavigate(); + const [filters, setFilters] = useState(EMPTY_SECURITY_REPORT_FILTERS); + const [filtersOpen, setFiltersOpen] = useState(false); + const debouncedSearch = useDebouncedValue(filters.search.trim(), 300); + const activeFilterCount = countActiveSecurityReportFilters(filters); + + // Search is debounced into the query so typing doesn't refetch every keystroke. + const { data, isLoading, isError, refetch, hasNextPage, isFetchingNextPage, fetchNextPage } = useInfiniteQuery( + securityReports.infinite({ ...filters, search: debouncedSearch }), + ); + + const items = data?.pages.flatMap((p) => p.items) ?? []; + const total = data?.pages[0]?.total ?? items.length; + + const sentinelRef = useInfiniteScrollSentinel({ hasNextPage, isFetchingNextPage, fetchNextPage }); + + return ( + + + setFilters((prev) => ({ ...prev, search: value }))} + placeholder="Search by number or subject…" + /> + + setFiltersOpen(true)}> + + + + + + {isLoading ? ( + + ) : isError ? ( + void refetch()} /> + ) : items.length === 0 ? ( + + ) : ( + + + {items.length} of {total} + + + {items.map((item) => ( + + ))} + + {/* IntersectionObserver can miss a zero-height target, so give the sentinel 1px to observe. */} +
+ + {isFetchingNextPage && } + {!hasNextPage && ( + + You're all caught up! + + )} + + )} + + setFiltersOpen(false)} + filters={filters} + onApply={setFilters} + /> + + {/* Fixed bottom-right, offset above the fixed TabBar — same positioning as SupportPage's own + "Create case" Fab. */} + navigate("/more/security-center/reports/new")} + sx={{ position: "fixed", right: 10, bottom: "calc(var(--tab-bar-height) + 60px)" }} + > + + + + ); +} + +function ListSkeleton() { + return ( + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + ); +} diff --git a/apps/csm-portal/microapp/src/components/security-center/VulnerabilitiesTab.tsx b/apps/csm-portal/microapp/src/components/security-center/VulnerabilitiesTab.tsx new file mode 100644 index 0000000000..d7321b91d4 --- /dev/null +++ b/apps/csm-portal/microapp/src/components/security-center/VulnerabilitiesTab.tsx @@ -0,0 +1,158 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useState } from "react"; +import { Card, Chip, Stack, Typography } from "@wso2/oxygen-ui"; +import { Link } from "react-router-dom"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { EMPTY_VULNERABILITY_FILTERS, vulnerabilities, type VulnerabilityFilters } from "@src/services/vulnerabilities"; +import type { Vulnerability, VulnerabilityPriority } from "@src/types"; +import { useDebouncedValue } from "@utils/useDebouncedValue"; +import { useInfiniteScrollSentinel } from "@utils/useInfiniteScrollSentinel"; +import { + ALL_VULNERABILITY_PRIORITIES, + VULNERABILITY_PRIORITY_LABEL, + vulnerabilityPriorityColor, +} from "@utils/vulnerabilities"; +import { SearchBar } from "@components/support/SearchBar"; +import { EmptyState } from "@components/support/EmptyState"; +import { ErrorState } from "@components/support/ErrorState"; + +function VulnerabilityCard({ item }: { item: Vulnerability }) { + return ( + + + + + {item.cveId || item.vulnerabilityId || "—"} + + {item.priority && ( + + )} + + + {item.componentName || "—"} + {item.componentVersion ? ` ${item.componentVersion}` : ""} + + + {item.productName || "—"} + {item.productVersion ? ` ${item.productVersion}` : ""} + + + + ); +} + +function VulnerabilityCardSkeleton() { + return ( + + + +   + + + + ); +} + +// Read-only product vulnerabilities list (search + single-select priority filter), +// infinite-scrolled — mirrors the webapp's ProductVulnerabilitiesTab, minus its +// Table/TablePagination (this app's list pages are all infinite-scrolled). +export function VulnerabilitiesTab() { + const [filters, setFilters] = useState(EMPTY_VULNERABILITY_FILTERS); + const debouncedSearch = useDebouncedValue(filters.search.trim(), 300); + + const { data, isLoading, isError, refetch, hasNextPage, isFetchingNextPage, fetchNextPage } = useInfiniteQuery( + vulnerabilities.infinite({ ...filters, search: debouncedSearch }), + ); + + const items = data?.pages.flatMap((p) => p.items) ?? []; + const total = data?.pages[0]?.total ?? items.length; + + const sentinelRef = useInfiniteScrollSentinel({ hasNextPage, isFetchingNextPage, fetchNextPage }); + + const togglePriority = (priority: VulnerabilityPriority) => { + setFilters((prev) => ({ ...prev, priority: prev.priority === priority ? null : priority })); + }; + + return ( + + setFilters((prev) => ({ ...prev, search: value }))} + placeholder="Search vulnerabilities…" + /> + + + {ALL_VULNERABILITY_PRIORITIES.map((priority) => { + const isSelected = filters.priority === priority; + return ( + togglePriority(priority)} + /> + ); + })} + + + {isLoading ? ( + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + ) : isError ? ( + void refetch()} /> + ) : items.length === 0 ? ( + + ) : ( + + + {items.length} of {total} + + + {items.map((item) => ( + + ))} + +
+ + {isFetchingNextPage && } + {!hasNextPage && ( + + You're all caught up! + + )} + + )} + + ); +} diff --git a/apps/csm-portal/microapp/src/components/support/AttachmentsField.tsx b/apps/csm-portal/microapp/src/components/support/AttachmentsField.tsx index 1f399ab710..b7bd420b1a 100644 --- a/apps/csm-portal/microapp/src/components/support/AttachmentsField.tsx +++ b/apps/csm-portal/microapp/src/components/support/AttachmentsField.tsx @@ -173,6 +173,9 @@ interface AttachmentsFieldProps { /** Per-file size cap. Defaults to the standalone-attachment ceiling; callers with a tighter * budget (e.g. inline attachments on a comment) pass MAX_INLINE_ATTACHMENT_SIZE_BYTES. */ maxSizeBytes?: number; + /** Overrides the default "Attachments (optional)" label — e.g. NewSecurityReportPage passes + * "Attachments (required)" since the backend rejects a security report with none. */ + label?: string; } // Mirrors the webapp's shared AttachmentsField @@ -184,6 +187,7 @@ export function AttachmentsField({ onChange, disabled, maxSizeBytes = MAX_ATTACHMENT_SIZE_BYTES, + label = "Attachments (optional)", }: AttachmentsFieldProps) { const { inputRef, error, handleFiles, openPicker } = useAttachmentPicker(onChange, maxSizeBytes); @@ -193,7 +197,7 @@ export function AttachmentsField({ - Attachments (optional) + {label} + + + + + ); +} diff --git a/apps/csm-portal/microapp/src/pages/ProjectDetailPage.tsx b/apps/csm-portal/microapp/src/pages/ProjectDetailPage.tsx index bc01d38267..b8619d72de 100644 --- a/apps/csm-portal/microapp/src/pages/ProjectDetailPage.tsx +++ b/apps/csm-portal/microapp/src/pages/ProjectDetailPage.tsx @@ -20,7 +20,7 @@ import { useQuery } from "@tanstack/react-query"; import { Card, Chip, Divider, Skeleton, Stack, Tab, Tabs, Typography } from "@wso2/oxygen-ui"; import { projects } from "@src/services/projects"; import { formatDateOnly, formatEnumLabel } from "@utils/customers"; -import { MetaRow, MetaValue } from "@components/customers/MetaRow"; +import { MetaRow, MetaValue } from "@components/common/MetaRow"; import { ProjectIssuesTab } from "@components/customers/ProjectIssuesTab"; import { DeploymentsTab } from "@components/customers/DeploymentsTab"; import { ErrorState } from "@components/support/ErrorState"; diff --git a/apps/csm-portal/microapp/src/pages/SecurityCenterPage.tsx b/apps/csm-portal/microapp/src/pages/SecurityCenterPage.tsx index a9d3303e85..36d1a059c3 100644 --- a/apps/csm-portal/microapp/src/pages/SecurityCenterPage.tsx +++ b/apps/csm-portal/microapp/src/pages/SecurityCenterPage.tsx @@ -14,9 +14,36 @@ // specific language governing permissions and limitations // under the License. -import { ComingSoonPage } from "@components/common/ComingSoonPage"; +import { useState } from "react"; +import { Stack, Tab, Tabs } from "@wso2/oxygen-ui"; +import { SecurityReportsTab } from "@components/security-center/SecurityReportsTab"; +import { VulnerabilitiesTab } from "@components/security-center/VulnerabilitiesTab"; -// The webapp also marks Security Center as wip:true (apps/csm-portal/webapp/src/config/csmNavItems.ts). +type SecurityCenterTabId = "security_reports" | "vulnerabilities"; + +const TABS: { id: SecurityCenterTabId; label: string }[] = [ + { id: "security_reports", label: "Security reports" }, + { id: "vulnerabilities", label: "Vulnerabilities" }, +]; + +// Mirrors the webapp's CsmSecurityCenterPage: Security reports (cases of type +// "security_report_analysis") | Vulnerabilities (a separate, non-case-backed +// entity). "New security report" lives behind SecurityReportsTab's own Fab +// (see NewSecurityReportPage.tsx) — Vulnerabilities stays read-only, there's no +// create flow for that entity in either app. export default function SecurityCenterPage() { - return ; + const [activeTab, setActiveTab] = useState("security_reports"); + + return ( + + setActiveTab(value)}> + {TABS.map((tab) => ( + + ))} + + + {activeTab === "security_reports" && } + {activeTab === "vulnerabilities" && } + + ); } diff --git a/apps/csm-portal/microapp/src/pages/VulnerabilityDetailPage.tsx b/apps/csm-portal/microapp/src/pages/VulnerabilityDetailPage.tsx new file mode 100644 index 0000000000..25e21fcf39 --- /dev/null +++ b/apps/csm-portal/microapp/src/pages/VulnerabilityDetailPage.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { Card, Chip, Divider, Skeleton, Stack, Typography } from "@wso2/oxygen-ui"; +import { vulnerabilities } from "@src/services/vulnerabilities"; +import { vulnerabilityPriorityColor } from "@utils/vulnerabilities"; +import { MetaRow, MetaValue } from "@components/common/MetaRow"; +import { ErrorState } from "@components/support/ErrorState"; + +function TextSection({ title, text }: { title: string; text: string | null }) { + if (!text || text.trim().length === 0) return null; + return ( + + {title} + + {text} + + + ); +} + +export default function VulnerabilityDetailPage() { + const { id } = useParams<{ id: string }>(); + const { data, isLoading, isError, refetch } = useQuery(vulnerabilities.get(id ?? "")); + + if (isLoading) { + return ( + + + + + ); + } + + if (isError || !data) { + return void refetch()} />; + } + + const v = data; + const heading = v.cveId || v.vulnerabilityId || "Vulnerability"; + const hasLongFormContent = !!(v.useCase?.trim() || v.justification?.trim() || v.resolution?.trim()); + + return ( + + + + + {heading} + + {v.priority && } + + {v.cveId && v.vulnerabilityId && v.cveId !== v.vulnerabilityId && ( + + {v.vulnerabilityId} + + )} + + + + + + {v.componentName ?? "—"} + + + + {v.componentVersion ?? "—"} + + + + {v.componentType ?? "—"} + + + + {v.productName ?? "—"} + + + + {v.productVersion ?? "—"} + + + + {v.type ?? "—"} + + + + {v.updateLevel ?? "—"} + + + + + {hasLongFormContent && ( + + + + + + + + )} + + ); +} diff --git a/apps/csm-portal/microapp/src/services/cases.ts b/apps/csm-portal/microapp/src/services/cases.ts index 9bd7b15707..fa2b624fd3 100644 --- a/apps/csm-portal/microapp/src/services/cases.ts +++ b/apps/csm-portal/microapp/src/services/cases.ts @@ -27,6 +27,7 @@ import type { CaseCommentCreateResponseDto, CaseCommentSearchResponseDto, CaseCreatePayloadDto, + CaseCreateResponseDto, CasePatchPayloadDto, CaseSearchFiltersDto, CaseSearchPayloadDto, @@ -34,6 +35,7 @@ import type { CaseType, CaseViewDto, CreatedCaseDto, + SecurityReportCreatePayloadDto, UpdateCaseResponseDto, } from "@src/types"; import { toCaseDetail, toCaseSummary, toComment, type CaseDetail, type CaseSummary, type Comment } from "@src/types"; @@ -200,9 +202,9 @@ const getCaseComments = async (id: string): Promise => { return data.comments.map(toComment); }; -const createCase = async (payload: CaseCreatePayloadDto): Promise => { - const { data } = await apiClient.post(CASES_ENDPOINT, payload); - return data; +const createCase = async (payload: CaseCreatePayloadDto | SecurityReportCreatePayloadDto): Promise => { + const { data } = await apiClient.post(CASES_ENDPOINT, payload); + return data.case; }; const patchCase = async (id: string, payload: CasePatchPayloadDto): Promise => { diff --git a/apps/csm-portal/microapp/src/services/securityReports.ts b/apps/csm-portal/microapp/src/services/securityReports.ts new file mode 100644 index 0000000000..3a36b325b6 --- /dev/null +++ b/apps/csm-portal/microapp/src/services/securityReports.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Security reports are cases of `type: "security_report_analysis"`, so this is +// a single `POST /cases/search` scoped to that type — read-only (create isn't +// in scope for this pass, mirrors the webapp's CsmSecurityCenterPage's Security +// reports tab, which only lists/filters; "New security report" is deliberately +// deferred, same call Engagements/Operations already made). Paged via infinite +// scroll, newest-updated first, mirroring services/engagements.ts. + +import { infiniteQueryOptions } from "@tanstack/react-query"; +import { CASES_SEARCH_ENDPOINT } from "@config/endpoints"; +import type { CaseSearchPayloadDto, CaseSearchResponseDto } from "@src/types"; +import { toCaseSummary, type CaseSummary } from "@src/types"; +import type { SecurityReportFilters } from "@utils/securityReports"; +import apiClient from "./apiClient"; + +export interface SecurityReportSearchResult { + items: CaseSummary[]; + total: number; + offset: number; + limit: number; + hasMore: boolean; +} + +const SECURITY_REPORTS_PAGE_LIMIT = 20; + +// Empty filter arrays are omitted so the search defaults to every state across +// all projects. `searchQuery` matches subject/number. +async function searchSecurityReports( + filters: SecurityReportFilters, + offset: number, +): Promise { + const q = filters.search.trim(); + const payload: CaseSearchPayloadDto = { + pagination: { offset, limit: SECURITY_REPORTS_PAGE_LIMIT }, + sortBy: { field: "updatedOn", order: "desc" }, + filters: { + types: ["security_report_analysis"], + ...(q ? { searchQuery: q } : {}), + ...(filters.states.length ? { states: filters.states } : {}), + ...(filters.workStates.length ? { workStates: filters.workStates } : {}), + ...(filters.projects.length ? { projectIds: filters.projects.map((p) => p.id) } : {}), + ...(filters.assignees.length ? { assignedUserIds: filters.assignees.map((a) => a.id) } : {}), + ...(filters.productNames.length ? { productNames: filters.productNames } : {}), + }, + }; + const { data } = await apiClient.post(CASES_SEARCH_ENDPOINT, payload); + const items = data.cases.map(toCaseSummary); + return { + items, + total: data.total, + offset: data.offset, + limit: data.limit, + // Some data sources omit hasMore from the search envelope (see cases.ts's + // getAllCases and adminUsers.ts's searchUsers, which hit the same quirk); + // derive it from offset/total when that happens instead of treating a + // missing/false field as "no more pages" after the first page. Also require + // a non-empty page — even when the backend *does* send an explicit + // hasMore: true — since an empty page with a stale/inconsistent total + // should never report hasMore, or the infinite query would keep + // requesting further pages forever. + hasMore: items.length > 0 && (data.hasMore ?? data.offset + items.length < data.total), + }; +} + +export const securityReports = { + infinite: (filters: SecurityReportFilters) => + infiniteQueryOptions({ + queryKey: ["security-reports", filters], + queryFn: ({ pageParam }) => searchSecurityReports(filters, pageParam), + initialPageParam: 0, + getNextPageParam: (last) => (last.hasMore ? last.offset + last.limit : undefined), + }), +}; diff --git a/apps/csm-portal/microapp/src/services/vulnerabilities.ts b/apps/csm-portal/microapp/src/services/vulnerabilities.ts new file mode 100644 index 0000000000..33a06eb29c --- /dev/null +++ b/apps/csm-portal/microapp/src/services/vulnerabilities.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; +import { PRODUCT_VULNERABILITIES_SEARCH_ENDPOINT, PRODUCT_VULNERABILITY_ENDPOINT } from "@config/endpoints"; +import type { VulnerabilityDto, VulnerabilityPriority, VulnerabilitySearchResponseDto } from "@src/types"; +import { toVulnerability, type Vulnerability } from "@src/types"; +import apiClient from "./apiClient"; + +export interface VulnerabilityFilters { + search: string; + priority: VulnerabilityPriority | null; +} + +export const EMPTY_VULNERABILITY_FILTERS: VulnerabilityFilters = { search: "", priority: null }; + +export interface VulnerabilitySearchResult { + items: Vulnerability[]; + total: number; + offset: number; + limit: number; + hasMore: boolean; +} + +// openapi.yaml declares this endpoint's pagination.limit maximum as 100, but every other search +// endpoint on this backend that claims the same (comments, products, deployments) actually +// rejects it with a 400 — see services/cases.ts's COMMENTS_PAGE_LIMIT and +// services/deployments.ts's SEARCH_PAGE_LIMIT for the confirmed cases. Match that real, working +// value defensively here too rather than waiting to hit the same 400 live. +const VULNERABILITIES_PAGE_LIMIT = 50; + +async function searchVulnerabilities( + filters: VulnerabilityFilters, + offset: number, +): Promise { + const q = filters.search.trim(); + const { data } = await apiClient.post(PRODUCT_VULNERABILITIES_SEARCH_ENDPOINT, { + pagination: { offset, limit: VULNERABILITIES_PAGE_LIMIT }, + filters: { + ...(q ? { searchQuery: q } : {}), + ...(filters.priority ? { priority: filters.priority } : {}), + }, + }); + const items = data.productVulnerabilities.map(toVulnerability); + return { + items, + total: data.total, + offset: data.offset, + limit: data.limit, + // The search response carries no hasMore field at all (unlike every other search endpoint in + // this app), so this is always derived, with the same empty-page guard used elsewhere against + // looping forever on a stale/inconsistent total. + hasMore: items.length > 0 && data.offset + items.length < data.total, + }; +} + +const getVulnerability = async (id: string): Promise => { + const { data } = await apiClient.get(PRODUCT_VULNERABILITY_ENDPOINT(id)); + return toVulnerability(data); +}; + +export const vulnerabilities = { + infinite: (filters: VulnerabilityFilters) => + infiniteQueryOptions({ + queryKey: ["vulnerabilities", "infinite", filters], + queryFn: ({ pageParam }) => searchVulnerabilities(filters, pageParam), + initialPageParam: 0, + getNextPageParam: (last) => (last.hasMore ? last.offset + last.limit : undefined), + }), + + get: (id: string) => + queryOptions({ + queryKey: ["vulnerability", id], + queryFn: () => getVulnerability(id), + }), +}; diff --git a/apps/csm-portal/microapp/src/types/case.dto.ts b/apps/csm-portal/microapp/src/types/case.dto.ts index 7ff63ad527..8ce8b2e5fc 100644 --- a/apps/csm-portal/microapp/src/types/case.dto.ts +++ b/apps/csm-portal/microapp/src/types/case.dto.ts @@ -288,8 +288,8 @@ export type CaseCause = | "INFRASTRUCTURE_SAAS_SIDE_OTHER" | "UNKNOWN"; -// Mirrors the webapp's BeCaseCreatePayload (the "case" type variant only — service_request and -// security_report_analysis creation aren't in the microapp's scope, see NewCasePage.tsx). +// Mirrors the webapp's BeCaseCreatePayload (the "case" type variant only — service_request +// creation isn't in the microapp's scope, see NewCasePage.tsx). export interface CaseCreatePayloadDto { type: "case"; projectId: string; @@ -301,6 +301,30 @@ export interface CaseCreatePayloadDto { issueType: CaseIssueType; } +// The "security_report_analysis" type variant — see NewSecurityReportPage.tsx. Unlike the "case" +// type, attachments are embedded directly in the create payload (raw base64, no `data:` prefix) +// rather than uploaded separately via POST /attachments after the case exists; the backend +// requires at least one entry. +export interface SecurityReportCreatePayloadDto { + type: "security_report_analysis"; + projectId: string; + deploymentId: string; + deployedProductId: string; + subject: string; + description: string; + attachments: { name: string; file: string }[]; +} + export interface CreatedCaseDto { id: string; } + +// POST /cases wraps the created case in a { message, case } envelope rather than returning it +// flat — confirmed against the webapp's usePostCsmCase.ts (BeCaseCreateResponse/BeCreatedCase), +// which unwraps res.case for exactly this reason. openapi.yaml's postCases 201 response +// ($ref: Case) doesn't reflect the envelope; trust the webapp's actual working code over the spec +// here, same doc-vs-reality gap seen elsewhere in this backend family. +export interface CaseCreateResponseDto { + message?: string; + case: CreatedCaseDto; +} diff --git a/apps/csm-portal/microapp/src/types/index.ts b/apps/csm-portal/microapp/src/types/index.ts index 257a1b2570..a75df4e095 100644 --- a/apps/csm-portal/microapp/src/types/index.ts +++ b/apps/csm-portal/microapp/src/types/index.ts @@ -40,3 +40,5 @@ export * from "./changeRequest.dto"; export * from "./changeRequest.model"; export * from "./updates.dto"; export * from "./updates.model"; +export * from "./vulnerability.dto"; +export * from "./vulnerability.model"; diff --git a/apps/csm-portal/microapp/src/types/vulnerability.dto.ts b/apps/csm-portal/microapp/src/types/vulnerability.dto.ts new file mode 100644 index 0000000000..888ef4bc59 --- /dev/null +++ b/apps/csm-portal/microapp/src/types/vulnerability.dto.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +export type VulnerabilityPriority = "info" | "low" | "medium" | "high" | "critical" | "unknown"; + +// Same shape for both a search-result row and GET /products/vulnerabilities/{id} — like Account, +// unlike Project/Deployment, there's no separate enriched detail shape. +export interface VulnerabilityDto { + id: string; + cveId?: string; + vulnerabilityId?: string; + /** Priority label from the upstream (e.g. "High", "Critical") — not the lowercase + * VulnerabilityPriority enum. */ + priority?: string; + productName?: string | null; + productVersion?: string | null; + componentName?: string; + version?: string; + type?: string; + componentType?: string | null; + updateLevel?: string | null; + useCase?: string | null; + justification?: string | null; + resolution?: string | null; +} + +export interface VulnerabilitySearchFiltersDto { + searchQuery?: string; + priority?: VulnerabilityPriority; + productName?: string; + productVersion?: string; +} + +export interface VulnerabilitySearchPayloadDto { + filters?: VulnerabilitySearchFiltersDto; + pagination?: { offset?: number; limit?: number }; +} + +// The backend's SearchProductVulnerabilitiesResponse carries no `hasMore` field at all — derive +// it from offset/total instead. +export interface VulnerabilitySearchResponseDto { + productVulnerabilities: VulnerabilityDto[]; + total: number; + limit: number; + offset: number; +} diff --git a/apps/csm-portal/microapp/src/types/vulnerability.model.ts b/apps/csm-portal/microapp/src/types/vulnerability.model.ts new file mode 100644 index 0000000000..b7e16ac186 --- /dev/null +++ b/apps/csm-portal/microapp/src/types/vulnerability.model.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import type { VulnerabilityDto } from "./vulnerability.dto"; + +export interface Vulnerability { + id: string; + cveId: string | null; + vulnerabilityId: string | null; + /** Priority label from the upstream (e.g. "High"), not the lowercase enum — see + * utils/vulnerabilities.ts's vulnerabilityPriorityColor for the display-color mapping. */ + priority: string | null; + productName: string | null; + productVersion: string | null; + componentName: string | null; + componentVersion: string | null; + type: string | null; + componentType: string | null; + updateLevel: string | null; + useCase: string | null; + justification: string | null; + resolution: string | null; +} + +export function toVulnerability(dto: VulnerabilityDto): Vulnerability { + return { + id: dto.id, + cveId: dto.cveId ?? null, + vulnerabilityId: dto.vulnerabilityId ?? null, + priority: dto.priority ?? null, + productName: dto.productName ?? null, + productVersion: dto.productVersion ?? null, + componentName: dto.componentName ?? null, + componentVersion: dto.version ?? null, + type: dto.type ?? null, + componentType: dto.componentType ?? null, + updateLevel: dto.updateLevel ?? null, + useCase: dto.useCase ?? null, + justification: dto.justification ?? null, + resolution: dto.resolution ?? null, + }; +} diff --git a/apps/csm-portal/microapp/src/utils/attachments.ts b/apps/csm-portal/microapp/src/utils/attachments.ts index 7cd5294e38..8e7b42d397 100644 --- a/apps/csm-portal/microapp/src/utils/attachments.ts +++ b/apps/csm-portal/microapp/src/utils/attachments.ts @@ -60,3 +60,11 @@ export async function toPendingAttachment(file: File): Promise;base64," prefix from a PendingAttachment's data URI, producing the raw + * base64 payload the case-create endpoint's embedded `attachments` field wants (security reports + * only — every other attachment path uses POST /attachments, which wants the full data URI). */ +export function toRawBase64(dataUrl: string): string { + const comma = dataUrl.indexOf(","); + return comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl; +} diff --git a/apps/csm-portal/microapp/src/utils/securityReports.ts b/apps/csm-portal/microapp/src/utils/securityReports.ts new file mode 100644 index 0000000000..4095388ece --- /dev/null +++ b/apps/csm-portal/microapp/src/utils/securityReports.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import type { CaseState, CaseWorkState, Project } from "@src/types"; + +/** Lightweight assignee ref for the filter chip — mirrors how `projects` stores a + * display-ready `Project[]` rather than bare ids (see utils/engagements.ts's own copy). */ +export interface SecurityReportAssignee { + id: string; + name: string; +} + +// UI-facing security report filters. `search` matches subject/number (server-side +// `searchQuery`); `states` → server-side `states`; `workStates` → server-side +// `workStates` (only meaningful while `states` includes "work_in_progress"); +// `projects` → server-side `projectIds`; `assignees` → server-side +// `assignedUserIds`; `productNames` → server-side `productNames`. All empty by +// default, so the list shows every state across all projects. (No severity — +// security reports carry none, same as the webapp's CsmIssuesView locks it out +// for any non-"case" type. No engagement type either — that's engagement-only.) +export interface SecurityReportFilters { + search: string; + states: CaseState[]; + workStates: NonNullable[]; + projects: Project[]; + assignees: SecurityReportAssignee[]; + productNames: string[]; +} + +export const EMPTY_SECURITY_REPORT_FILTERS: SecurityReportFilters = { + search: "", + states: [], + workStates: [], + projects: [], + assignees: [], + productNames: [], +}; + +/** Active filter groups — drives the "Filters (n)" badge. Search is excluded (it + * has its own always-visible box). */ +export function countActiveSecurityReportFilters(filters: SecurityReportFilters): number { + let count = 0; + if (filters.states.length > 0) count += 1; + if (filters.workStates.length > 0) count += 1; + if (filters.projects.length > 0) count += 1; + if (filters.assignees.length > 0) count += 1; + if (filters.productNames.length > 0) count += 1; + return count; +} diff --git a/apps/csm-portal/microapp/src/utils/vulnerabilities.ts b/apps/csm-portal/microapp/src/utils/vulnerabilities.ts new file mode 100644 index 0000000000..7bb0e60afe --- /dev/null +++ b/apps/csm-portal/microapp/src/utils/vulnerabilities.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import type { ChipProps } from "@wso2/oxygen-ui"; +import type { VulnerabilityPriority } from "@src/types"; + +/** All vulnerability priorities in display order (highest to lowest) — mirrors the + * webapp's VULNERABILITY_PRIORITIES (csm-security-center/utils/vulnerabilities.ts). */ +export const ALL_VULNERABILITY_PRIORITIES: VulnerabilityPriority[] = [ + "critical", + "high", + "medium", + "low", + "info", + "unknown", +]; + +export const VULNERABILITY_PRIORITY_LABEL: Record = { + critical: "Critical", + high: "High", + medium: "Medium", + low: "Low", + info: "Info", + unknown: "Unknown", +}; + +const PRIORITY_COLOR: Record> = { + critical: "error", + high: "error", + medium: "warning", + low: "info", + info: "default", + unknown: "default", +}; + +/** Chip colour for a priority value. Accepts both the enum values ("high") and the label + * strings the search/detail endpoints actually return ("High") by normalizing to lowercase — + * mirrors the webapp's vulnerabilityPriorityColor. */ +export function vulnerabilityPriorityColor(priority?: string | null): NonNullable { + if (!priority) return "default"; + return PRIORITY_COLOR[priority.toLowerCase()] ?? "default"; +}