diff --git a/e2e/package.json b/e2e/package.json index 209237b493..9d01651436 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,7 +5,7 @@ "type": "commonjs", "scripts": { "lint": "pnpm prettier --check './tests/**.ts' './utils/**/*.ts' && pnpm eslint './tests/**.ts' './utils/**/*.ts'", - "fix": "pnpm prettier -w ./tests/**/*.ts ./utils/**/*.ts && pnpm eslint --fix ./tests/**/*.ts ./utils/**/*.ts", + "fix": "pnpm prettier -w './tests/**.ts' './utils/**/*.ts' && pnpm eslint --fix ./tests/**/*.ts ./utils/**/*.ts", "test": "pnpm playwright test" }, "keywords": [], @@ -42,4 +42,4 @@ "volta": { "node": "19.9.0" } -} +} \ No newline at end of file diff --git a/e2e/tests/externalopenid.spec.ts b/e2e/tests/externalopenid.spec.ts index 9e7ba3bc5f..29cbf60aac 100644 --- a/e2e/tests/externalopenid.spec.ts +++ b/e2e/tests/externalopenid.spec.ts @@ -54,7 +54,7 @@ test.describe('External OIDC.', () => { dockerDown(); }); - test.fixme('Login through external oidc.', async ({ page }) => { + test('Login through external oidc.', async ({ page }) => { expect(client.clientID).toBeDefined(); expect(client.clientSecret).toBeDefined(); await waitForBase(page); diff --git a/e2e/tests/externalopenidmfa.spec.ts b/e2e/tests/externalopenidmfa.spec.ts index b83b9d3b59..785e7cc1a1 100644 --- a/e2e/tests/externalopenidmfa.spec.ts +++ b/e2e/tests/externalopenidmfa.spec.ts @@ -52,7 +52,7 @@ test.describe('External OIDC.', () => { dockerDown(); }); - test.fixme('Complete client MFA through external OpenID', async ({ page, browser }) => { + test('Complete client MFA through external OpenID', async ({ page, browser }) => { await waitForBase(page); const mfaStartUrl = `${testsConfig.ENROLLMENT_URL}/api/v1/client-mfa/start`; await createDevice(browser, testUser, { diff --git a/e2e/types.ts b/e2e/types.ts index 0ba8de96ab..966f45e8b4 100644 --- a/e2e/types.ts +++ b/e2e/types.ts @@ -69,7 +69,7 @@ export type NetworkForm = { port: string; allowed_ips?: string; dns?: string; - location_mfa_mode?:string; + location_mfa_mode?: string; }; export type DeviceForm = { diff --git a/e2e/utils/controllers/vpn/createNetwork.ts b/e2e/utils/controllers/vpn/createNetwork.ts index 7351a4a4e5..b17a059872 100644 --- a/e2e/utils/controllers/vpn/createNetwork.ts +++ b/e2e/utils/controllers/vpn/createNetwork.ts @@ -15,11 +15,20 @@ export const createNetwork = async (browser: Browser, network: NetworkForm) => { const navNext = page.getByTestId('wizard-next'); await page.getByTestId('setup-option-manual').click(); await navNext.click(); - for (const key of Object.keys(network)) { + + // fill form + for (const key of Object.keys(network).filter((key) => key !== 'location_mfa_mode')) { const field = page.getByTestId(`field-${key}`); await field.clear(); await field.type(network[key]); } + // select location MFA mode + if (network.location_mfa_mode) { + const mfaModeSelect = page.locator('div.location-mfa-mode-select'); + const mfaMode = mfaModeSelect.locator(`div.${network.location_mfa_mode}`); + await mfaMode.click(); + } + const responseCreateNetworkPromise = page.waitForResponse('**/network'); await navNext.click(); const response = await responseCreateNetworkPromise; diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index 0a5c734f35..dc0f928f06 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -1991,6 +1991,11 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do 'By default, all users will be allowed to connect to this location. If you want to restrict access to this location to a specific group, please select it below.', aclFeatureDisabled: "ACL functionality is an enterprise feature and you've exceeded the user, device or network limits to use it. In order to use this feature, purchase an enterprise license or upgrade your existing one.", + locationMfaMode: { + description: 'Choose how MFA is enforced when connecting to this location:', + internal: "Internal MFA - MFA is enforced using Defguard's built-in MFA (e.g. TOTP, WebAuthn) with internal identity", + external: 'External MFA - If configured (see [OpenID settings](settings)) this option uses external identity provider for MFA', + }, }, messages: { networkModified: 'Location modified.', @@ -2031,6 +2036,9 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do acl_default_allow: { label: 'Default ACL policy', }, + location_mfa_mode: { + label: 'MFA requirement', + } }, controls: { submit: 'Save changes', diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index 4a6ff495b8..ce89aa98e7 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -4788,6 +4788,20 @@ type RootTranslation = { * A​C​L​ ​f​u​n​c​t​i​o​n​a​l​i​t​y​ ​i​s​ ​a​n​ ​e​n​t​e​r​p​r​i​s​e​ ​f​e​a​t​u​r​e​ ​a​n​d​ ​y​o​u​'​v​e​ ​e​x​c​e​e​d​e​d​ ​t​h​e​ ​u​s​e​r​,​ ​d​e​v​i​c​e​ ​o​r​ ​n​e​t​w​o​r​k​ ​l​i​m​i​t​s​ ​t​o​ ​u​s​e​ ​i​t​.​ ​I​n​ ​o​r​d​e​r​ ​t​o​ ​u​s​e​ ​t​h​i​s​ ​f​e​a​t​u​r​e​,​ ​p​u​r​c​h​a​s​e​ ​a​n​ ​e​n​t​e​r​p​r​i​s​e​ ​l​i​c​e​n​s​e​ ​o​r​ ​u​p​g​r​a​d​e​ ​y​o​u​r​ ​e​x​i​s​t​i​n​g​ ​o​n​e​. */ aclFeatureDisabled: string + locationMfaMode: { + /** + * C​h​o​o​s​e​ ​h​o​w​ ​M​F​A​ ​i​s​ ​e​n​f​o​r​c​e​d​ ​w​h​e​n​ ​c​o​n​n​e​c​t​i​n​g​ ​t​o​ ​t​h​i​s​ ​l​o​c​a​t​i​o​n​: + */ + description: string + /** + * I​n​t​e​r​n​a​l​ ​M​F​A​ ​-​ ​M​F​A​ ​i​s​ ​e​n​f​o​r​c​e​d​ ​u​s​i​n​g​ ​D​e​f​g​u​a​r​d​'​s​ ​b​u​i​l​t​-​i​n​ ​M​F​A​ ​(​e​.​g​.​ ​T​O​T​P​,​ ​W​e​b​A​u​t​h​n​)​ ​w​i​t​h​ ​i​n​t​e​r​n​a​l​ ​i​d​e​n​t​i​t​y + */ + internal: string + /** + * E​x​t​e​r​n​a​l​ ​M​F​A​ ​-​ ​I​f​ ​c​o​n​f​i​g​u​r​e​d​ ​(​s​e​e​ ​[​O​p​e​n​I​D​ ​s​e​t​t​i​n​g​s​]​(​s​e​t​t​i​n​g​s​)​)​ ​t​h​i​s​ ​o​p​t​i​o​n​ ​u​s​e​s​ ​e​x​t​e​r​n​a​l​ ​i​d​e​n​t​i​t​y​ ​p​r​o​v​i​d​e​r​ ​f​o​r​ ​M​F​A + */ + external: string + } } messages: { /** @@ -4870,6 +4884,12 @@ type RootTranslation = { */ label: string } + location_mfa_mode: { + /** + * M​F​A​ ​r​e​q​u​i​r​e​m​e​n​t + */ + label: string + } } controls: { /** @@ -11339,6 +11359,20 @@ export type TranslationFunctions = { * ACL functionality is an enterprise feature and you've exceeded the user, device or network limits to use it. In order to use this feature, purchase an enterprise license or upgrade your existing one. */ aclFeatureDisabled: () => LocalizedString + locationMfaMode: { + /** + * Choose how MFA is enforced when connecting to this location: + */ + description: () => LocalizedString + /** + * Internal MFA - MFA is enforced using Defguard's built-in MFA (e.g. TOTP, WebAuthn) with internal identity + */ + internal: () => LocalizedString + /** + * External MFA - If configured (see [OpenID settings](settings)) this option uses external identity provider for MFA + */ + external: () => LocalizedString + } } messages: { /** @@ -11421,6 +11455,12 @@ export type TranslationFunctions = { */ label: () => LocalizedString } + location_mfa_mode: { + /** + * MFA requirement + */ + label: () => LocalizedString + } } controls: { /** diff --git a/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx b/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx index 3e8a2994e4..69fad36c22 100644 --- a/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx +++ b/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx @@ -12,6 +12,7 @@ import { shallow } from 'zustand/shallow'; import { useI18nContext } from '../../../i18n/i18n-react'; import { FormAclDefaultPolicy } from '../../../shared/components/Form/FormAclDefaultPolicySelect/FormAclDefaultPolicy.tsx'; import { FormLocationMfaModeSelect } from '../../../shared/components/Form/FormLocationMfaModeSelect/FormLocationMfaModeSelect.tsx'; +import { RenderMarkdown } from '../../../shared/components/Layout/RenderMarkdown/RenderMarkdown.tsx'; import { FormCheckBox } from '../../../shared/defguard-ui/components/Form/FormCheckBox/FormCheckBox.tsx'; import { FormInput } from '../../../shared/defguard-ui/components/Form/FormInput/FormInput'; import { FormSelect } from '../../../shared/defguard-ui/components/Form/FormSelect/FormSelect'; @@ -31,6 +32,7 @@ import { validateIpOrDomainList, } from '../../../shared/validators'; import { useNetworkPageStore } from '../hooks/useNetworkPageStore'; +import { DividerHeader } from './components/DividerHeader.tsx'; export const NetworkEditForm = () => { const toaster = useToaster(); @@ -341,6 +343,22 @@ export const NetworkEditForm = () => { label={LL.networkConfiguration.form.fields.peer_disconnect_threshold.label()} type="number" /> + + +

{LL.networkConfiguration.form.helpers.locationMfaMode.description()}

+ +
diff --git a/web/src/pages/network/NetworkEditForm/components/DividerHeader.tsx b/web/src/pages/network/NetworkEditForm/components/DividerHeader.tsx new file mode 100644 index 0000000000..86243ea08b --- /dev/null +++ b/web/src/pages/network/NetworkEditForm/components/DividerHeader.tsx @@ -0,0 +1,16 @@ +import type { PropsWithChildren } from 'react'; + +type DividerHeaderProps = { + text: string; +} & PropsWithChildren; + +export const DividerHeader = ({ text, children }: DividerHeaderProps) => { + return ( +
+
+

{text}

+ {children} +
+
+ ); +}; diff --git a/web/src/pages/network/NetworkEditForm/style.scss b/web/src/pages/network/NetworkEditForm/style.scss index deaf9ead94..c8840e9d88 100644 --- a/web/src/pages/network/NetworkEditForm/style.scss +++ b/web/src/pages/network/NetworkEditForm/style.scss @@ -29,4 +29,33 @@ } } } + + #location-mfa-mode-explain-message-box { + ul { + list-style-position: inside; + margin-top: 8px; + + li { + p { + display: inline; + } + } + } + } + + .divider-header { + padding-bottom: var(--spacing-s); + + .inner { + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + border-bottom: 1px solid var(--border-primary); + } + + .header { + @include typography(app-side-bar); + } + } } diff --git a/web/src/shared/components/Form/FormLocationMfaModeSelect/FormLocationMfaModeSelect.tsx b/web/src/shared/components/Form/FormLocationMfaModeSelect/FormLocationMfaModeSelect.tsx index c0600c22c4..ff11d7f350 100644 --- a/web/src/shared/components/Form/FormLocationMfaModeSelect/FormLocationMfaModeSelect.tsx +++ b/web/src/shared/components/Form/FormLocationMfaModeSelect/FormLocationMfaModeSelect.tsx @@ -1,21 +1,27 @@ +import './style.scss'; +import clsx from 'clsx'; import { useMemo } from 'react'; -import type { FieldValues, UseControllerProps } from 'react-hook-form'; - +import { + type FieldValues, + type UseControllerProps, + useController, +} from 'react-hook-form'; import { useI18nContext } from '../../../../i18n/i18n-react'; -import { FormSelect } from '../../../defguard-ui/components/Form/FormSelect/FormSelect'; +import { RadioButton } from '../../../defguard-ui/components/Layout/RadioButton/Radiobutton'; import type { SelectOption } from '../../../defguard-ui/components/Layout/Select/types'; import { LocationMfaMode } from '../../../types'; type Props = { controller: UseControllerProps; - disabled?: boolean; }; export const FormLocationMfaModeSelect = ({ controller, - disabled = false, }: Props) => { const { LL } = useI18nContext(); + const { + field: { onChange, value: fieldValue }, + } = useController(controller); const options = useMemo( (): SelectOption[] => [ @@ -37,12 +43,26 @@ export const FormLocationMfaModeSelect = ({ ], [LL.components.aclDefaultPolicySelect.options], ); + return ( - +
+ {options.map(({ key, value, label }) => { + const active = fieldValue === value; + return ( +
{ + onChange(value); + }} + > +

{label}

+ +
+ ); + })} +
); }; diff --git a/web/src/shared/components/Form/FormLocationMfaModeSelect/style.scss b/web/src/shared/components/Form/FormLocationMfaModeSelect/style.scss new file mode 100644 index 0000000000..62799c65d1 --- /dev/null +++ b/web/src/shared/components/Form/FormLocationMfaModeSelect/style.scss @@ -0,0 +1,45 @@ +.location-mfa-mode-select { + display: flex; + flex-flow: column; + row-gap: var(--spacing-s); + + .location-mfa-mode { + display: flex; + align-items: center; + justify-content: space-between; + column-gap: var(--spacing-xs); + min-height: 30px; + border: 1px solid var(--border-primary); + padding: var(--spacing-xs) var(--spacing-s); + border-radius: 10px; + cursor: pointer; + user-select: none; + transition-property: border-color; + + @include animate-standard; + + &:not(.active) { + &:hover { + border-color: var(--border-separator); + } + } + + &.active { + border-color: var(--surface-main-primary); + } + + &.active, + &:hover { + .label { + color: var(--text-body-primary); + } + } + + .label { + color: var(--text-body-secondary); + transition-property: color; + @include typography(app-modal-1); + @include animate-standard; + } + } +}