Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions web/messages/en/location.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@
"location_access_selected_group_count_one": "+{count} group",
"location_access_selected_group_count_other": "+{count} groups",
"location_access_edit_groups": "Edit groups",
"location_posture_checks_edit": "Edit posture check",
"location_posture_checks_select": "Select posture check",
"location_posture_checks_update_failed": "Failed to update posture checks",
"location_posture_checks_empty_state_before_link": "You don't have any posture checks yet. Create at least one in the",
"location_posture_checks_empty_state_after_link": "section before assigning it to this location.",
"location_access_select_allowed_groups": "Select allowed groups",
"location_access_required_groups": "At least one group must be selected",
"network_device_delete_success": "Network device deleted",
Expand Down
164 changes: 161 additions & 3 deletions web/src/pages/EditLocationPage/EditLocationPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import './style.scss';

import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { useNavigate, useParams } from '@tanstack/react-router';
import { Link, useNavigate, useParams } from '@tanstack/react-router';
import { cloneDeep, omit } from 'lodash-es';
import { useMemo } from 'react';
import z from 'zod';
Expand All @@ -16,9 +16,15 @@ import {
import { EditPage } from '../../shared/components/EditPage/EditPage';
import { EditPageControls } from '../../shared/components/EditPageControls/EditPageControls';
import { EditPageFormSection } from '../../shared/components/EditPageFormSection/EditPageFormSection';
import type { SelectionOption } from '../../shared/components/SelectionSection/type';
import { useSelectionModal } from '../../shared/components/modals/SelectionModal/useSelectionModal';
import type {
SelectionOption,
SelectionSectionCustomRender,
} from '../../shared/components/SelectionSection/type';
import { SelectMultiple } from '../../shared/components/SelectMultiple/SelectMultiple';
import { externalLink } from '../../shared/constants';

import { Button } from '../../shared/defguard-ui/components/Button/Button';
import { IconKind } from '../../shared/defguard-ui/components/Icon';
import { InfoBanner } from '../../shared/defguard-ui/components/InfoBanner/InfoBanner';
import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox';
import { Snackbar } from '../../shared/defguard-ui/providers/snackbar/snackbar';
Expand All @@ -35,6 +41,9 @@ import {
} from '../../shared/utils/license';
import { smallestNetworkCapacity } from '../../shared/utils/network';
import { Validate } from '../../shared/validate';
import postureCheckShield from './assets/posture_check_shield.png';
import { renderPostureCheckSelectionItem } from './postureCheckSelectionItem';
import { getPostureChecksSectionState } from './postureChecksSection';

export const EditLocationPage = () => {
const { locationId: paramsId } = useParams({
Expand Down Expand Up @@ -341,9 +350,46 @@ const EditLocationForm = ({ location }: { location: NetworkLocation }) => {
if (licenseInfo === undefined) return undefined;
return canUseBusinessFeature(licenseInfo).result;
}, [licenseInfo]);
const { data: postureChecks = [] } = useQuery({
queryKey: ['device-posture'],
queryFn: api.devicePosture.getDevicePostures,
enabled: canUseEnterprise === true,
});
const serviceLocationLocked = isPresent(canUseEnterprise) && !canUseEnterprise;
const postureChecksSectionState = useMemo(
() =>
getPostureChecksSectionState({
assignedPostureChecksCount: location.posture_checks.length,
canUseEnterprise,
postureChecksCount: postureChecks.length,
}),
[canUseEnterprise, location.posture_checks.length, postureChecks.length],
);
const firewallLocked = isPresent(canUseBusiness) && !canUseBusiness;

const postureCheckOptions = useMemo(
() =>
postureChecks.map(
(postureCheck): SelectionOption<number> => ({
id: postureCheck.id,
label: postureCheck.name,
meta: postureCheck,
}),
),
[postureChecks],
);

const assignedPostureChecks = useMemo(() => {
const labelsById = new Map(
postureChecks.map((postureCheck) => [postureCheck.id, postureCheck.name]),
);

return location.posture_checks.map((id) => ({
id,
label: labelsById.get(id) ?? String(id),
}));
}, [location.posture_checks, postureChecks]);

const serviceLocationLabelContent = useMemo(() => {
if (!serviceLocationLocked) return undefined;
return (
Expand All @@ -368,6 +414,18 @@ const EditLocationForm = ({ location }: { location: NetworkLocation }) => {
);
}, [firewallLocked]);

const postureChecksLabelContent = useMemo(() => {
if (!postureChecksSectionState.locked) return undefined;
return (
<>
<p>{m.license_enterprise_required()}</p>
<a href={externalLink.defguard.pricing} target="_blank" rel="noreferrer">
{m.license_upgrade_to_unlock()}
</a>
</>
);
}, [postureChecksSectionState.locked]);

const { data: devices } = useQuery({
queryKey: ['device', 'all'],
queryFn: api.device.getDevices,
Expand Down Expand Up @@ -402,6 +460,41 @@ const EditLocationForm = ({ location }: { location: NetworkLocation }) => {
},
});

const { mutate: setLocationPostures, isPending: isUpdatingLocationPostures } =
useMutation({
mutationFn: (data: { postures: number[] }) =>
api.devicePosture.setLocationPostures(location.id, data),
meta: {
invalidate: [['device-posture'], ['network']],
},
onError: () => {
Snackbar.error(m.location_posture_checks_update_failed());
},
});

const openPostureChecksSelection = () => {
useSelectionModal.setState({
isOpen: true,
contentClassName: 'posture-check-assignment-modal',
title: m.location_posture_checks_select(),
enableDividers: true,
itemGap: 12,
options: postureCheckOptions,
renderItem: renderPostureCheckSelectionItem as SelectionSectionCustomRender<
string | number,
unknown
>,
searchPlaceholder: m.controls_search(),
selected: new Set(location.posture_checks),
visibleItemsLimit: 4,
onSubmit: (values) => {
setLocationPostures({
postures: values.filter((value): value is number => typeof value === 'number'),
});
},
});
};

const defaultValues = useMemo(
(): FormFields => ({
name: location.name,
Expand Down Expand Up @@ -791,6 +884,71 @@ const EditLocationForm = ({ location }: { location: NetworkLocation }) => {
)}
</form.AppField>
</EditPageFormSection>
<EditPageFormSection
label={m.cmp_nav_item_posture_checks()}
labelContent={postureChecksLabelContent}
>
{postureChecksSectionState.showEmptyState && (
<div className="posture-checks-empty-state">
<img src={postureCheckShield} alt="" className="posture-check-shield" />
<p>
{m.location_posture_checks_empty_state_before_link()}{' '}
<Link to="/acl/posture-checks">{m.cmp_nav_item_posture_checks()}</Link>{' '}
{m.location_posture_checks_empty_state_after_link()}
</p>
</div>
)}
{postureChecksSectionState.showAssignedPostureChecks && (
<div className="posture-checks-assigned-state">
<SelectMultiple
options={postureCheckOptions}
selected={
new Set(assignedPostureChecks.map((postureCheck) => postureCheck.id))
}
modalTitle={m.location_posture_checks_select()}
editText={m.location_posture_checks_edit()}
editIcon={IconKind.Edit}
toggleValue={false}
counterText={() => ''}
onSelectionChange={(values) => {
setLocationPostures({
postures: values.filter(
(value): value is number => typeof value === 'number',
),
});
}}
onToggleChange={() => {}}
selectionCustomItemRender={renderPostureCheckSelectionItem}
selectionModalProps={{
contentClassName: 'posture-check-assignment-modal',
enableDividers: true,
itemGap: 12,
searchPlaceholder: m.controls_search(),
visibleItemsLimit: 6,
}}
/>
</div>
)}
{postureChecksSectionState.showAssignButton && (
<Button
variant="outlined"
iconLeft={IconKind.ConnectedDevices}
loading={isUpdatingLocationPostures}
text={m.posture_checks_wizard_title()}
onClick={openPostureChecksSelection}
/>
)}
{postureChecksSectionState.showLockedButton && (
<div className="posture-checks-locked-state">
<Button
variant="primary"
disabled
iconLeft={IconKind.ConnectedDevices}
text={m.posture_checks_wizard_title()}
/>
</div>
)}
</EditPageFormSection>
<form.Subscribe
selector={(form) => ({
isSubmitting: form.isSubmitting,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
64 changes: 64 additions & 0 deletions web/src/pages/EditLocationPage/postureCheckSelectionItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Fragment } from 'react';
import type { ApiDevicePosture } from '../../shared/api/types';
import type { SelectionSectionCustomRender } from '../../shared/components/SelectionSection/type';
import { Checkbox } from '../../shared/defguard-ui/components/Checkbox/Checkbox';
import { Divider } from '../../shared/defguard-ui/components/Divider/Divider';
import { IconKind } from '../../shared/defguard-ui/components/Icon';
import { Icon } from '../../shared/defguard-ui/components/Icon/Icon';
import { TooltipContent } from '../../shared/defguard-ui/providers/tooltip/TooltipContent';
import { TooltipProvider } from '../../shared/defguard-ui/providers/tooltip/TooltipContext';
import { TooltipTrigger } from '../../shared/defguard-ui/providers/tooltip/TooltipTrigger';
import { ThemeVariable } from '../../shared/defguard-ui/types';
import { getPostureCheckAssignmentSummarySections } from './postureChecksSection';

export const renderPostureCheckSelectionItem: SelectionSectionCustomRender<
number,
ApiDevicePosture
> = ({ active, onClick, option }) => {
if (!option.meta) return null;

const sections = getPostureCheckAssignmentSummarySections(option.meta);

return (
<div className="posture-check-selection-item">
<Checkbox
active={active}
onClick={onClick}
text={option.label}
helperBlock={
<TooltipProvider placement="right-start">
<TooltipTrigger>
<div
className="posture-check-info-trigger"
onClick={(event) => {
event.stopPropagation();
}}
>
<Icon
icon={IconKind.InfoOutlined}
size={20}
staticColor={ThemeVariable.FgMuted}
/>
</div>
</TooltipTrigger>
<TooltipContent className="posture-check-info-tooltip" variant="light">
{sections.map((section, index) => (
<Fragment key={section.label}>
{index > 0 && <Divider />}
<div className="posture-check-info-item">
<p className="label">{section.label}</p>
<div className="content">
{section.lines.map((line) => (
<p key={`${section.label}-${line}`}>{line}</p>
))}
</div>
</div>
</Fragment>
))}
</TooltipContent>
</TooltipProvider>
}
/>
</div>
);
};
Loading
Loading