+
+
{LL.settingsPage.enterprise.fields.clientTrafficPolicy.header()}
+
+
+
+
+ -
+
{LL.settingsPage.enterprise.fields.clientTrafficPolicy.none.helper()}
+
+ -
+
+ {LL.settingsPage.enterprise.fields.clientTrafficPolicy.disableAllTraffic.helper()}
+
+
+ -
+
+ {LL.settingsPage.enterprise.fields.clientTrafficPolicy.forceAllTraffic.helper()}
+
+
+
+
+ {options.map(({ key, value, label, disabled = false }) => {
+ const active = fieldValue === value;
+ return (
+
{
+ if (!disabled) {
+ onChange(value);
+ }
+ }}
+ >
+
{label}
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/style.scss b/web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/style.scss
new file mode 100644
index 0000000000..ad3f917539
--- /dev/null
+++ b/web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/style.scss
@@ -0,0 +1,72 @@
+.client-traffic-policy-select {
+ display: flex;
+ flex-flow: column;
+ row-gap: var(--spacing-s);
+ margin-bottom: 25px;
+
+ .client-traffic-policy {
+ 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, opacity;
+ @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);
+ }
+ }
+
+ &.disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ background-color: var(--surface-secondary);
+
+ .label {
+ color: var(--text-body-disabled);
+ }
+
+ &:hover {
+ border-color: var(--border-primary);
+ }
+ }
+
+ .label {
+ color: var(--text-body-secondary);
+ transition-property: color;
+ @include typography(app-modal-1);
+ @include animate-standard;
+ }
+ }
+
+ #client-traffic-policy-message-box {
+ ul {
+ list-style-position: inside;
+ margin-top: 8px;
+
+ li {
+ p {
+ display: inline;
+ }
+ }
+ }
+ }
+}
diff --git a/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx b/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx
index a859fbbfca..d60e136342 100644
--- a/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx
+++ b/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx
@@ -5,10 +5,10 @@ import { useMemo, useState } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import { useI18nContext } from '../../../../../i18n/i18n-react';
+import { FormCheckBox } from '../../../../../shared/defguard-ui/components/Form/FormCheckBox/FormCheckBox';
import { FormInput } from '../../../../../shared/defguard-ui/components/Form/FormInput/FormInput';
import { FormSelect } from '../../../../../shared/defguard-ui/components/Form/FormSelect/FormSelect';
import { Helper } from '../../../../../shared/defguard-ui/components/Layout/Helper/Helper';
-import { LabeledCheckbox } from '../../../../../shared/defguard-ui/components/Layout/LabeledCheckbox/LabeledCheckbox';
import SvgIconDownload from '../../../../../shared/defguard-ui/components/svg/IconDownload';
import { titleCase } from '../../../../../shared/utils/titleCase';
import { SUPPORTED_SYNC_PROVIDERS } from './SupportedProviders';
@@ -80,16 +80,11 @@ export const DirsyncSettings = ({ isLoading }: { isLoading: boolean }) => {
{showDirsync ? (
<>
-
- {/* FIXME: Really buggy when using the controller, investigate why */}
- setValue('directory_sync_enabled', val)}
- // controller={{ control, name: 'directory_sync_enabled' }}
- />
-
+
{
disabled={isLoading}
/>
{providerName === 'Microsoft' ? (
- {parse(localLL.form.labels.group_match.helper())}
- }
- required={false}
- >
+ <>
+
+
+ {localLL.form.labels.prefetch_users.helper()}
+
+ {parse(localLL.form.labels.group_match.helper())}
+ }
+ required={false}
+ />
+ >
) : null}
{providerName === 'Okta' ? (
<>
diff --git a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx
index ec3215c2b4..992c57a543 100644
--- a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx
+++ b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsForm.tsx
@@ -102,6 +102,7 @@ export const OpenIdSettingsForm = () => {
google_service_account_email: z.string(),
google_service_account_key: z.string(),
directory_sync_enabled: z.boolean(),
+ prefetch_users: z.boolean(),
directory_sync_interval: z.number().min(60, LL.form.error.invalid()),
directory_sync_user_behavior: z.enum(['keep', 'disable', 'delete']),
directory_sync_admin_behavior: z.enum(['keep', 'disable', 'delete']),
@@ -175,6 +176,7 @@ export const OpenIdSettingsForm = () => {
google_service_account_email: '',
google_service_account_key: '',
directory_sync_enabled: false,
+ prefetch_users: false,
directory_sync_interval: 600,
directory_sync_user_behavior: 'keep',
directory_sync_admin_behavior: 'keep',
diff --git a/web/src/pages/settings/components/OpenIdSettings/components/style.scss b/web/src/pages/settings/components/OpenIdSettings/components/style.scss
index 017b6c9d4e..0e9f73a849 100644
--- a/web/src/pages/settings/components/OpenIdSettings/components/style.scss
+++ b/web/src/pages/settings/components/OpenIdSettings/components/style.scss
@@ -76,8 +76,14 @@
justify-content: flex-end;
}
- .labeled-checkbox {
- padding-bottom: var(--spacing-s);
+ #directory-sync-settings {
+ & > .form-checkbox {
+ padding-bottom: var(--spacing-s);
+ }
+
+ .helper-row {
+ padding-bottom: var(--spacing-s);
+ }
}
}
diff --git a/web/src/pages/settings/components/SmtpSettings/components/SmtpSettingsForm/SmtpSettingsForm.tsx b/web/src/pages/settings/components/SmtpSettings/components/SmtpSettingsForm/SmtpSettingsForm.tsx
index 55db9997b5..f28d33c025 100644
--- a/web/src/pages/settings/components/SmtpSettings/components/SmtpSettingsForm/SmtpSettingsForm.tsx
+++ b/web/src/pages/settings/components/SmtpSettings/components/SmtpSettingsForm/SmtpSettingsForm.tsx
@@ -27,7 +27,7 @@ import { patternValidEmail } from '../../../../../../shared/patterns';
import { QueryKeys } from '../../../../../../shared/queries';
import type { SettingsSMTP } from '../../../../../../shared/types';
import { invalidateMultipleQueries } from '../../../../../../shared/utils/invalidateMultipleQueries';
-import { validateIpOrDomain } from '../../../../../../shared/validators';
+import { Validate } from '../../../../../../shared/validators';
import { useSettingsPage } from '../../../../hooks/useSettingsPage';
import { SmtpTestModal } from '../SmtpTest/SmtpTestModal';
import { useSmtpTestModal } from '../SmtpTest/useSmtpTestModal';
@@ -112,8 +112,14 @@ export const SmtpSettingsForm = () => {
.trim()
.min(1, LL.form.error.required())
.refine(
- (val) => (!val ? true : validateIpOrDomain(val, false, true)),
- LL.form.error.endpoint(),
+ (val) =>
+ Validate.any(val, [
+ Validate.IPv4,
+ Validate.IPv6,
+ Validate.Domain,
+ Validate.Empty,
+ ]),
+ LL.form.error.address(),
),
smtp_port: z
.number({
diff --git a/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx b/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx
index caa6ef8530..993154b893 100644
--- a/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx
+++ b/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx
@@ -10,6 +10,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 { FormServiceLocationModeSelect } from '../../../../shared/components/Form/FormServiceLocationModeSelect/FormServiceLocationModeSelect.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';
@@ -22,10 +23,10 @@ import { useAppStore } from '../../../../shared/hooks/store/useAppStore.ts';
import useApi from '../../../../shared/hooks/useApi';
import { useToaster } from '../../../../shared/hooks/useToaster';
import { QueryKeys } from '../../../../shared/queries';
-import { LocationMfaMode } from '../../../../shared/types.ts';
+import { LocationMfaMode, ServiceLocationMode } from '../../../../shared/types.ts';
import { titleCase } from '../../../../shared/utils/titleCase';
import { trimObjectStrings } from '../../../../shared/utils/trimObjectStrings.ts';
-import { validateIpList, validateIpOrDomainList } from '../../../../shared/validators';
+import { Validate } from '../../../../shared/validators';
import { useWizardStore } from '../../hooks/useWizardStore';
import { DividerHeader } from './components/DividerHeader.tsx';
@@ -106,27 +107,52 @@ export const WizardNetworkConfiguration = () => {
.string()
.trim()
.min(1, LL.form.error.required())
- .refine((value) => {
- return validateIpList(value, ',', true);
- }, LL.form.error.addressNetmask()),
- endpoint: z.string().trim().min(1, LL.form.error.required()),
+ .refine(
+ (val) => Validate.any(val, [Validate.CIDRv4, Validate.CIDRv6]),
+ LL.form.error.addressNetmask(),
+ ),
+ endpoint: z
+ .string()
+ .trim()
+ .min(1, LL.form.error.required())
+ .refine(
+ (val) =>
+ Validate.any(val, [Validate.IPv4, Validate.IPv6, Validate.Domain], true),
+ LL.form.error.endpoint(),
+ ),
port: z
.number({
invalid_type_error: LL.form.error.invalid(),
})
.max(65535, LL.form.error.portMax())
.nonnegative(),
- allowed_ips: z.string().trim(),
+ allowed_ips: z
+ .string()
+ .trim()
+ .refine(
+ (val) =>
+ Validate.any(
+ val,
+ [
+ Validate.CIDRv4,
+ Validate.IPv4,
+ Validate.CIDRv6,
+ Validate.IPv6,
+ Validate.Empty,
+ ],
+ true,
+ ),
+ LL.form.error.address(),
+ ),
dns: z
.string()
.trim()
.optional()
- .refine((val) => {
- if (val === '' || !val) {
- return true;
- }
- return validateIpOrDomainList(val, ',', true);
- }, LL.form.error.allowedIps()),
+ .refine(
+ (val) =>
+ Validate.any(val, [Validate.IPv4, Validate.IPv6, Validate.Empty], true),
+ LL.form.error.address(),
+ ),
allowed_groups: z.array(z.string().trim().min(1, LL.form.error.minimumLength())),
keepalive_interval: z
.number({
@@ -141,6 +167,7 @@ export const WizardNetworkConfiguration = () => {
acl_enabled: z.boolean(),
acl_default_allow: z.boolean(),
location_mfa_mode: z.nativeEnum(LocationMfaMode),
+ service_location_mode: z.nativeEnum(ServiceLocationMode),
}),
[LL.form.error],
);
@@ -171,6 +198,15 @@ export const WizardNetworkConfiguration = () => {
() => locationMfaMode === LocationMfaMode.DISABLED,
[locationMfaMode],
);
+ const serviceLocationMode = useWatch({
+ control,
+ name: 'service_location_mode',
+ defaultValue: getDefaultValues.service_location_mode,
+ });
+ const serviceLocationEnabled = useMemo(
+ () => serviceLocationMode !== ServiceLocationMode.DISABLED,
+ [serviceLocationMode],
+ );
const handleValidSubmit: SubmitHandler = (values) => {
const trimmed = trimObjectStrings(values);
@@ -282,7 +318,17 @@ export const WizardNetworkConfiguration = () => {
-
+ {serviceLocationEnabled && (
+
+
+ {LL.networkConfiguration.form.helpers.locationMfaMode.serviceLocationWarning()}
+
+
+ )}
+
{LL.networkConfiguration.form.helpers.peerDisconnectThreshold()}
@@ -292,6 +338,15 @@ export const WizardNetworkConfiguration = () => {
type="number"
disabled={mfaDisabled}
/>
+ {!mfaDisabled && (
+
+ {LL.networkConfiguration.form.helpers.serviceLocation.mfaWarning()}
+
+ )}
+
diff --git a/web/src/pages/wizard/components/WizardNetworkImport/WizardNetworkImport.tsx b/web/src/pages/wizard/components/WizardNetworkImport/WizardNetworkImport.tsx
index 52c798c0ee..7f38c6d255 100644
--- a/web/src/pages/wizard/components/WizardNetworkImport/WizardNetworkImport.tsx
+++ b/web/src/pages/wizard/components/WizardNetworkImport/WizardNetworkImport.tsx
@@ -27,7 +27,7 @@ import { QueryKeys } from '../../../../shared/queries';
import type { ImportNetworkRequest } from '../../../../shared/types';
import { invalidateMultipleQueries } from '../../../../shared/utils/invalidateMultipleQueries';
import { titleCase } from '../../../../shared/utils/titleCase';
-import { validateIpOrDomain } from '../../../../shared/validators';
+import { Validate } from '../../../../shared/validators';
import { useWizardStore } from '../../hooks/useWizardStore';
interface FormInputs extends Omit {
@@ -70,7 +70,10 @@ export const WizardNetworkImport = () => {
.string()
.trim()
.min(1, LL.form.error.required())
- .refine((val) => validateIpOrDomain(val), LL.form.error.endpoint()),
+ .refine(
+ (val) => Validate.any(val, [Validate.IPv4, Validate.IPv6, Validate.Domain]),
+ LL.form.error.endpoint(),
+ ),
fileName: z.string().trim().min(1, LL.form.error.required()),
config: z.string().trim().min(1, LL.form.error.required()),
allowed_groups: z.array(z.string().min(1, LL.form.error.minimumLength())),
diff --git a/web/src/pages/wizard/hooks/useWizardStore.ts b/web/src/pages/wizard/hooks/useWizardStore.ts
index 0206902238..944f9b48f0 100644
--- a/web/src/pages/wizard/hooks/useWizardStore.ts
+++ b/web/src/pages/wizard/hooks/useWizardStore.ts
@@ -7,6 +7,7 @@ import {
type ImportedDevice,
LocationMfaMode,
type Network,
+ ServiceLocationMode,
} from '../../../shared/types';
export enum WizardSetupType {
@@ -34,6 +35,7 @@ const defaultValues: StoreFields = {
acl_enabled: false,
acl_default_allow: false,
location_mfa_mode: LocationMfaMode.DISABLED,
+ service_location_mode: ServiceLocationMode.DISABLED,
},
};
@@ -90,6 +92,7 @@ type StoreFields = {
acl_enabled: boolean;
acl_default_allow: boolean;
location_mfa_mode: LocationMfaMode;
+ service_location_mode: ServiceLocationMode;
};
};
diff --git a/web/src/shared/components/Form/FormLocationMfaModeSelect/FormLocationMfaModeSelect.tsx b/web/src/shared/components/Form/FormLocationMfaModeSelect/FormLocationMfaModeSelect.tsx
index 0c87aae1c7..23e89e11c0 100644
--- a/web/src/shared/components/Form/FormLocationMfaModeSelect/FormLocationMfaModeSelect.tsx
+++ b/web/src/shared/components/Form/FormLocationMfaModeSelect/FormLocationMfaModeSelect.tsx
@@ -14,10 +14,12 @@ import { LocationMfaMode } from '../../../types';
type Props = {
controller: UseControllerProps;
+ disabled?: boolean;
};
export const FormLocationMfaModeSelect = ({
controller,
+ disabled = false,
}: Props) => {
const { LL } = useI18nContext();
const {
@@ -38,12 +40,13 @@ export const FormLocationMfaModeSelect = ({
key: LocationMfaMode.INTERNAL,
value: LocationMfaMode.INTERNAL,
label: LL.components.locationMfaModeSelect.options.internal(),
+ disabled: disabled,
},
{
key: LocationMfaMode.EXTERNAL,
value: LocationMfaMode.EXTERNAL,
label: LL.components.locationMfaModeSelect.options.external(),
- disabled: externalMfaDisabled,
+ disabled: externalMfaDisabled || disabled,
},
],
[
@@ -51,6 +54,7 @@ export const FormLocationMfaModeSelect = ({
LL.components.locationMfaModeSelect.options.external,
LL.components.locationMfaModeSelect.options.internal,
externalMfaDisabled,
+ disabled,
],
);
diff --git a/web/src/shared/components/Form/FormServiceLocationModeSelect/FormServiceLocationModeSelect.tsx b/web/src/shared/components/Form/FormServiceLocationModeSelect/FormServiceLocationModeSelect.tsx
new file mode 100644
index 0000000000..150c158f1d
--- /dev/null
+++ b/web/src/shared/components/Form/FormServiceLocationModeSelect/FormServiceLocationModeSelect.tsx
@@ -0,0 +1,84 @@
+import './style.scss';
+import clsx from 'clsx';
+import { useMemo } from 'react';
+import {
+ type FieldValues,
+ type UseControllerProps,
+ useController,
+} from 'react-hook-form';
+import { useI18nContext } from '../../../../i18n/i18n-react';
+import { RadioButton } from '../../../defguard-ui/components/Layout/RadioButton/Radiobutton';
+import type { SelectOption } from '../../../defguard-ui/components/Layout/Select/types';
+import { useAppStore } from '../../../hooks/store/useAppStore';
+import { ServiceLocationMode } from '../../../types';
+
+type Props = {
+ controller: UseControllerProps;
+ disabled?: boolean;
+};
+
+export const FormServiceLocationModeSelect = ({
+ controller,
+ disabled = false,
+}: Props) => {
+ const { LL } = useI18nContext();
+ const {
+ field: { onChange, value: fieldValue },
+ } = useController(controller);
+ const enterpriseEnabled = useAppStore((s) => s.appInfo?.license_info.enterprise);
+
+ const options = useMemo(
+ (): SelectOption[] => [
+ {
+ key: ServiceLocationMode.DISABLED,
+ value: ServiceLocationMode.DISABLED,
+ label: LL.components.serviceLocationModeSelect.options.disabled(),
+ },
+ {
+ key: ServiceLocationMode.PRELOGON,
+ value: ServiceLocationMode.PRELOGON,
+ label: LL.components.serviceLocationModeSelect.options.prelogon(),
+ disabled: !enterpriseEnabled || disabled,
+ },
+ {
+ key: ServiceLocationMode.ALWAYSON,
+ value: ServiceLocationMode.ALWAYSON,
+ label: LL.components.serviceLocationModeSelect.options.alwayson(),
+ disabled: !enterpriseEnabled || disabled,
+ },
+ ],
+ [
+ LL.components.serviceLocationModeSelect.options.disabled,
+ LL.components.serviceLocationModeSelect.options.prelogon,
+ LL.components.serviceLocationModeSelect.options.alwayson,
+ disabled,
+ enterpriseEnabled,
+ ],
+ );
+
+ return (
+
+
+ {options.map(({ key, value, label, disabled = false }) => {
+ const active = fieldValue === value;
+ return (
+
{
+ if (!disabled) {
+ onChange(value);
+ }
+ }}
+ >
+
{label}
+
+
+ );
+ })}
+
+ );
+};
diff --git a/web/src/shared/components/Form/FormServiceLocationModeSelect/style.scss b/web/src/shared/components/Form/FormServiceLocationModeSelect/style.scss
new file mode 100644
index 0000000000..4cc39d601e
--- /dev/null
+++ b/web/src/shared/components/Form/FormServiceLocationModeSelect/style.scss
@@ -0,0 +1,59 @@
+.service-location-mode-select {
+ display: flex;
+ flex-flow: column;
+ row-gap: var(--spacing-s);
+ margin-bottom: 25px;
+
+ .service-location-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, opacity;
+ @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);
+ }
+ }
+
+ &.disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ background-color: var(--surface-secondary);
+
+ .label {
+ color: var(--text-body-disabled);
+ }
+
+ &:hover {
+ border-color: var(--border-primary);
+ }
+ }
+
+ .label {
+ color: var(--text-body-secondary);
+ transition-property: color;
+ @include typography(app-modal-1);
+ @include animate-standard;
+ }
+ }
+}
diff --git a/web/src/shared/patterns.ts b/web/src/shared/patterns.ts
index cfb5db0bdb..b9c4d6cb7b 100644
--- a/web/src/shared/patterns.ts
+++ b/web/src/shared/patterns.ts
@@ -63,9 +63,14 @@ export const patternValidUrl = new RegExp(
'$',
'i',
);
-
-export const patternValidDomain =
- /^(?:(?:(?:[A-Za-z-]+):\/{1,3})?(?:[A-Za-z0-9])(?:[A-Za-z0-9\-.]){1,61}(?:\.[A-Za-z]{2,})+|\[(?:(?:(?:[a-fA-F0-9]){1,4})(?::(?:[a-fA-F0-9]){1,4}){7}|::1|::)\]|(?:(?:[0-9]{1,3})(?:\.[0-9]{1,3}){3}))(?::[0-9]{1,5})?$/;
+export const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
+export const ipv4WithPortPattern = /^(\d{1,3}\.){3}\d{1,3}:\d{1,5}$/;
+export const ipv4WithCIDRPattern = /^(\d{1,3}\.){3}\d{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/;
+export const domainPattern =
+ /^(?:(?:(?:[A-Za-z-]+):\/{1,3})?(?:[A-Za-z0-9])(?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*(?:\.[A-Za-z]{2,})+|\[(?:(?:(?:[a-fA-F0-9]){1,4})(?::(?:[a-fA-F0-9]){1,4}){7}|::1|::)\]|(?:(?:[0-9]{1,3})(?:\.[0-9]{1,3}){3}))$/;
+
+export const domainWithPortPattern =
+ /^(?:(?:(?:[A-Za-z-]+):\/{1,3})?(?:[A-Za-z0-9])(?:[A-Za-z0-9\-.]){1,61}(?:\.[A-Za-z]{2,})+|\[(?:(?:(?:[a-fA-F0-9]){1,4})(?::(?:[a-fA-F0-9]){1,4}){7}|::1|::)\]|(?:(?:[0-9]{1,3})(?:\.[0-9]{1,3}){3})):[0-9]{1,5}$/;
export const patternSafeUsernameCharacters = /^[a-zA-Z0-9]+[a-zA-Z0-9.\-_]*$/;
diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts
index d423ceed58..86bf0baca2 100644
--- a/web/src/shared/types.ts
+++ b/web/src/shared/types.ts
@@ -139,6 +139,12 @@ export enum LocationMfaMode {
EXTERNAL = 'external',
}
+export enum ServiceLocationMode {
+ DISABLED = 'disabled',
+ PRELOGON = 'prelogon',
+ ALWAYSON = 'alwayson',
+}
+
export interface Network {
id: number;
name: string;
@@ -156,6 +162,7 @@ export interface Network {
acl_enabled: boolean;
acl_default_allow: boolean;
location_mfa_mode: LocationMfaMode;
+ service_location_mode: ServiceLocationMode;
}
export type ModifyNetworkRequest = {
@@ -164,7 +171,7 @@ export type ModifyNetworkRequest = {
Network,
'gateways' | 'connected' | 'id' | 'connected_at' | 'allowed_ips'
> & {
- allowed_ips: string;
+ allowed_ips?: string;
};
};
@@ -1114,9 +1121,15 @@ export type SettingsGatewayNotifications = {
gateway_disconnect_notifications_reconnect_notification_enabled: boolean;
};
+export enum ClientTrafficPolicy {
+ NONE = 'none',
+ DISABLE_ALL_TRAFFIC = 'disable_all_traffic',
+ FORCE_ALL_TRAFFIC = 'force_all_traffic',
+}
+
export type SettingsEnterprise = {
admin_device_management: boolean;
- disable_all_traffic: boolean;
+ client_traffic_policy: ClientTrafficPolicy;
only_client_activation: boolean;
};
diff --git a/web/src/shared/validators.ts b/web/src/shared/validators.ts
index 4bc73aa9df..23b24bbfc0 100644
--- a/web/src/shared/validators.ts
+++ b/web/src/shared/validators.ts
@@ -1,7 +1,12 @@
import ipaddr from 'ipaddr.js';
import { z } from 'zod';
-
-import { patternValidDomain, patternValidWireguardKey } from './patterns';
+import {
+ domainPattern,
+ ipv4Pattern,
+ ipv4WithCIDRPattern,
+ ipv4WithPortPattern,
+ patternValidWireguardKey,
+} from './patterns';
export const validateWireguardPublicKey = (props: {
requiredError: string;
@@ -18,79 +23,155 @@ export const validateWireguardPublicKey = (props: {
.max(44, props.maxError)
.regex(patternValidWireguardKey, props.validKeyError);
-// Returns false when invalid
-export const validateIpOrDomain = (
- val: string,
- allowMask = false,
- allowIPv6 = false,
-): boolean => {
- return (
- (allowIPv6 && validateIPv6(val, allowMask)) ||
- validateIPv4(val, allowMask) ||
- patternValidDomain.test(val)
- );
-};
+export const Validate = {
+ IPv4: (ip: string): boolean => {
+ if (!ipv4Pattern.test(ip)) {
+ return false;
+ }
+ if (!ipaddr.IPv4.isValid(ip)) {
+ return false;
+ }
+ return true;
+ },
+ IPv4withPort: (ip: string): boolean => {
+ if (!ipv4WithPortPattern.test(ip)) {
+ return false;
+ }
+ const addr = ip.split(':');
+ if (!ipaddr.IPv4.isValid(addr[0]) || !Validate.Port(addr[1])) {
+ return false;
+ }
+ return true;
+ },
+ IPv6: (ip: string): boolean => {
+ if (!ipaddr.IPv6.isValid(ip)) {
+ return false;
+ }
+ return true;
+ },
+ IPv6withPort: (ip: string): boolean => {
+ if (ip.includes(']')) {
+ const address = ip.split(']');
+ const ipv6 = address[0].replaceAll('[', '').replaceAll(']', '');
+ const port = address[1].replaceAll(']', '').replaceAll(':', '');
+ if (!ipaddr.IPv6.isValid(ipv6)) {
+ return false;
+ }
+ if (!Validate.Port(port)) {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ return true;
+ },
+ CIDRv4: (ip: string): boolean => {
+ if (!ipv4WithCIDRPattern.test(ip)) {
+ return false;
+ }
+ if (ip.endsWith('/0')) {
+ return false;
+ }
+ if (!ipaddr.IPv4.isValidCIDR(ip)) {
+ return false;
+ }
+ return true;
+ },
+ CIDRv6: (ip: string): boolean => {
+ if (ip.endsWith('/0')) {
+ return false;
+ }
+ if (!ipaddr.IPv6.isValidCIDR(ip)) {
+ return false;
+ }
+ return true;
+ },
+ Domain: (ip: string): boolean => {
+ if (!domainPattern.test(ip)) {
+ return false;
+ }
+ return true;
+ },
+ DomainWithPort: (ip: string): boolean => {
+ const splitted = ip.split(':');
+ const domain = splitted[0];
+ const port = splitted[1];
+ if (!Validate.Port(port)) {
+ return false;
+ }
+ if (!domainPattern.test(domain)) {
+ return false;
+ }
+ return true;
+ },
+ Port: (val: string): boolean => {
+ const parsed = Number(val);
+ if (Number.isNaN(parsed) || !Number.isInteger(parsed)) {
+ return false;
+ }
+ return 0 < parsed && parsed <= 65535;
+ },
+ Empty: (val: string): boolean => {
+ if (val === '' || !val) {
+ return true;
+ }
+ return false;
+ },
+ any: (
+ value: string | undefined,
+ validators: Array<(val: string) => boolean>,
+ allowList: boolean = false,
+ splitWith = ',',
+ ): boolean => {
+ if (!value) {
+ return true;
+ }
+ const items = value.replaceAll(' ', '').split(splitWith);
-// Returns false when invalid
-export const validateIpList = (
- val: string,
- splitWith = ',',
- allowMasks = false,
-): boolean => {
- return val
- .replace(' ', '')
- .split(splitWith)
- .every((el) => {
- return validateIPv4(el, allowMasks) || validateIPv6(el, allowMasks);
- });
-};
+ if (items.length > 1 && !allowList) {
+ return false;
+ }
-// Returns false when invalid
-export const validateIpOrDomainList = (
- val: string,
- splitWith = ',',
- allowMasks = false,
- allowIPv6 = false,
-): boolean => {
- const trimmed = val.replace(' ', '');
- const split = trimmed.split(splitWith);
- for (const value of split) {
- if (
- !validateIPv4(value, allowMasks) &&
- !patternValidDomain.test(value) &&
- (!allowIPv6 || !validateIPv6(value, allowMasks))
- ) {
- return false;
- }
- }
- return true;
-};
+ for (const item of items) {
+ let valid = false;
+ for (const validator of validators) {
+ if (validator(item)) {
+ valid = true;
+ break;
+ }
+ }
+ if (!valid) {
+ return false;
+ }
+ }
-// Returns false when invalid
-export const validateIPv4 = (ip: string, allowMask = false): boolean => {
- if (allowMask) {
- if (ip.includes('/')) {
- return ipaddr.IPv4.isValidCIDR(ip);
- }
- }
- return ipaddr.IPv4.isValid(ip);
-};
+ return true;
+ },
+ all: (
+ value: string | undefined,
+ validators: Array<(val: string) => boolean>,
+ allowList: boolean = false,
+ splitWith = ',',
+ ): boolean => {
+ if (!value) {
+ return true;
+ }
+ const items = value.replaceAll(' ', '').split(splitWith);
-export const validateIPv6 = (ip: string, allowMask = false): boolean => {
- if (allowMask) {
- if (ip.includes('/')) {
- return ipaddr.IPv6.isValidCIDR(ip);
- }
- }
- return ipaddr.IPv6.isValid(ip);
-};
+ if (items.length > 1 && !allowList) {
+ return false;
+ }
+ for (const item of items) {
+ for (const validator of validators) {
+ if (!validator(item)) {
+ return false;
+ }
+ }
+ }
-export const validatePort = (val: string) => {
- const parsed = parseInt(val, 10);
- if (!Number.isNaN(parsed)) {
- return parsed <= 65535;
- }
-};
+ return true;
+ },
+} as const;
export const numericString = (val: string) => /^\d+$/.test(val);
diff --git a/web/tests/validators.test.ts b/web/tests/validators.test.ts
new file mode 100644
index 0000000000..08f8000998
--- /dev/null
+++ b/web/tests/validators.test.ts
@@ -0,0 +1,301 @@
+import { describe, expect, it } from 'vitest';
+import { Validate } from '../src/shared/validators';
+
+describe('Validate.IPv4', () => {
+ it('should accept valid IPv4 addresses', () => {
+ expect(Validate.IPv4('192.168.1.1')).toBe(true);
+ expect(Validate.IPv4('10.0.0.1')).toBe(true);
+ expect(Validate.IPv4('172.16.0.1')).toBe(true);
+ expect(Validate.IPv4('255.255.255.255')).toBe(true);
+ expect(Validate.IPv4('0.0.0.0')).toBe(true);
+ });
+
+ it('should reject invalid IPv4 addresses', () => {
+ expect(Validate.IPv4('1')).toBe(false);
+ expect(Validate.IPv4('256.1.1.1')).toBe(false);
+ expect(Validate.IPv4('192.168.1')).toBe(false);
+ expect(Validate.IPv4('192.168.1.1.1')).toBe(false);
+ expect(Validate.IPv4('abc.def.ghi.jkl')).toBe(false);
+ expect(Validate.IPv4('192.168.1.1/24')).toBe(false);
+ });
+
+ it('should reject empty strings', () => {
+ expect(Validate.IPv4('')).toBe(false);
+ });
+});
+
+describe('Validate.IPv4withPort', () => {
+ it('should accept valid IPv4 with port', () => {
+ expect(Validate.IPv4withPort('192.168.1.1:8080')).toBe(true);
+ expect(Validate.IPv4withPort('10.0.0.1:80')).toBe(true);
+ expect(Validate.IPv4withPort('127.0.0.1:5051')).toBe(true);
+ expect(Validate.IPv4withPort('192.168.1.1:65535')).toBe(true);
+ });
+
+ it('should reject IPv4 without port', () => {
+ expect(Validate.IPv4withPort('192.168.1.1')).toBe(false);
+ });
+
+ it('should reject invalid port numbers', () => {
+ expect(Validate.IPv4withPort('192.168.1.1:0')).toBe(false);
+ expect(Validate.IPv4withPort('192.168.1.1:65536')).toBe(false);
+ expect(Validate.IPv4withPort('192.168.1.1:99999')).toBe(false);
+ });
+
+ it('should reject invalid IPv4 format', () => {
+ expect(Validate.IPv4withPort('256.1.1.1:8080')).toBe(false);
+ expect(Validate.IPv4withPort('192.168.1:8080')).toBe(false);
+ });
+});
+
+describe('Validate.IPv6', () => {
+ it('should accept valid IPv6 addresses', () => {
+ expect(Validate.IPv6('2001:db8::1')).toBe(true);
+ expect(Validate.IPv6('::1')).toBe(true);
+ expect(Validate.IPv6('::')).toBe(true);
+ expect(Validate.IPv6('2001:0db8:0000:0000:0000:0000:0000:0001')).toBe(true);
+ expect(Validate.IPv6('fe80::1')).toBe(true);
+ });
+
+ it('should reject invalid IPv6 addresses', () => {
+ expect(Validate.IPv6('192.168.1.1')).toBe(false);
+ expect(Validate.IPv6('gggg::1')).toBe(false);
+ expect(Validate.IPv6('invalid')).toBe(false);
+ });
+});
+
+describe('Validate.IPv6withPort', () => {
+ it('should accept valid IPv6 with port in brackets', () => {
+ expect(Validate.IPv6withPort('[::1]:8080')).toBe(true);
+ expect(Validate.IPv6withPort('[2001:db8::1]:80')).toBe(true);
+ expect(Validate.IPv6withPort('[fe80::1]:65535')).toBe(true);
+ });
+
+ it('should reject IPv6 without brackets', () => {
+ expect(Validate.IPv6withPort('::1:8080')).toBe(false);
+ expect(Validate.IPv6withPort('2001:db8::1:8080')).toBe(false);
+ });
+
+ it('should reject IPv6 without port', () => {
+ expect(Validate.IPv6withPort('[::1]')).toBe(false);
+ });
+
+ it('should reject invalid port numbers', () => {
+ expect(Validate.IPv6withPort('[::1]:0')).toBe(false);
+ expect(Validate.IPv6withPort('[::1]:65536')).toBe(false);
+ });
+});
+
+describe('Validate.CIDRv4', () => {
+ it('should accept valid IPv4 CIDR notation', () => {
+ expect(Validate.CIDRv4('192.168.1.0/24')).toBe(true);
+ expect(Validate.CIDRv4('10.0.0.0/8')).toBe(true);
+ expect(Validate.CIDRv4('172.16.0.0/12')).toBe(true);
+ expect(Validate.CIDRv4('192.168.1.1/32')).toBe(true);
+ expect(Validate.CIDRv4('192.168.1.0/1')).toBe(true);
+ });
+
+ it('should reject CIDR with /0 mask', () => {
+ expect(Validate.CIDRv4('192.168.1.0/0')).toBe(false);
+ });
+
+ it('should reject invalid CIDR masks', () => {
+ expect(Validate.CIDRv4('192.168.1.0/33')).toBe(false);
+ expect(Validate.CIDRv4('192.168.1.0/99')).toBe(false);
+ });
+
+ it('should reject IPv4 without CIDR mask', () => {
+ expect(Validate.CIDRv4('192.168.1.1')).toBe(false);
+ });
+
+ it('should reject invalid IPv4 in CIDR', () => {
+ expect(Validate.CIDRv4('256.1.1.1/24')).toBe(false);
+ expect(Validate.CIDRv4('192.168.1/24')).toBe(false);
+ });
+});
+
+describe('Validate.CIDRv6', () => {
+ it('should accept valid IPv6 CIDR notation', () => {
+ expect(Validate.CIDRv6('2001:db8::/32')).toBe(true);
+ expect(Validate.CIDRv6('fe80::/10')).toBe(true);
+ expect(Validate.CIDRv6('::1/128')).toBe(true);
+ });
+
+ it('should reject CIDR with /0 mask', () => {
+ expect(Validate.CIDRv6('2001:db8::/0')).toBe(false);
+ });
+
+ it('should reject invalid CIDR masks', () => {
+ expect(Validate.CIDRv6('2001:db8::/129')).toBe(false);
+ });
+
+ it('should reject IPv6 without CIDR mask', () => {
+ expect(Validate.CIDRv6('2001:db8::1')).toBe(false);
+ });
+});
+
+describe('Validate.Domain', () => {
+ it('should accept valid domain names', () => {
+ expect(Validate.Domain('example.com')).toBe(true);
+ expect(Validate.Domain('sub.example.com')).toBe(true);
+ expect(Validate.Domain('my-domain.co.uk')).toBe(true);
+ expect(Validate.Domain('test123.example.org')).toBe(true);
+ });
+
+ it('should reject domains with port', () => {
+ expect(Validate.Domain('example.com:8080')).toBe(false);
+ });
+
+ it('should reject invalid domain formats', () => {
+ expect(Validate.Domain('invalid domain')).toBe(false);
+ expect(Validate.Domain('example..com')).toBe(false);
+ expect(Validate.Domain('domain.secret.com')).toBe(true);
+ });
+});
+
+describe('Validate.DomainWithPort', () => {
+ it('should accept valid domains with port', () => {
+ expect(Validate.DomainWithPort('example.com:8080')).toBe(true);
+ expect(Validate.DomainWithPort('sub.example.com:443')).toBe(true);
+ expect(Validate.DomainWithPort('test.org:3000')).toBe(true);
+ });
+
+ it('should reject domains without port', () => {
+ expect(Validate.DomainWithPort('example.com')).toBe(false);
+ });
+
+ it('should reject invalid port numbers', () => {
+ expect(Validate.DomainWithPort('example.com:0')).toBe(false);
+ expect(Validate.DomainWithPort('example.com:65536')).toBe(false);
+ expect(Validate.DomainWithPort('example.com:99999')).toBe(false);
+ });
+});
+
+describe('Validate.Port', () => {
+ it('should accept valid port numbers', () => {
+ expect(Validate.Port('1')).toBe(true);
+ expect(Validate.Port('80')).toBe(true);
+ expect(Validate.Port('443')).toBe(true);
+ expect(Validate.Port('8080')).toBe(true);
+ expect(Validate.Port('65535')).toBe(true);
+ });
+
+ it('should reject port 0', () => {
+ expect(Validate.Port('0')).toBe(false);
+ });
+
+ it('should reject ports above 65535', () => {
+ expect(Validate.Port('65536')).toBe(false);
+ expect(Validate.Port('99999')).toBe(false);
+ });
+
+ it('should reject non-numeric values', () => {
+ expect(Validate.Port('abc')).toBe(false);
+ expect(Validate.Port('12.34')).toBe(false);
+ expect(Validate.Port('')).toBe(false);
+ });
+
+ it('should reject negative numbers', () => {
+ expect(Validate.Port('-1')).toBe(false);
+ });
+});
+
+describe('Validate.any', () => {
+ it('should accept single valid value matching any validator', () => {
+ expect(Validate.any('192.168.1.1', [Validate.IPv4, Validate.IPv6])).toBe(true);
+ expect(Validate.any('2001:db8::1', [Validate.IPv4, Validate.IPv6])).toBe(true);
+ expect(Validate.any('example.com', [Validate.Domain, Validate.IPv4])).toBe(true);
+ });
+
+ it('should reject single value not matching any validator', () => {
+ expect(Validate.any('invalid', [Validate.IPv4, Validate.IPv6])).toBe(false);
+ expect(Validate.any('256.1.1.1', [Validate.IPv4, Validate.IPv6])).toBe(false);
+ });
+
+ it('should reject multiple values when allowList is false (default)', () => {
+ expect(Validate.any('192.168.1.1,10.0.0.1', [Validate.IPv4])).toBe(false);
+ expect(Validate.any('example.com,test.com', [Validate.Domain])).toBe(false);
+ });
+
+ it('should accept multiple valid values when allowList is true', () => {
+ expect(Validate.any('192.168.1.1,10.0.0.1', [Validate.IPv4], true)).toBe(true);
+ expect(
+ Validate.any('192.168.1.1,2001:db8::1', [Validate.IPv4, Validate.IPv6], true),
+ ).toBe(true);
+ expect(Validate.any('example.com,test.org', [Validate.Domain], true)).toBe(true);
+ });
+
+ it('should reject list with any invalid value when allowList is true', () => {
+ expect(Validate.any('192.168.1.1,invalid', [Validate.IPv4], true)).toBe(false);
+ expect(Validate.any('192.168.1.1,256.1.1.1', [Validate.IPv4], true)).toBe(false);
+ });
+
+ it('should handle mixed valid values with allowList', () => {
+ expect(
+ Validate.any(
+ '192.168.1.1,2001:db8::1,10.0.0.1',
+ [Validate.IPv4, Validate.IPv6],
+ true,
+ ),
+ ).toBe(true);
+ expect(
+ Validate.any('example.com,192.168.1.1', [Validate.Domain, Validate.IPv4], true),
+ ).toBe(true);
+ });
+
+ it('should handle custom split character', () => {
+ expect(Validate.any('192.168.1.1;10.0.0.1', [Validate.IPv4], true, ';')).toBe(true);
+ expect(Validate.any('192.168.1.1|10.0.0.1', [Validate.IPv4], true, '|')).toBe(true);
+ });
+
+ it('should handle whitespace in list', () => {
+ expect(Validate.any('192.168.1.1, 10.0.0.1', [Validate.IPv4], true)).toBe(true);
+ expect(Validate.any('192.168.1.1 , 10.0.0.1', [Validate.IPv4], true)).toBe(true);
+ });
+
+ it('should accept empty string with Empty validator in list', () => {
+ expect(Validate.any('', [Validate.IPv4, Validate.Empty], true)).toBe(true);
+ });
+});
+
+describe('Validate.all', () => {
+ it('should accept single value matching all validators', () => {
+ expect(Validate.all('192.168.1.1', [Validate.IPv4])).toBe(true);
+ });
+
+ it('should reject single value not matching all validators', () => {
+ expect(Validate.all('192.168.1.1', [Validate.IPv4, Validate.IPv6])).toBe(false);
+ expect(Validate.all('invalid', [Validate.IPv4])).toBe(false);
+ });
+
+ it('should accept empty string or undefined', () => {
+ expect(Validate.all('', [Validate.IPv4])).toBe(true);
+ expect(Validate.all(undefined, [Validate.IPv4])).toBe(true);
+ });
+
+ it('should reject multiple values when allowList is false (default)', () => {
+ expect(Validate.all('192.168.1.1,10.0.0.1', [Validate.IPv4])).toBe(false);
+ });
+
+ it('should accept multiple valid values when allowList is true', () => {
+ expect(Validate.all('192.168.1.1,10.0.0.1', [Validate.IPv4], true)).toBe(true);
+ expect(Validate.all('example.com,test.org', [Validate.Domain], true)).toBe(true);
+ });
+
+ it('should reject if any value does not match all validators when allowList is true', () => {
+ expect(Validate.all('192.168.1.1,invalid', [Validate.IPv4], true)).toBe(false);
+ expect(Validate.all('192.168.1.1,256.1.1.1', [Validate.IPv4], true)).toBe(false);
+ });
+
+ it('should handle custom split character', () => {
+ expect(Validate.all('192.168.1.1;10.0.0.1', [Validate.IPv4], true, ';')).toBe(true);
+ expect(Validate.all('192.168.1.1|10.0.0.1', [Validate.IPv4], true, '|')).toBe(true);
+ });
+
+ it('should handle whitespace in list', () => {
+ expect(Validate.all('192.168.1.1, 10.0.0.1', [Validate.IPv4], true)).toBe(true);
+ expect(
+ Validate.all('192.168.1.1 , 10.0.0.1 , 172.16.0.1', [Validate.IPv4], true),
+ ).toBe(true);
+ });
+});
diff --git a/web/vitest.config.mts b/web/vitest.config.mts
new file mode 100644
index 0000000000..06e004bc54
--- /dev/null
+++ b/web/vitest.config.mts
@@ -0,0 +1,16 @@
+import { defineConfig } from 'vitest/config';
+import * as path from 'path';
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'node',
+ include: ['tests/**/*.test.ts'],
+ },
+ resolve: {
+ alias: {
+ '@scss': path.resolve(__dirname, './src/shared/scss'),
+ '@scssutils': path.resolve(__dirname, './src/shared/scss/global'),
+ },
+ },
+});