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 (
+
+ );
+}
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}