From 60adc1dac67721d047b1ebfdd60c56d3667e5bed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 27 Feb 2026 07:52:28 +0100 Subject: [PATCH 01/10] add test to verify that API enforces destination requirement --- .../tests/integration/api/acl.rs | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/crates/defguard_core/tests/integration/api/acl.rs b/crates/defguard_core/tests/integration/api/acl.rs index 36f913ebda..5e64ffce18 100644 --- a/crates/defguard_core/tests/integration/api/acl.rs +++ b/crates/defguard_core/tests/integration/api/acl.rs @@ -218,6 +218,111 @@ async fn test_rule_crud(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response_rules.len(), 0); } +#[sqlx::test] +async fn test_rule_requires_destination(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + // manual destination enabled but empty + let mut rule = make_rule(); + rule.use_manual_destination_settings = true; + rule.addresses = String::new(); + rule.ports = String::new(); + rule.protocols = Vec::new(); + rule.any_address = false; + rule.any_port = false; + rule.any_protocol = false; + rule.destinations = Vec::new(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // manual destination disabled and no destination aliases + let mut rule = make_rule(); + rule.use_manual_destination_settings = false; + rule.addresses = String::new(); + rule.ports = String::new(); + rule.protocols = Vec::new(); + rule.any_address = false; + rule.any_port = false; + rule.any_protocol = false; + rule.destinations = Vec::new(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // manual destination configured + let mut rule = make_rule(); + rule.use_manual_destination_settings = true; + rule.addresses = "10.0.0.1".to_string(); + rule.ports = "80".to_string(); + rule.protocols = vec![6]; + rule.any_address = false; + rule.any_port = false; + rule.any_protocol = false; + rule.destinations = Vec::new(); + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let created_rule: ApiAclRule = response.json().await; + + // destination alias configured + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let destination: Value = response.json().await; + let destination_id = destination["id"].as_i64().unwrap(); + + let mut rule = make_rule(); + rule.use_manual_destination_settings = false; + rule.addresses = String::new(); + rule.ports = String::new(); + rule.protocols = Vec::new(); + rule.any_address = false; + rule.any_port = false; + rule.any_protocol = false; + rule.destinations = vec![destination_id]; + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // update to invalid manual destination + let mut invalid_update = created_rule.clone(); + invalid_update.use_manual_destination_settings = true; + invalid_update.addresses = String::new(); + invalid_update.ports = String::new(); + invalid_update.protocols = Vec::new(); + invalid_update.any_address = false; + invalid_update.any_port = false; + invalid_update.any_protocol = false; + invalid_update.destinations = Vec::new(); + let response = client + .put(format!("/api/v1/acl/rule/{}", created_rule.id)) + .json(&invalid_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // update to invalid alias-only destination + let mut invalid_update = created_rule.clone(); + invalid_update.use_manual_destination_settings = false; + invalid_update.addresses = String::new(); + invalid_update.ports = String::new(); + invalid_update.protocols = Vec::new(); + invalid_update.any_address = false; + invalid_update.any_port = false; + invalid_update.any_protocol = false; + invalid_update.destinations = Vec::new(); + let response = client + .put(format!("/api/v1/acl/rule/{}", created_rule.id)) + .json(&invalid_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + #[sqlx::test] async fn test_rule_enterprise(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; From 7fdc99becaea17f00a5e27c63aec3ab0013356ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 27 Feb 2026 08:20:23 +0100 Subject: [PATCH 02/10] add destination validation --- .../src/enterprise/handlers/acl.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/defguard_core/src/enterprise/handlers/acl.rs b/crates/defguard_core/src/enterprise/handlers/acl.rs index ff7e8c2cfd..5bb9973d2d 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl.rs @@ -131,7 +131,24 @@ pub struct EditAclRule { impl EditAclRule { pub fn validate(&self) -> Result<(), WebError> { - // FIXME: validate that destination is defined + let manual_configured = self.any_address + || self.any_port + || self.any_protocol + || !self.addresses.trim().is_empty() + || !self.ports.trim().is_empty() + || !self.protocols.is_empty(); + if self.use_manual_destination_settings { + if !manual_configured { + return Err(WebError::BadRequest( + "Must provide manual destination settings".to_string(), + )); + } + } else if self.destinations.is_empty() { + return Err(WebError::BadRequest( + "Must provide destination alias".to_string(), + )); + } + // check if some allowed users/group/devices are configured if !self.allow_all_users && !self.allow_all_groups From 6d2a3249890b68c65aca1252cac95cfb71b1d8cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 27 Feb 2026 08:27:56 +0100 Subject: [PATCH 03/10] fix rule and alias type conversion --- crates/defguard_core/src/enterprise/db/models/acl.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/defguard_core/src/enterprise/db/models/acl.rs b/crates/defguard_core/src/enterprise/db/models/acl.rs index c876dff947..efdbca7d5a 100644 --- a/crates/defguard_core/src/enterprise/db/models/acl.rs +++ b/crates/defguard_core/src/enterprise/db/models/acl.rs @@ -854,7 +854,7 @@ impl TryFrom for AclRule { any_address: rule.any_address, any_port: rule.any_port, any_protocol: rule.any_protocol, - use_manual_destination_settings: true, + use_manual_destination_settings: rule.use_manual_destination_settings, }) } } @@ -1644,9 +1644,9 @@ impl TryFrom<&EditAclAlias> for AclAlias { kind: AliasKind::Component, state: AliasState::Applied, protocols: alias.protocols.clone(), - any_address: true, - any_port: true, - any_protocol: true, + any_address: false, + any_port: false, + any_protocol: false, }) } } From 1898ee81deef85e40abef2897ea47b41e171a90b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 27 Feb 2026 09:38:38 +0100 Subject: [PATCH 04/10] use the same validation approach for Destinations --- .../enterprise/handlers/acl/destination.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs index 1df5d43c71..f26172e973 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs @@ -16,6 +16,7 @@ use crate::{ AclAlias, AclAliasDestinationRange, AclAliasInfo, AclError, AliasKind, AliasState, Protocol, acl_delete_related_objects, parse_destination_addresses, }, + error::WebError, handlers::{ApiResponse, ApiResult}, }; @@ -32,6 +33,26 @@ pub(crate) struct EditAclDestination { } impl EditAclDestination { + fn validate(&self) -> Result<(), WebError> { + if !self.any_address && self.addresses.trim().is_empty() { + return Err(WebError::BadRequest( + "Must provide destination addresses or enable any address".to_string(), + )); + } + if !self.any_port && self.ports.trim().is_empty() { + return Err(WebError::BadRequest( + "Must provide destination ports or enable any port".to_string(), + )); + } + if !self.any_protocol && self.protocols.is_empty() { + return Err(WebError::BadRequest( + "Must provide destination protocols or enable any protocol".to_string(), + )); + } + + Ok(()) + } + /// Creates relation objects for a given [`AclAlias`] based on [`AclAliasInfo`] object. pub(crate) async fn create_related_objects( &self, @@ -307,6 +328,7 @@ pub(crate) async fn create_acl_destination( "User {} creating ACL destination {data:?}", session.user.username ); + data.validate()?; let alias = ApiAclDestination::create_from_api(&appstate.pool, &data) .await .map_err(|err| { @@ -344,6 +366,7 @@ pub(crate) async fn update_acl_destination( "User {} updating ACL destination {data:?}", session.user.username ); + data.validate()?; let alias = ApiAclDestination::update_from_api(&appstate.pool, id, &data) .await .map_err(|err| { From 2dc0b192c910950299d49b3c7e1cbea12956b794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 27 Feb 2026 09:51:43 +0100 Subject: [PATCH 05/10] add a test for Destination validation --- .../tests/integration/api/acl.rs | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/crates/defguard_core/tests/integration/api/acl.rs b/crates/defguard_core/tests/integration/api/acl.rs index 5e64ffce18..eef7df3200 100644 --- a/crates/defguard_core/tests/integration/api/acl.rs +++ b/crates/defguard_core/tests/integration/api/acl.rs @@ -1469,3 +1469,107 @@ async fn test_acl_count_endpoints(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(counts["applied"], json!(2)); assert_eq!(counts["pending"], json!(1)); } + +#[sqlx::test] +async fn test_destination_requires_any_or_values(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + // create destination with empty fields and no any flags + let invalid_destination = json!({ + "name": "invalid destination", + "addresses": "", + "ports": "", + "protocols": [], + "any_address": false, + "any_port": false, + "any_protocol": false + }); + let response = client + .post("/api/v1/acl/destination") + .json(&invalid_destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // try to create destinations with only some destination fields set + let invalid_destination = json!({ + "name": "invalid destination", + "addresses": "", + "ports": "22, 443", + "protocols": [], + "any_address": false, + "any_port": false, + "any_protocol": true + }); + let response = client + .post("/api/v1/acl/destination") + .json(&invalid_destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // create valid destination + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let destination: Value = response.json().await; + let destination_id = destination["id"].as_i64().unwrap(); + + // update destination with empty fields and no any flags + let invalid_update = json!({ + "name": "invalid update", + "addresses": "", + "ports": "", + "protocols": [], + "any_address": false, + "any_port": false, + "any_protocol": false + }); + let response = client + .put(format!("/api/v1/acl/destination/{destination_id}")) + .json(&invalid_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // update destination with some destination fields set + let invalid_update = json!({ + "name": "invalid update", + "addresses": "", + "ports": "5432", + "protocols": [], + "any_address": true, + "any_port": false, + "any_protocol": false + }); + let response = client + .put(format!("/api/v1/acl/destination/{destination_id}")) + .json(&invalid_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // create valid destination with only "any" flags enabled + let destination = json!({ + "name": "valid destination", + "addresses": "", + "ports": "", + "protocols": [], + "any_address": true, + "any_port": true, + "any_protocol": true + }); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); +} From 4a7201adfd31fe70a194478b90ad3ab29733a0f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 27 Feb 2026 10:29:46 +0100 Subject: [PATCH 06/10] validate frontend form & display destination error --- web/src/pages/CERulePage/CERulePage.tsx | 44 +++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/web/src/pages/CERulePage/CERulePage.tsx b/web/src/pages/CERulePage/CERulePage.tsx index 9ac7a5a167..e011f59207 100644 --- a/web/src/pages/CERulePage/CERulePage.tsx +++ b/web/src/pages/CERulePage/CERulePage.tsx @@ -33,6 +33,7 @@ import { ButtonsGroup } from '../../shared/defguard-ui/components/ButtonsGroup/B import { CheckboxIndicator } from '../../shared/defguard-ui/components/CheckboxIndicator/CheckboxIndicator'; import { Chip } from '../../shared/defguard-ui/components/Chip/Chip'; import { Divider } from '../../shared/defguard-ui/components/Divider/Divider'; +import { FieldError } from '../../shared/defguard-ui/components/FieldError/FieldError'; import { Fold } from '../../shared/defguard-ui/components/Fold/Fold'; import { Icon, type IconKindValue } from '../../shared/defguard-ui/components/Icon'; import { MarkedSection } from '../../shared/defguard-ui/components/MarkedSection/MarkedSection'; @@ -42,6 +43,7 @@ import { TooltipContent } from '../../shared/defguard-ui/providers/tooltip/Toolt import { TooltipProvider } from '../../shared/defguard-ui/providers/tooltip/TooltipContext'; import { TooltipTrigger } from '../../shared/defguard-ui/providers/tooltip/TooltipTrigger'; import { TextStyle, ThemeSpacing, ThemeVariable } from '../../shared/defguard-ui/types'; +import { useFormFieldError } from '../../shared/defguard-ui/hooks/useFormFieldError'; import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; import { useAppForm } from '../../shared/form'; import { formChangeLogic } from '../../shared/formLogic'; @@ -361,6 +363,39 @@ const Content = ({ rule: initialRule }: Props) => { message, }); } + + // check if ACL destination is set + if (vals.use_manual_destination_settings) { + const message = + 'Manual destination is enabled. Provide a value or enable Any.'; + if (!vals.any_address && vals.addresses.trim().length === 0) { + ctx.addIssue({ + path: ['addresses'], + code: 'custom', + message, + }); + } + if (!vals.any_port && vals.ports.trim().length === 0) { + ctx.addIssue({ + path: ['ports'], + code: 'custom', + message, + }); + } + if (!vals.any_protocol && vals.protocols.size === 0) { + ctx.addIssue({ + path: ['protocols'], + code: 'custom', + message, + }); + } + } else if (vals.destinations.size === 0) { + ctx.addIssue({ + path: ['destinations'], + code: 'custom', + message: m.form_error_required(), + }); + } }), [], ); @@ -531,6 +566,7 @@ const Content = ({ rule: initialRule }: Props) => { }); }} /> + {selectedDestinations.length > 0 && (
@@ -910,3 +946,11 @@ const AliasDataBlock = ({ values }: AliasDataBlockProps) => {
); }; + +const DestinationSelectionError = () => { + const error = useFormFieldError(); + if (!error) return null; + return ( + + ); +}; From 61709bc25559b41bc746f203026f62783341cf78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 27 Feb 2026 10:59:35 +0100 Subject: [PATCH 07/10] run destination validation on submit --- web/src/pages/CERulePage/CERulePage.tsx | 61 ++++++++++++++----------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/web/src/pages/CERulePage/CERulePage.tsx b/web/src/pages/CERulePage/CERulePage.tsx index e011f59207..f8a526487f 100644 --- a/web/src/pages/CERulePage/CERulePage.tsx +++ b/web/src/pages/CERulePage/CERulePage.tsx @@ -364,32 +364,7 @@ const Content = ({ rule: initialRule }: Props) => { }); } - // check if ACL destination is set - if (vals.use_manual_destination_settings) { - const message = - 'Manual destination is enabled. Provide a value or enable Any.'; - if (!vals.any_address && vals.addresses.trim().length === 0) { - ctx.addIssue({ - path: ['addresses'], - code: 'custom', - message, - }); - } - if (!vals.any_port && vals.ports.trim().length === 0) { - ctx.addIssue({ - path: ['ports'], - code: 'custom', - message, - }); - } - if (!vals.any_protocol && vals.protocols.size === 0) { - ctx.addIssue({ - path: ['protocols'], - code: 'custom', - message, - }); - } - } else if (vals.destinations.size === 0) { + if (!vals.use_manual_destination_settings && vals.destinations.size === 0) { ctx.addIssue({ path: ['destinations'], code: 'custom', @@ -400,6 +375,38 @@ const Content = ({ rule: initialRule }: Props) => { [], ); + const submitFormSchema = useMemo( + () => + formSchema.superRefine((vals, ctx) => { + if (vals.use_manual_destination_settings) { + const message = + 'Manual destination is enabled. Provide a value or enable Any.'; + if (!vals.any_address && vals.addresses.trim().length === 0) { + ctx.addIssue({ + path: ['addresses'], + code: 'custom', + message, + }); + } + if (!vals.any_port && vals.ports.trim().length === 0) { + ctx.addIssue({ + path: ['ports'], + code: 'custom', + message, + }); + } + if (!vals.any_protocol && vals.protocols.size === 0) { + ctx.addIssue({ + path: ['protocols'], + code: 'custom', + message, + }); + } + } + }), + [formSchema], + ); + type FormFields = z.infer; const defaultValues = useMemo((): FormFields => { @@ -447,7 +454,7 @@ const Content = ({ rule: initialRule }: Props) => { defaultValues, validationLogic: formChangeLogic, validators: { - onSubmit: formSchema, + onSubmit: submitFormSchema, onChange: formSchema, }, onSubmit: async ({ value }) => { From 6770c343c5f45143d33696d6b259004586a1ad7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 27 Feb 2026 12:17:40 +0100 Subject: [PATCH 08/10] don't show manual setting errors on fresh form --- web/src/pages/CERulePage/CERulePage.tsx | 222 ++++++++++++++++++------ 1 file changed, 166 insertions(+), 56 deletions(-) diff --git a/web/src/pages/CERulePage/CERulePage.tsx b/web/src/pages/CERulePage/CERulePage.tsx index f8a526487f..0f0d360f2a 100644 --- a/web/src/pages/CERulePage/CERulePage.tsx +++ b/web/src/pages/CERulePage/CERulePage.tsx @@ -4,7 +4,8 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { useRouter } from '@tanstack/react-router'; import { intersection } from 'lodash-es'; import { cloneDeep, flat, omit } from 'radashi'; -import { useMemo, useState } from 'react'; +import clsx from 'clsx'; +import { useCallback, useMemo, useState } from 'react'; import z from 'zod'; import { m } from '../../paraglide/messages'; import api from '../../shared/api/api'; @@ -36,8 +37,12 @@ import { Divider } from '../../shared/defguard-ui/components/Divider/Divider'; import { FieldError } from '../../shared/defguard-ui/components/FieldError/FieldError'; import { Fold } from '../../shared/defguard-ui/components/Fold/Fold'; import { Icon, type IconKindValue } from '../../shared/defguard-ui/components/Icon'; +import { Input } from '../../shared/defguard-ui/components/Input/Input'; import { MarkedSection } from '../../shared/defguard-ui/components/MarkedSection/MarkedSection'; import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { Textarea } from '../../shared/defguard-ui/components/Textarea/Textarea'; +import type { InputProps } from '../../shared/defguard-ui/components/Input/types'; +import type { TextareaProps } from '../../shared/defguard-ui/components/Textarea/types'; import { Snackbar } from '../../shared/defguard-ui/providers/snackbar/snackbar'; import { TooltipContent } from '../../shared/defguard-ui/providers/tooltip/TooltipContent'; import { TooltipProvider } from '../../shared/defguard-ui/providers/tooltip/TooltipContext'; @@ -45,7 +50,7 @@ import { TooltipTrigger } from '../../shared/defguard-ui/providers/tooltip/Toolt import { TextStyle, ThemeSpacing, ThemeVariable } from '../../shared/defguard-ui/types'; import { useFormFieldError } from '../../shared/defguard-ui/hooks/useFormFieldError'; import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; -import { useAppForm } from '../../shared/form'; +import { useAppForm, useFieldContext, useFormContext } from '../../shared/form'; import { formChangeLogic } from '../../shared/formLogic'; import { openModal } from '../../shared/hooks/modalControls/modalsSubjects'; import { ModalName } from '../../shared/hooks/modalControls/modalTypes'; @@ -364,7 +369,31 @@ const Content = ({ rule: initialRule }: Props) => { }); } - if (!vals.use_manual_destination_settings && vals.destinations.size === 0) { + if (vals.use_manual_destination_settings) { + const message = + 'Manual destination is enabled. Provide a value or enable Any.'; + if (!vals.any_address && vals.addresses.trim().length === 0) { + ctx.addIssue({ + path: ['addresses'], + code: 'custom', + message, + }); + } + if (!vals.any_port && vals.ports.trim().length === 0) { + ctx.addIssue({ + path: ['ports'], + code: 'custom', + message, + }); + } + if (!vals.any_protocol && vals.protocols.size === 0) { + ctx.addIssue({ + path: ['protocols'], + code: 'custom', + message, + }); + } + } else if (vals.destinations.size === 0) { ctx.addIssue({ path: ['destinations'], code: 'custom', @@ -375,38 +404,6 @@ const Content = ({ rule: initialRule }: Props) => { [], ); - const submitFormSchema = useMemo( - () => - formSchema.superRefine((vals, ctx) => { - if (vals.use_manual_destination_settings) { - const message = - 'Manual destination is enabled. Provide a value or enable Any.'; - if (!vals.any_address && vals.addresses.trim().length === 0) { - ctx.addIssue({ - path: ['addresses'], - code: 'custom', - message, - }); - } - if (!vals.any_port && vals.ports.trim().length === 0) { - ctx.addIssue({ - path: ['ports'], - code: 'custom', - message, - }); - } - if (!vals.any_protocol && vals.protocols.size === 0) { - ctx.addIssue({ - path: ['protocols'], - code: 'custom', - message, - }); - } - } - }), - [formSchema], - ); - type FormFields = z.infer; const defaultValues = useMemo((): FormFields => { @@ -454,7 +451,7 @@ const Content = ({ rule: initialRule }: Props) => { defaultValues, validationLogic: formChangeLogic, validators: { - onSubmit: submitFormSchema, + onSubmit: formSchema, onChange: formSchema, }, onSubmit: async ({ value }) => { @@ -687,11 +684,13 @@ const Content = ({ rule: initialRule }: Props) => { {(open) => ( - - {(field) => ( - - )} - + + {() => ( + + )} + alias.addresses.split(',')), @@ -714,11 +713,11 @@ const Content = ({ rule: initialRule }: Props) => { {(open) => ( - - {(field) => ( - - )} - + + {() => ( + + )} + alias.ports.split(',')), @@ -741,14 +740,14 @@ const Content = ({ rule: initialRule }: Props) => { {(open) => ( - - {(field) => ( - - )} - + + {() => ( + + )} + @@ -901,8 +900,8 @@ const Content = ({ rule: initialRule }: Props) => { */} - ({ isSubmitting: s.isSubmitting })}> - {({ isSubmitting }) => ( + ({ isSubmitting: s.isSubmitting, canSubmit: s.canSubmit })}> + {({ isSubmitting }) => ( {(field) => } @@ -961,3 +960,114 @@ const DestinationSelectionError = () => { ); }; + +const manualDestinationErrorMessage = + 'Manual destination is enabled. Provide a value or enable Any.'; + +const useManualDestinationErrorVisibility = () => { + const form = useFormContext(); + return useStore( + form.store, + (store) => !store.isSubmitSuccessful && store.submissionAttempts > 0, + ); +}; + +const ManualDestinationTextarea = ( + props: Omit, +) => { + const field = useFieldContext(); + const showError = useManualDestinationErrorVisibility(); + const error = + showError && field.state.meta.errors?.length + ? manualDestinationErrorMessage + : undefined; + + return ( +