diff --git a/web/messages/en/location.json b/web/messages/en/location.json index 589a6d788..4ec2b1b72 100644 --- a/web/messages/en/location.json +++ b/web/messages/en/location.json @@ -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", diff --git a/web/src/pages/EditLocationPage/EditLocationPage.tsx b/web/src/pages/EditLocationPage/EditLocationPage.tsx index e0228a51c..31f0f8845 100644 --- a/web/src/pages/EditLocationPage/EditLocationPage.tsx +++ b/web/src/pages/EditLocationPage/EditLocationPage.tsx @@ -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'; @@ -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'; @@ -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({ @@ -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 => ({ + 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 ( @@ -368,6 +414,18 @@ const EditLocationForm = ({ location }: { location: NetworkLocation }) => { ); }, [firewallLocked]); + const postureChecksLabelContent = useMemo(() => { + if (!postureChecksSectionState.locked) return undefined; + return ( + <> +

{m.license_enterprise_required()}

+ + {m.license_upgrade_to_unlock()} + + + ); + }, [postureChecksSectionState.locked]); + const { data: devices } = useQuery({ queryKey: ['device', 'all'], queryFn: api.device.getDevices, @@ -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, @@ -791,6 +884,71 @@ const EditLocationForm = ({ location }: { location: NetworkLocation }) => { )} + + {postureChecksSectionState.showEmptyState && ( +
+ +

+ {m.location_posture_checks_empty_state_before_link()}{' '} + {m.cmp_nav_item_posture_checks()}{' '} + {m.location_posture_checks_empty_state_after_link()} +

+
+ )} + {postureChecksSectionState.showAssignedPostureChecks && ( +
+ 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, + }} + /> +
+ )} + {postureChecksSectionState.showAssignButton && ( + diff --git a/web/src/shared/components/SelectMultiple/style.scss b/web/src/shared/components/SelectMultiple/style.scss index d9eb8ef8a..16aab0db4 100644 --- a/web/src/shared/components/SelectMultiple/style.scss +++ b/web/src/shared/components/SelectMultiple/style.scss @@ -7,13 +7,20 @@ gap: var(--spacing-sm); } - & > button { + & > .select-multiple-edit { background-color: transparent; border: 0; + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); font: var(--t-body-sm-400); color: var(--fg-action); cursor: pointer; user-select: none; padding: 0; + + .icon { + flex: 0 0 auto; + } } } diff --git a/web/src/shared/components/SelectMultiple/types.ts b/web/src/shared/components/SelectMultiple/types.ts index 790b5a962..315aeea4d 100644 --- a/web/src/shared/components/SelectMultiple/types.ts +++ b/web/src/shared/components/SelectMultiple/types.ts @@ -1,14 +1,24 @@ +import type { IconKindValue } from '../../defguard-ui/components/Icon'; import type { SelectionKey, SelectionOption, SelectionSectionCustomRender, + SelectionSectionProps, } from '../SelectionSection/type'; -export type SelectMultipleProps = { +type SelectionModalOverrides = Pick< + SelectionSectionProps, + 'enableDividers' | 'itemGap' | 'searchPlaceholder' | 'visibleItemsLimit' +> & { + contentClassName?: string; +}; + +export type SelectMultipleProps = { options: SelectionOption[]; selected: Set; modalTitle: string; editText: string; + editIcon?: IconKindValue; toggleValue: boolean; toggleText?: string; error?: string; @@ -20,4 +30,5 @@ export type SelectMultipleProps = { ) => void; onToggleChange: (value: boolean) => void; selectionCustomItemRender?: SelectionSectionCustomRender; + selectionModalProps?: SelectionModalOverrides; }; diff --git a/web/src/shared/components/SelectionSection/SelectionSection.tsx b/web/src/shared/components/SelectionSection/SelectionSection.tsx index bcaaa72b7..4a41cca59 100644 --- a/web/src/shared/components/SelectionSection/SelectionSection.tsx +++ b/web/src/shared/components/SelectionSection/SelectionSection.tsx @@ -18,6 +18,8 @@ export const SelectionSection = ({ onChange, options, selection, + searchPlaceholder, + visibleItemsLimit = 10, className, id, renderItem, @@ -80,20 +82,20 @@ export const SelectionSection = ({ ); const maxHeight = useMemo(() => { - let res = itemHeight * 10; + let res = itemHeight * visibleItemsLimit; // add gaps if (enableDividers) { - res += (itemGap * 2 + 1) * 9; + res += (itemGap * 2 + 1) * Math.max(visibleItemsLimit - 1, 0); } else { - res += itemGap * 9; + res += itemGap * Math.max(visibleItemsLimit - 1, 0); } return res; - }, [itemGap, itemHeight, enableDividers]); + }, [enableDividers, itemGap, itemHeight, visibleItemsLimit]); return (
diff --git a/web/src/shared/components/SelectionSection/type.ts b/web/src/shared/components/SelectionSection/type.ts index 3961674e4..23bbac992 100644 --- a/web/src/shared/components/SelectionSection/type.ts +++ b/web/src/shared/components/SelectionSection/type.ts @@ -24,6 +24,8 @@ export interface SelectionSectionProps { selection: Set; onChange: (value: Set) => void; options: SelectionOption[]; + searchPlaceholder?: string; + visibleItemsLimit?: number; orderItems?: (items: SelectionOption[]) => SelectionOption[]; renderItem?: SelectionSectionCustomRender; enableDividers?: boolean; diff --git a/web/src/shared/components/modals/SelectionModal/SelectionModal.tsx b/web/src/shared/components/modals/SelectionModal/SelectionModal.tsx index 178a3119f..07df2aeee 100644 --- a/web/src/shared/components/modals/SelectionModal/SelectionModal.tsx +++ b/web/src/shared/components/modals/SelectionModal/SelectionModal.tsx @@ -8,6 +8,7 @@ import { useSelectionModal } from './useSelectionModal'; export const SelectionModal = () => { const title = useSelectionModal((s) => s.title); + const contentClassName = useSelectionModal((s) => s.contentClassName); const isOpen = useSelectionModal((s) => s.isOpen); const onCancel = useSelectionModal((s) => s.onCancel); @@ -21,6 +22,7 @@ export const SelectionModal = () => { { useSelectionModal.setState({ isOpen: false }); @@ -40,6 +42,8 @@ const ModalContent = () => { const initialSelected = useSelectionModal((s) => s.selected); const renderItem = useSelectionModal((s) => s.renderItem); const orderItems = useSelectionModal((s) => s.orderItems); + const searchPlaceholder = useSelectionModal((s) => s.searchPlaceholder); + const visibleItemsLimit = useSelectionModal((s) => s.visibleItemsLimit); const itemGap = useSelectionModal((s) => s.itemGap); const enableDividers = useSelectionModal((s) => s.enableDividers); @@ -54,6 +58,8 @@ const ModalContent = () => { onChange={setInternalSelection} renderItem={renderItem} orderItems={orderItems} + searchPlaceholder={searchPlaceholder} + visibleItemsLimit={visibleItemsLimit} itemGap={itemGap} enableDividers={enableDividers} /> diff --git a/web/src/shared/components/modals/SelectionModal/useSelectionModal.tsx b/web/src/shared/components/modals/SelectionModal/useSelectionModal.tsx index 953294ae7..6facaff01 100644 --- a/web/src/shared/components/modals/SelectionModal/useSelectionModal.tsx +++ b/web/src/shared/components/modals/SelectionModal/useSelectionModal.tsx @@ -10,9 +10,12 @@ type SectionProps = SelectionSectionProps; interface StoreValues { title: string; + contentClassName?: string; options: SelectionOption[]; selected: Set | Set; isOpen: boolean; + searchPlaceholder?: string; + visibleItemsLimit?: number; itemGap: number; enableDividers: boolean; onSubmit?: (values: Array) => void; @@ -23,9 +26,12 @@ interface StoreValues { const getDefaultValues = (): StoreValues => ({ title: m.modal_selection_title(), + contentClassName: undefined, options: [], selected: new Set(), isOpen: false, + searchPlaceholder: undefined, + visibleItemsLimit: undefined, itemGap: 8, enableDividers: false, onSubmit: undefined, diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index 034614b3d..fae350a2b 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit 034614b3dc8c43fae41c2c79e9c3993d2f732eb8 +Subproject commit fae350a2baadddb1edffce0ff30408283cdd8699 diff --git a/web/tests/edit-location-posture-checks.test.ts b/web/tests/edit-location-posture-checks.test.ts new file mode 100644 index 000000000..d837f8500 --- /dev/null +++ b/web/tests/edit-location-posture-checks.test.ts @@ -0,0 +1,231 @@ +import { isValidElement, type ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ApiDevicePosture } from '../src/shared/api/types'; + +vi.mock('../src/paraglide/messages', () => ({ + m: { + posture_checks_wizard_summary_linux_version: ({ version }: { version: number }) => + `Linux kernel ${version}+`, + posture_checks_wizard_summary_ios_version: ({ version }: { version: number }) => + `iOS ${version}+`, + posture_checks_wizard_summary_android_version: ({ version }: { version: number }) => + `Android ${version}+`, + posture_checks_wizard_operating_systems_windows_security_updates: () => + 'Check security updates', + posture_checks_wizard_operating_systems_condition_active_directory: () => + 'Connected to Active Directory', + posture_checks_wizard_operating_systems_condition_antivirus: () => + 'Antivirus installed', + posture_checks_wizard_operating_systems_condition_disk_encryption: () => + 'Disk encryption enabled', + posture_checks_wizard_operating_systems_condition_device_integrity: () => + 'Device integrity enabled', + posture_checks_wizard_summary_defguard_version: ({ version }: { version: string }) => + `Defguard ${version} and higher`, + posture_checks_wizard_summary_prerelease: () => + 'Allow pre-release versions of the Defguard client.', + posture_checks_wizard_summary_defguard_label: () => 'Defguard', + }, +})); + +const { getPostureCheckAssignmentSummarySections, getPostureChecksSectionState } = + await import('../src/pages/EditLocationPage/postureChecksSection'); +const { renderPostureCheckSelectionItem } = await import( + '../src/pages/EditLocationPage/postureCheckSelectionItem' +); + +describe('edit location posture-checks section state', () => { + it('shows the empty state when enterprise access is available but no posture checks exist', () => { + expect( + getPostureChecksSectionState({ + assignedPostureChecksCount: 0, + canUseEnterprise: true, + postureChecksCount: 0, + }), + ).toEqual({ + hasAnyPostureChecks: false, + hasAssignedPostureChecks: false, + locked: false, + showAssignButton: false, + showLockedButton: false, + showAssignedPostureChecks: false, + showEmptyState: true, + }); + expect( + getPostureChecksSectionState({ + assignedPostureChecksCount: 0, + canUseEnterprise: undefined, + postureChecksCount: 0, + }), + ).toEqual({ + hasAnyPostureChecks: false, + hasAssignedPostureChecks: false, + locked: false, + showAssignButton: false, + showLockedButton: false, + showAssignedPostureChecks: false, + showEmptyState: true, + }); + }); + + it('shows the assign button when posture checks exist but none are attached to the location', () => { + expect( + getPostureChecksSectionState({ + assignedPostureChecksCount: 0, + canUseEnterprise: true, + postureChecksCount: 2, + }), + ).toEqual({ + hasAnyPostureChecks: true, + hasAssignedPostureChecks: false, + locked: false, + showAssignButton: true, + showLockedButton: false, + showAssignedPostureChecks: false, + showEmptyState: false, + }); + }); + + it('shows the assigned posture-check list when the location already has posture checks attached', () => { + expect( + getPostureChecksSectionState({ + assignedPostureChecksCount: 2, + canUseEnterprise: true, + postureChecksCount: 3, + }), + ).toEqual({ + hasAnyPostureChecks: true, + hasAssignedPostureChecks: true, + locked: false, + showAssignButton: false, + showLockedButton: false, + showAssignedPostureChecks: true, + showEmptyState: false, + }); + }); + + it('locks the add posture-check CTA when enterprise access is unavailable', () => { + expect( + getPostureChecksSectionState({ + assignedPostureChecksCount: 0, + canUseEnterprise: false, + postureChecksCount: 2, + }), + ).toEqual({ + hasAnyPostureChecks: true, + hasAssignedPostureChecks: false, + locked: true, + showAssignButton: false, + showLockedButton: true, + showAssignedPostureChecks: false, + showEmptyState: false, + }); + }); + + it('builds structured posture-check info sections for the assignment tooltip', () => { + const postureCheck: ApiDevicePosture = { + id: 1, + name: 'Windows admins', + description: null, + min_client_version: '2.0', + allow_prerelease_client: true, + locations: [], + os_rules: [ + { + os_type: 'windows', + min_os_version: 11, + disk_encryption_required: true, + antivirus_required: true, + ad_domain_joined_required: true, + windows_security_update_current: true, + }, + { + os_type: 'macos', + min_os_version: 15, + disk_encryption_required: true, + device_integrity_required: true, + }, + ], + }; + + expect(getPostureCheckAssignmentSummarySections(postureCheck)).toEqual([ + { + label: 'Windows', + lines: [ + 'Windows 11+', + 'Check security updates', + 'Connected to Active Directory', + 'Antivirus installed', + 'Disk encryption enabled', + ], + }, + { + label: 'macOS', + lines: ['macOS 15+', 'Disk encryption enabled', 'Device integrity enabled'], + }, + { + label: 'Defguard', + lines: [ + 'Defguard 2.0 and higher', + 'Allow pre-release versions of the Defguard client.', + ], + }, + ]); + }); + + it('keeps the selection row checkbox wired to the provided toggle handler', () => { + const onClick = vi.fn(); + const option = { + id: 1, + label: 'Windows admins', + meta: { + id: 1, + name: 'Windows admins', + description: null, + min_client_version: '2.0', + allow_prerelease_client: false, + locations: [], + os_rules: [ + { + os_type: 'windows' as const, + min_os_version: 11, + disk_encryption_required: true, + antivirus_required: false, + ad_domain_joined_required: false, + windows_security_update_current: false, + }, + ], + } satisfies ApiDevicePosture, + }; + + const rendered = renderPostureCheckSelectionItem({ + active: true, + onClick, + option, + }); + + expect(isValidElement<{ children: ReactNode }>(rendered)).toBe(true); + if (!isValidElement<{ children: ReactNode }>(rendered)) { + throw new Error('Expected a posture-check selection row element'); + } + + const checkboxElement = rendered.props.children; + + expect( + isValidElement<{ onClick: (event: { currentTarget: null }) => void }>( + checkboxElement, + ), + ).toBe(true); + if ( + !isValidElement<{ onClick: (event: { currentTarget: null }) => void }>( + checkboxElement, + ) + ) { + throw new Error('Expected the row to render a checkbox'); + } + + checkboxElement.props.onClick({ currentTarget: null }); + + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/tests/posture-checks-page.test.ts b/web/tests/posture-checks-page.test.ts index e5ae3719d..a99fa1814 100644 --- a/web/tests/posture-checks-page.test.ts +++ b/web/tests/posture-checks-page.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { shouldFetchPostureChecksEnterpriseData } from '../src/pages/PostureChecksPage/license'; import { filterPostureChecks, getPostureCheckColumnFilterOptions, @@ -218,4 +219,9 @@ describe('posture checks page helpers', () => { mapPostureCheckFilterValueToRequestValue(PostureCheckRequirement.PrereleaseAllowed), ).toBe('Pre-release allowed'); }); + + it('fetches enterprise-only posture data only when enterprise access is available', () => { + expect(shouldFetchPostureChecksEnterpriseData(true)).toBe(true); + expect(shouldFetchPostureChecksEnterpriseData(false)).toBe(false); + }); });