Add teams features to Vuln details page (#16761)

## Addresses #16758 

- Add Teams functionality to the Vulnerability details page
  - Premium only
  -  All links from page include team_id query param
      - To hosts page
      - To OS details pages
      - To SW version details pages
  - API call includes `team_id` query param 
  - Teams header
- If user only has access to one team, just shows the teamname (no
dropdown)
    -  Otherwise, Dropdown with team options
    - Includes "All teams," but not "No teams"
- Add empty state when ~both vulnerable OS versions and SW versions are
empty~ affected hosts === 0

<img width="1657" alt="Screenshot 2024-02-12 at 4 47 36 PM"
src="https://github.com/fleetdm/fleet/assets/61553566/4f0bb856-57c4-4905-b82b-b00dac6a8306">
<img width="1552" alt="Screenshot 2024-02-12 at 4 45 42 PM"
src="https://github.com/fleetdm/fleet/assets/61553566/45d6bbb5-0b31-46df-a715-7fa176029a1d">
<img width="1552" alt="Screenshot 2024-02-12 at 4 45 47 PM"
src="https://github.com/fleetdm/fleet/assets/61553566/de6c68a6-aaa4-4637-998c-569b4d189433">

---------
Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
Jacob Shandling
2024-02-15 10:35:58 -07:00
committed by mostlikelee
co-authored by Jacob Shandling
parent 4f32ba3235
commit f0c4dd854b
13 changed files with 241 additions and 58 deletions
@@ -0,0 +1,40 @@
import React from "react";
import { ITeamSummary } from "interfaces/team";
import TeamsDropdown from "components/TeamsDropdown";
interface ITeamsHeader {
isOnGlobalTeam?: boolean;
currentTeamId?: number;
userTeams?: ITeamSummary[];
isSandboxMode?: boolean;
onTeamChange: (teamId: number) => void;
}
const TeamsHeader = ({
isOnGlobalTeam,
currentTeamId,
userTeams = [],
isSandboxMode = false,
onTeamChange,
}: ITeamsHeader) => {
if (userTeams) {
if (userTeams.length > 1 || isOnGlobalTeam) {
return (
<TeamsDropdown
currentUserTeams={userTeams}
selectedTeamId={currentTeamId}
onChange={onTeamChange}
isSandboxMode={isSandboxMode}
/>
);
}
if (userTeams.length === 1 && !isOnGlobalTeam) {
return <h1>{userTeams[0].name}</h1>;
}
}
return <></>;
};
export default TeamsHeader;
+1
View File
@@ -0,0 +1 @@
export { default } from "./TeamsHeader";
@@ -17,6 +17,8 @@ interface IHostLinkProps {
customText?: string;
/** Table links shows on row hover only */
rowHover?: boolean;
// don't actually create a link, useful when click is handled by an ancestor
noLink?: boolean;
}
const baseClass = "view-all-hosts-link";
@@ -29,6 +31,7 @@ const ViewAllHostsLink = ({
responsive = false,
customText,
rowHover = false,
noLink = false,
}: IHostLinkProps): JSX.Element => {
const viewAllHostsLinkClass = classnames(baseClass, className, {
"row-hover-link": rowHover,
@@ -43,7 +46,11 @@ const ViewAllHostsLink = ({
: endpoint;
return (
<Link className={viewAllHostsLinkClass} to={path} title="host-link">
<Link
className={viewAllHostsLinkClass}
to={noLink ? "" : path}
title="host-link"
>
{!condensed && (
<span
className={`${baseClass}__text${responsive ? "--responsive" : ""}`}
+2 -1
View File
@@ -327,6 +327,7 @@ export const useTeamIdParam = ({
}, [contextTeam?.id, currentTeam, isRouteOk, setContextTeam]);
return {
// essentially `currentTeamIdForAppContext`, where -1: all teams, 0: no team, and positive integers: team ids
currentTeamId: currentTeam?.id,
currentTeamName: currentTeam?.name,
currentTeamSummary: currentTeam,
@@ -347,7 +348,7 @@ export const useTeamIdParam = ({
!!currentTeam?.id &&
!!currentUser &&
permissions.isObserverPlus(currentUser, currentTeam.id),
teamIdForApi: getTeamIdForApi({ currentTeam, includeNoTeam }),
teamIdForApi: getTeamIdForApi({ currentTeam, includeNoTeam }), // for everywhere except AppContext
userTeams,
handleTeamChange,
};
+10 -15
View File
@@ -24,7 +24,7 @@ import { buildQueryStringFromParams } from "utilities/url";
import Button from "components/buttons/Button";
import MainContent from "components/MainContent";
import TeamsDropdown from "components/TeamsDropdown";
import TeamsHeader from "components/TeamsHeader";
import TabsWrapper from "components/TabsWrapper";
import ManageAutomationsModal from "./components/ManageSoftwareAutomationsModal";
@@ -280,20 +280,15 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
return (
<>
{isFreeTier && <h1>Software</h1>}
{isPremiumTier &&
userTeams &&
(userTeams.length > 1 || isOnGlobalTeam) && (
<TeamsDropdown
currentUserTeams={userTeams || []}
selectedTeamId={currentTeamId}
onChange={onTeamChange}
isSandboxMode={isSandboxMode}
/>
)}
{isPremiumTier &&
!isOnGlobalTeam &&
userTeams &&
userTeams.length === 1 && <h1>{userTeams[0].name}</h1>}
{isPremiumTier && (
<TeamsHeader
isOnGlobalTeam={isOnGlobalTeam}
currentTeamId={currentTeamId}
userTeams={userTeams}
onTeamChange={onTeamChange}
isSandboxMode={isSandboxMode}
/>
)}
</>
);
};
@@ -18,16 +18,19 @@ interface ISoftwareVulnOSVersions {
osVersions: IVulnerability["os_versions"];
isPremiumTier: boolean;
router: InjectedRouter;
teamIdForApi?: number;
}
const SoftwareVulnOSVersions = ({
osVersions,
isPremiumTier,
router,
teamIdForApi,
}: ISoftwareVulnOSVersions) => {
const columnConfigs = useMemo(() => generateColumnConfigs(isPremiumTier), [
isPremiumTier,
]);
const columnConfigs = useMemo(
() => generateColumnConfigs(isPremiumTier, router, teamIdForApi),
[isPremiumTier, router, teamIdForApi]
);
const onSelectSingleRow = useCallback(
({ original: { os_version_id } }) => {
@@ -36,10 +39,13 @@ const SoftwareVulnOSVersions = ({
}
router.push(
`${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams({ os_version_id })}`
`${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams({
os_version_id,
team_id: teamIdForApi,
})}`
);
},
[router]
[teamIdForApi, router]
);
const renderVulnerableOSTable = () => {
@@ -10,6 +10,7 @@ import PATHS from "router/paths";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
import TextCell from "components/TableContainer/DataTable/TextCell";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import { InjectedRouter } from "react-router";
interface ICellProps {
row: {
@@ -23,7 +24,11 @@ interface INumberCellProps extends ICellProps {
};
}
const generateColumnConfigs = (isPremiumTier: boolean): Column[] => {
const generateColumnConfigs = (
isPremiumTier: boolean,
router: InjectedRouter,
teamIdForApi?: number
): Column[] => {
const configs = [
{
Header: "Name",
@@ -31,9 +36,21 @@ const generateColumnConfigs = (isPremiumTier: boolean): Column[] => {
accessor: "name_only",
Cell: ({ row }: ICellProps) => {
const { name, os_version_id, platform } = row.original;
const endpoint = PATHS.SOFTWARE_OS_DETAILS(os_version_id);
// since No Teams not supported on this page, falsiness of 0 is okay
const path = teamIdForApi
? `${endpoint}?team_id=${teamIdForApi}`
: endpoint;
const onClickVulnOs = (e: React.MouseEvent) => {
// Allows for button to be clickable in a clickable row
e.stopPropagation();
router?.push(path);
};
return (
<LinkCell
path={PATHS.SOFTWARE_OS_DETAILS(os_version_id)}
path={path}
customOnClick={onClickVulnOs}
value={
<>
<SoftwareIcon name={platform} />
@@ -73,6 +90,7 @@ const generateColumnConfigs = (isPremiumTier: boolean): Column[] => {
queryParams={{ os_version_id }}
responsive
rowHover
noLink
/>
</>
);
@@ -20,16 +20,19 @@ interface ISoftwareVulnSoftwareVersions {
vulnSoftware: IVulnerability["software"];
isPremiumTier: boolean;
router: InjectedRouter;
teamIdForApi?: number;
}
const SoftwareVulnSoftwareVersions = ({
vulnSoftware,
isPremiumTier,
router,
teamIdForApi,
}: ISoftwareVulnSoftwareVersions) => {
const columnConfigs = useMemo(() => generateColumnConfigs(isPremiumTier), [
isPremiumTier,
]);
const columnConfigs = useMemo(
() => generateColumnConfigs(isPremiumTier, router, teamIdForApi),
[isPremiumTier, router, teamIdForApi]
);
const onSelectSingleRow = useCallback(
({ original: { id: software_title_id } }) => {
@@ -40,10 +43,11 @@ const SoftwareVulnSoftwareVersions = ({
router.push(
`${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams({
software_title_id,
team_id: teamIdForApi,
})}`
);
},
[router]
[teamIdForApi, router]
);
const renderVulnerableSoftwareTable = () => {
return (
@@ -9,6 +9,7 @@ import LinkCell from "components/TableContainer/DataTable/LinkCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
import { InjectedRouter } from "react-router";
interface ICellProps {
cell: {
@@ -25,7 +26,11 @@ interface IStringCellProps extends ICellProps {
};
}
const generateColumnConfigs = (isPremiumTier: boolean): Column[] => {
const generateColumnConfigs = (
isPremiumTier: boolean,
router: InjectedRouter,
teamIdForApi?: number
): Column[] => {
const configs = [
{
Header: "Name",
@@ -33,9 +38,21 @@ const generateColumnConfigs = (isPremiumTier: boolean): Column[] => {
accessor: "name",
Cell: ({ row }: ICellProps) => {
const { name, id } = row.original;
const endpoint = PATHS.SOFTWARE_VERSION_DETAILS(id.toString());
// since No Teams not supported on this page, falsiness of 0 is okay
const path = teamIdForApi
? `${endpoint}?team_id=${teamIdForApi}`
: endpoint;
const onClickVulnSwVersion = (e: React.MouseEvent) => {
// Allows for button to be clickable in a clickable row
e.stopPropagation();
router?.push(path);
};
return (
<LinkCell
path={PATHS.SOFTWARE_VERSION_DETAILS(id.toString())}
path={path}
customOnClick={onClickVulnSwVersion}
value={
<>
<SoftwareIcon name={name} />
@@ -75,6 +92,7 @@ const generateColumnConfigs = (isPremiumTier: boolean): Column[] => {
queryParams={{ software_title_id: id }}
responsive
rowHover
noLink
/>
</>
);
@@ -17,11 +17,13 @@ const baseClass = "software-vuln-summary";
interface ISoftwareVulnSummaryProps {
vuln: IVulnerability;
isPremiumTier: boolean;
teamIdForApi?: number;
}
const SoftwareVulnSummary = ({
vuln,
isPremiumTier,
teamIdForApi,
}: ISoftwareVulnSummaryProps) => {
const {
cve,
@@ -43,7 +45,7 @@ const SoftwareVulnSummary = ({
<CustomLink url={details_link} text="Visit NVD page" newTab />
<ViewAllHostsLink
customText="View affected hosts"
queryParams={{ cve }}
queryParams={{ cve, team_id: teamIdForApi }}
/>
</span>
</span>
@@ -1,16 +1,17 @@
/** software/vulnerabilities/:cve */
/* software/vulnerabilities/:cve */
import React, { useCallback, useContext } from "react";
import React, { useContext } from "react";
import { useQuery } from "react-query";
import { RouteComponentProps } from "react-router";
import { AxiosError } from "axios";
import useTeamIdParam from "hooks/useTeamIdParam";
import { AppContext } from "context/app";
import { IVulnerability } from "interfaces/vulnerability";
import softwareVulnAPI, {
IGetVulnerabilityQueryKey,
IVulnerabilityResponse,
} from "services/entities/vulnerabilities";
@@ -18,6 +19,8 @@ import Spinner from "components/Spinner";
import DataError from "components/DataError";
import Fleet404 from "pages/errors/Fleet404";
import MainContent from "components/MainContent";
import TeamsHeader from "components/TeamsHeader";
import Card from "components/Card";
import SoftwareVulnSummary from "./SoftwareVulnSummary/SoftwareVulnSummary";
import SoftwareVulnOSVersions from "./SoftwareVulnOSVersions";
@@ -27,6 +30,7 @@ const baseClass = "software-vulnerability-details-page";
interface ISoftwareVulnerabilityDetailsRouteParams {
cve: string;
team_id?: string;
}
type ISoftwareVulnerabilityDetailsPageProps = RouteComponentProps<
@@ -37,24 +41,92 @@ type ISoftwareVulnerabilityDetailsPageProps = RouteComponentProps<
const SoftwareVulnerabilityDetailsPage = ({
router,
routeParams,
location,
}: ISoftwareVulnerabilityDetailsPageProps) => {
const { isPremiumTier } = useContext(AppContext);
const { isPremiumTier, isOnGlobalTeam } = useContext(AppContext);
const cve = routeParams.cve;
const {
currentTeamId,
teamIdForApi,
userTeams,
handleTeamChange,
} = useTeamIdParam({
location,
router,
includeAllTeams: true,
includeNoTeam: false,
});
const {
data: vuln,
isLoading: isVulnLoading,
isError: isVulnError,
error: vulnError,
} = useQuery<IVulnerabilityResponse, AxiosError, IVulnerability>(
["softwareVulnByCVE", cve],
() => softwareVulnAPI.getVulnerability(cve),
} = useQuery<
IVulnerabilityResponse,
AxiosError,
IVulnerability,
IGetVulnerabilityQueryKey[]
>(
[
{
scope: "softwareVulnByCVE",
cve: routeParams.cve,
teamId: teamIdForApi,
},
],
({ queryKey }) => {
return softwareVulnAPI.getVulnerability(queryKey[0]);
},
{
select: (data) => data.vulnerability,
}
);
const onTeamChange = useCallback(
(teamId: number) => {
handleTeamChange(teamId);
},
[handleTeamChange]
);
const renderVulnTables = () => {
// always the case, just for typing
if (vuln) {
if (vuln.hosts_count === 0) {
return (
<Card borderRadiusSize="large" includeShadow className="not-detected">
<h2>Vulnerability not detected</h2>
<p>
No host {!!teamIdForApi && "on this team "}is affected by{" "}
{vuln.cve}.
</p>
</Card>
);
}
return (
<>
{!!vuln.os_versions && vuln.os_versions.length > 0 && (
<SoftwareVulnOSVersions
osVersions={vuln.os_versions}
isPremiumTier={isPremiumTier ?? false}
router={router}
teamIdForApi={teamIdForApi}
/>
)}
{!!vuln.software && vuln.software.length > 0 && (
<SoftwareVulnSoftwareVersions
vulnSoftware={vuln.software}
isPremiumTier={isPremiumTier ?? false}
router={router}
teamIdForApi={teamIdForApi}
/>
)}
</>
);
}
};
const renderContent = () => {
if (isVulnLoading || !vuln) {
return <Spinner />;
@@ -68,24 +140,20 @@ const SoftwareVulnerabilityDetailsPage = ({
}
return (
<>
{isPremiumTier && (
<TeamsHeader
isOnGlobalTeam={isOnGlobalTeam}
currentTeamId={currentTeamId}
userTeams={userTeams}
onTeamChange={onTeamChange}
/>
)}
<SoftwareVulnSummary
vuln={vuln}
isPremiumTier={isPremiumTier ?? false}
teamIdForApi={teamIdForApi}
/>
{!!vuln.os_versions && vuln.os_versions.length > 0 && (
<SoftwareVulnOSVersions
osVersions={vuln.os_versions}
isPremiumTier={isPremiumTier ?? false}
router={router}
/>
)}
{!!vuln.software && vuln.software.length > 0 && (
<SoftwareVulnSoftwareVersions
vulnSoftware={vuln.software}
isPremiumTier={isPremiumTier ?? false}
router={router}
/>
)}
{renderVulnTables()}
</>
);
};
@@ -9,7 +9,7 @@
padding: $pad-xxlarge;
}
h1,
* > h1,
h2 {
margin: 0;
font-weight: $bold;
@@ -22,6 +22,16 @@
font-size: 1rem;
}
.not-detected {
gap: 0;
text-align: center;
* {
font-size: $x-small;
line-height: 21px;
margin: 0;
}
}
.resolved-suffix {
display: inline;
@media (max-width: $break-md) {
+16 -3
View File
@@ -23,6 +23,15 @@ export interface IGetVulnerabilitiesQueryKey
scope: string;
}
interface IGetVulnerabilityOptions {
cve: string;
teamId?: number;
}
export interface IGetVulnerabilityQueryKey extends IGetVulnerabilityOptions {
scope: "softwareVulnByCVE";
}
export interface IVulnerabilitiesResponse {
count: number;
counts_updated_at: string;
@@ -67,10 +76,14 @@ export const getVulnerabilities = ({
});
};
const getVulnerability = (cve: string): Promise<IVulnerabilityResponse> => {
const { VULNERABILITY } = endpoints;
const getVulnerability = ({
cve,
teamId,
}: IGetVulnerabilityOptions): Promise<IVulnerabilityResponse> => {
const endpoint = endpoints.VULNERABILITY(cve);
const path = teamId ? `${endpoint}?team_id=${teamId}` : endpoint;
// return sendRequest("GET", VULNERABILITY(cve)); // TODO: API INTEGRATION: uncomment when API is ready
// return sendRequest("GET", path); // TODO: API INTEGRATION: uncomment when API is ready
return new Promise((resolve, reject) => {
resolve(createMockVulnerabilityResponse());
});