Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8b091de
[CSM Portal Microapp] Relocate MetaRow from customers/ to common/
Hesara2003 Jul 22, 2026
355ade5
[CSM Portal Microapp] Add the Security reports tab
Hesara2003 Jul 22, 2026
060d02e
[CSM Portal Microapp] Add the Vulnerability domain type
Hesara2003 Jul 22, 2026
28156f2
[CSM Portal Microapp] Add the vulnerabilities service
Hesara2003 Jul 22, 2026
0673500
[CSM Portal Microapp] Add the Vulnerabilities list and detail page
Hesara2003 Jul 22, 2026
60df9e1
[CSM Portal Microapp] Wire up Security Center as a 2-tab shell
Hesara2003 Jul 22, 2026
3acd9d6
[CSM Portal Microapp] Add toRawBase64 for the security-report attachm…
Hesara2003 Jul 23, 2026
5fb23b4
[CSM Portal Microapp] Add SecurityReportCreatePayloadDto
Hesara2003 Jul 23, 2026
d0aa504
[CSM Portal Microapp] Widen cases.create to accept security report pa…
Hesara2003 Jul 23, 2026
4fcc95a
[CSM Portal Microapp] Add an overridable label prop to AttachmentsField
Hesara2003 Jul 23, 2026
911f43d
[CSM Portal Microapp] Add the New Security Report page
Hesara2003 Jul 23, 2026
0d2e538
[CSM Portal Microapp] Route New Security Report at /more/security-cen…
Hesara2003 Jul 23, 2026
e8510ef
[CSM Portal Microapp] Add the New Security Report Fab
Hesara2003 Jul 23, 2026
f24e416
[CSM Portal Microapp] Update comment now that security report creatio…
Hesara2003 Jul 23, 2026
714f2d0
[CSM Portal Microapp] Unwrap POST /cases's {message,case} envelope
Hesara2003 Jul 23, 2026
70cfdfc
[CSM Portal Microapp] Trim search before debouncing in Vulnerabilitie…
Hesara2003 Jul 23, 2026
938b1d5
[CSM Portal Microapp] Measure exact payload bytes instead of approxim…
Hesara2003 Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/csm-portal/microapp/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -74,6 +76,8 @@ export default function App() {
<Route path="/more/announcements" element={<AnnouncementsPage />} />
<Route path="/more/time-cards" element={<TimeCardsPage />} />
<Route path="/more/security-center" element={<SecurityCenterPage />} />
<Route path="/more/security-center/reports/new" element={<NewSecurityReportPage />} />
<Route path="/more/security-center/vulnerabilities/:id" element={<VulnerabilityDetailPage />} />
<Route path="/more/updates" element={<UpdatesPage />} />
<Route path="/more/engagements" element={<EngagementsPage />} />
<Route path="/more/customers" element={<CustomersPage />} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" gap={2}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T>(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 (
<Autocomplete
multiple
size="small"
options={options}
value={value}
loading={isFetching}
getOptionLabel={(o) => o.name}
isOptionEqualToValue={(a, b) => a.id === b.id}
onChange={(_, next) => onChange(next)}
onInputChange={(_, next) => setInput(next)}
slotProps={{ paper: OPAQUE_POPUP }}
renderInput={(params) => <TextField {...params} label="Project" size="small" />}
/>
);
}

// 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 (
<Autocomplete
multiple
size="small"
options={options}
value={value}
loading={isFetching}
getOptionLabel={(o) => 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) => <TextField {...params} label="Assignee" size="small" placeholder="Search engineers…" />}
/>
);
}

// 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<string>(data ?? []);
value.forEach((v) => merged.add(v));
return [...merged].sort((a, b) => a.localeCompare(b));
}, [data, value]);

return (
<Autocomplete
multiple
size="small"
options={options}
value={value}
loading={isFetching}
onChange={(_, next) => onChange(next)}
slotProps={{ paper: OPAQUE_POPUP }}
renderInput={(params) => <TextField {...params} label="Product" size="small" />}
/>
);
}

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<SecurityReportFilters>(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 (
<Dialog
open={open}
onClose={onClose}
fullWidth
maxWidth="xs"
slotProps={{ paper: { sx: { backgroundImage: "none", backgroundColor: "background.default" } } }}
>
<DialogTitle sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
Filters
<IconButton size="small" aria-label="Close filters" onClick={onClose}>
<X size={18} />
</IconButton>
</DialogTitle>

<Divider />

<DialogContent>
<Stack gap={2.5}>
<Stack gap={1}>
<Typography variant="subtitle2">State</Typography>
<Stack direction="row" gap={1} flexWrap="wrap">
{FILTERABLE_STATES.map((state) => {
const isSelected = draft.states.includes(state);
return (
<Chip
key={state}
label={STATE_LABELS[state] ?? state}
size="small"
aria-pressed={isSelected}
variant={isSelected ? "filled" : "outlined"}
color={isSelected ? "primary" : "default"}
onClick={() => {
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 : [],
});
}}
/>
);
})}
</Stack>
</Stack>

<Stack gap={1}>
<Typography variant="subtitle2">Work state</Typography>
<Stack direction="row" gap={1} flexWrap="wrap">
{ALL_WORK_STATES.map((workState) => {
const isSelected = draft.workStates.includes(workState);
const chip = (
<Chip
label={WORK_STATE_LABEL[workState]}
size="small"
aria-pressed={isSelected}
variant={isSelected ? "filled" : "outlined"}
color={isSelected ? "primary" : "default"}
disabled={!workInProgressSelected}
onClick={() => 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 ? (
<span key={workState}>{chip}</span>
) : (
<Tooltip key={workState} title={`Select "${STATE_LABELS.work_in_progress}" to filter by work state`}>
<span>{chip}</span>
</Tooltip>
);
})}
</Stack>
</Stack>

<AssigneeMultiSelect value={draft.assignees} onChange={(next) => setDraft({ ...draft, assignees: next })} />

<ProjectMultiSelect value={draft.projects} onChange={(next) => setDraft({ ...draft, projects: next })} />

<ProductMultiSelect
value={draft.productNames}
onChange={(next) => setDraft({ ...draft, productNames: next })}
/>
</Stack>
</DialogContent>

<Divider />

<DialogActions>
<Button
onClick={() => {
// Keep the current search text; only the sheet's own filters reset.
const cleared = { ...EMPTY_SECURITY_REPORT_FILTERS, search: draft.search };
setDraft(cleared);
onApply(cleared);
onClose();
}}
>
Clear all
</Button>
<Button
variant="contained"
onClick={() => {
onApply(draft);
onClose();
}}
>
Apply
</Button>
</DialogActions>
</Dialog>
);
}
Loading