From 887045a2c77166671d7ba94af5dcc205adb9a8ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 19 Mar 2026 15:17:06 +0100 Subject: [PATCH 1/8] fix IP display in activity log table --- .../ActivityLogPage/ActivityLogTable.tsx | 6 +++- web/src/shared/utils/formatIpForDisplay.ts | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 web/src/shared/utils/formatIpForDisplay.ts diff --git a/web/src/pages/ActivityLogPage/ActivityLogTable.tsx b/web/src/pages/ActivityLogPage/ActivityLogTable.tsx index 03513b27df..445c914f1d 100644 --- a/web/src/pages/ActivityLogPage/ActivityLogTable.tsx +++ b/web/src/pages/ActivityLogPage/ActivityLogTable.tsx @@ -17,6 +17,7 @@ import { TableTop } from '../../shared/defguard-ui/components/table/TableTop/Tab import { useApiToTableState } from '../../shared/defguard-ui/hooks/useApiToTableState'; import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; import { displayDate } from '../../shared/utils/displayDate'; +import { formatIpForDisplay } from '../../shared/utils/formatIpForDisplay'; type RowData = ActivityLogEvent; @@ -87,8 +88,11 @@ export const ActivityLogTable = ({ minSize: 150, cell: (info) => { const value = info.getValue(); + const displayValue = isPresent(value) ? formatIpForDisplay(value) : value; return ( - {renderOptionalTableValue(value, 'No IP recorded')} + + {renderOptionalTableValue(displayValue, 'No IP recorded')} + ); }, }), diff --git a/web/src/shared/utils/formatIpForDisplay.ts b/web/src/shared/utils/formatIpForDisplay.ts new file mode 100644 index 0000000000..b659747336 --- /dev/null +++ b/web/src/shared/utils/formatIpForDisplay.ts @@ -0,0 +1,33 @@ +import ipaddr from 'ipaddr.js'; + +const isHostCidr = (value: string): boolean => { + try { + const [address, prefixLength] = ipaddr.parseCIDR(value); + + if (address.kind() === 'ipv4') { + return prefixLength === 32; + } + + if (address.kind() === 'ipv6') { + return prefixLength === 128; + } + + return false; + } catch { + return false; + } +}; + +export const formatIpForDisplay = (value: string): string => { + const separatorIndex = value.lastIndexOf('/'); + + if (separatorIndex === -1) { + return value; + } + + if (!isHostCidr(value)) { + return value; + } + + return value.slice(0, separatorIndex); +}; From d2a220b90cf645bcfb133670ffcb379812b3408b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 19 Mar 2026 15:17:34 +0100 Subject: [PATCH 2/8] extend tests for new acl flags --- .../enterprise/firewall/tests/destination.rs | 478 ++++++++++++- .../src/enterprise/firewall/tests/mod.rs | 645 ++++++++++++++++++ 2 files changed, 1122 insertions(+), 1 deletion(-) diff --git a/crates/defguard_core/src/enterprise/firewall/tests/destination.rs b/crates/defguard_core/src/enterprise/firewall/tests/destination.rs index 213bbd1a18..ca198a64ed 100644 --- a/crates/defguard_core/src/enterprise/firewall/tests/destination.rs +++ b/crates/defguard_core/src/enterprise/firewall/tests/destination.rs @@ -5,7 +5,8 @@ use std::{ use defguard_common::db::{NoId, models::WireguardNetwork, setup_pool}; use defguard_proto::enterprise::firewall::{ - FirewallPolicy, IpAddress, IpRange, ip_address::Address, + FirewallPolicy, IpAddress, IpRange, Port, Protocol, ip_address::Address, + port::Port as PortInner, }; use rand::thread_rng; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; @@ -534,3 +535,478 @@ async fn test_manual_destination_merges_rule_and_component_alias_address_ranges( assert!(deny_rule.source_addrs.is_empty()); assert_eq!(deny_rule.destination_addrs, expected_destination_addrs); } + +#[sqlx::test] +async fn test_any_port_preserves_destination_addresses_and_protocols( + _: PgPoolOptions, + options: PgConnectOptions, +) { + set_test_license_business(); + let pool = setup_pool(options).await; + + let mut rng = thread_rng(); + + let mut location = WireguardNetwork::default() + .set_address(["10.0.0.1/16".parse().unwrap()]) + .unwrap(); + location.acl_enabled = true; + let location = location.save(&pool).await.unwrap(); + + create_test_users_and_devices(&mut rng, &pool, vec![&location]).await; + + let acl_rule = AclRule { + name: "any port manual destination rule".to_string(), + state: RuleState::Applied, + allow_all_users: true, + addresses: vec!["192.168.50.0/24".parse().unwrap()], + ports: vec![ + crate::enterprise::db::models::acl::PortRange::new(22, 22).into(), + crate::enterprise::db::models::acl::PortRange::new(443, 443).into(), + ], + protocols: vec![Protocol::Tcp.into(), Protocol::Udp.into()], + any_address: false, + any_port: true, + any_protocol: false, + use_manual_destination_settings: true, + ..Default::default() + }; + + create_acl_rule( + &pool, + acl_rule, + vec![location.id], + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + vec![( + IpAddr::V4(Ipv4Addr::new(192, 168, 60, 10)), + IpAddr::V4(Ipv4Addr::new(192, 168, 60, 20)), + )], + Vec::new(), + ) + .await; + + let mut conn = pool.acquire().await.unwrap(); + let generated_firewall_rules = try_get_location_firewall_config(&location, &mut conn) + .await + .unwrap() + .unwrap() + .rules; + + let expected_source_addrs = [ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.1.1".to_string(), + end: "10.0.1.2".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.2.1".to_string(), + end: "10.0.2.2".to_string(), + })), + }, + ]; + let expected_destination_addrs = [ + IpAddress { + address: Some(Address::IpSubnet("192.168.50.0/24".to_string())), + }, + IpAddress { + address: Some(Address::IpSubnet("192.168.60.10/31".to_string())), + }, + IpAddress { + address: Some(Address::IpSubnet("192.168.60.12/30".to_string())), + }, + IpAddress { + address: Some(Address::IpSubnet("192.168.60.16/30".to_string())), + }, + IpAddress { + address: Some(Address::Ip("192.168.60.20".to_string())), + }, + ]; + + assert_eq!(generated_firewall_rules.len(), 2); + + let allow_rule = &generated_firewall_rules[0]; + assert_eq!(allow_rule.verdict, i32::from(FirewallPolicy::Allow)); + assert_eq!(allow_rule.source_addrs, expected_source_addrs); + assert_eq!(allow_rule.destination_addrs, expected_destination_addrs); + assert!(allow_rule.destination_ports.is_empty()); + assert_eq!( + allow_rule.protocols, + [i32::from(Protocol::Tcp), i32::from(Protocol::Udp)] + ); + + let deny_rule = &generated_firewall_rules[1]; + assert_eq!(deny_rule.verdict, i32::from(FirewallPolicy::Deny)); + assert!(deny_rule.source_addrs.is_empty()); + assert_eq!(deny_rule.destination_addrs, expected_destination_addrs); + assert!(deny_rule.destination_ports.is_empty()); + assert!(deny_rule.protocols.is_empty()); +} + +#[sqlx::test] +async fn test_any_protocol_preserves_destination_addresses_and_ports( + _: PgPoolOptions, + options: PgConnectOptions, +) { + set_test_license_business(); + let pool = setup_pool(options).await; + + let mut rng = thread_rng(); + + let mut location = WireguardNetwork::default() + .set_address(["10.0.0.1/16".parse().unwrap()]) + .unwrap(); + location.acl_enabled = true; + let location = location.save(&pool).await.unwrap(); + + create_test_users_and_devices(&mut rng, &pool, vec![&location]).await; + + let acl_rule = AclRule { + name: "any protocol manual destination rule".to_string(), + state: RuleState::Applied, + allow_all_users: true, + addresses: vec![ + "192.168.70.0/24".parse().unwrap(), + "192.168.80.1/32".parse().unwrap(), + ], + ports: vec![ + crate::enterprise::db::models::acl::PortRange::new(80, 80).into(), + crate::enterprise::db::models::acl::PortRange::new(1000, 1005).into(), + ], + protocols: vec![Protocol::Tcp.into(), Protocol::Udp.into()], + any_address: false, + any_port: false, + any_protocol: true, + use_manual_destination_settings: true, + ..Default::default() + }; + + create_acl_rule( + &pool, + acl_rule, + vec![location.id], + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ) + .await; + + let mut conn = pool.acquire().await.unwrap(); + let generated_firewall_rules = try_get_location_firewall_config(&location, &mut conn) + .await + .unwrap() + .unwrap() + .rules; + + let expected_source_addrs = [ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.1.1".to_string(), + end: "10.0.1.2".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.2.1".to_string(), + end: "10.0.2.2".to_string(), + })), + }, + ]; + let expected_destination_addrs = [ + IpAddress { + address: Some(Address::IpSubnet("192.168.70.0/24".to_string())), + }, + IpAddress { + address: Some(Address::Ip("192.168.80.1".to_string())), + }, + ]; + let expected_ports = [ + Port { + port: Some(PortInner::SinglePort(80)), + }, + Port { + port: Some(PortInner::PortRange( + defguard_proto::enterprise::firewall::PortRange { + start: 1000, + end: 1005, + }, + )), + }, + ]; + + assert_eq!(generated_firewall_rules.len(), 2); + + let allow_rule = &generated_firewall_rules[0]; + assert_eq!(allow_rule.verdict, i32::from(FirewallPolicy::Allow)); + assert_eq!(allow_rule.source_addrs, expected_source_addrs); + assert_eq!(allow_rule.destination_addrs, expected_destination_addrs); + assert_eq!(allow_rule.destination_ports, expected_ports); + assert!(allow_rule.protocols.is_empty()); + + let deny_rule = &generated_firewall_rules[1]; + assert_eq!(deny_rule.verdict, i32::from(FirewallPolicy::Deny)); + assert!(deny_rule.source_addrs.is_empty()); + assert_eq!(deny_rule.destination_addrs, expected_destination_addrs); + assert!(deny_rule.destination_ports.is_empty()); + assert!(deny_rule.protocols.is_empty()); +} + +#[sqlx::test] +async fn test_destination_alias_any_port_preserves_addresses_and_protocols( + _: PgPoolOptions, + options: PgConnectOptions, +) { + set_test_license_business(); + let pool = setup_pool(options).await; + + let mut rng = thread_rng(); + + let mut location = WireguardNetwork::default() + .set_address(["10.0.0.1/16".parse().unwrap()]) + .unwrap(); + location.acl_enabled = true; + let location = location.save(&pool).await.unwrap(); + + create_test_users_and_devices(&mut rng, &pool, vec![&location]).await; + + let destination_alias = AclAlias { + name: "any port destination alias".to_string(), + kind: AliasKind::Destination, + addresses: vec!["192.168.90.0/24".parse().unwrap()], + ports: vec![ + crate::enterprise::db::models::acl::PortRange::new(22, 22).into(), + crate::enterprise::db::models::acl::PortRange::new(443, 443).into(), + ], + protocols: vec![Protocol::Tcp.into(), Protocol::Udp.into()], + any_address: false, + any_port: true, + any_protocol: false, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + AclAliasDestinationRange { + id: NoId, + alias_id: destination_alias.id, + start: IpAddr::V4(Ipv4Addr::new(192, 168, 91, 10)), + end: IpAddr::V4(Ipv4Addr::new(192, 168, 91, 20)), + } + .save(&pool) + .await + .unwrap(); + + let acl_rule = AclRule { + name: "any port destination alias rule".to_string(), + state: RuleState::Applied, + allow_all_users: true, + use_manual_destination_settings: false, + ..Default::default() + }; + + create_acl_rule( + &pool, + acl_rule, + vec![location.id], + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + vec![destination_alias.id], + ) + .await; + + let mut conn = pool.acquire().await.unwrap(); + let generated_firewall_rules = try_get_location_firewall_config(&location, &mut conn) + .await + .unwrap() + .unwrap() + .rules; + + let expected_source_addrs = [ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.1.1".to_string(), + end: "10.0.1.2".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.2.1".to_string(), + end: "10.0.2.2".to_string(), + })), + }, + ]; + let expected_destination_addrs = [ + IpAddress { + address: Some(Address::IpSubnet("192.168.90.0/24".to_string())), + }, + IpAddress { + address: Some(Address::IpSubnet("192.168.91.10/31".to_string())), + }, + IpAddress { + address: Some(Address::IpSubnet("192.168.91.12/30".to_string())), + }, + IpAddress { + address: Some(Address::IpSubnet("192.168.91.16/30".to_string())), + }, + IpAddress { + address: Some(Address::Ip("192.168.91.20".to_string())), + }, + ]; + + assert_eq!(generated_firewall_rules.len(), 2); + + let allow_rule = &generated_firewall_rules[0]; + assert_eq!(allow_rule.verdict, i32::from(FirewallPolicy::Allow)); + assert_eq!(allow_rule.source_addrs, expected_source_addrs); + assert_eq!(allow_rule.destination_addrs, expected_destination_addrs); + assert!(allow_rule.destination_ports.is_empty()); + assert_eq!( + allow_rule.protocols, + [i32::from(Protocol::Tcp), i32::from(Protocol::Udp)] + ); + + let deny_rule = &generated_firewall_rules[1]; + assert_eq!(deny_rule.verdict, i32::from(FirewallPolicy::Deny)); + assert!(deny_rule.source_addrs.is_empty()); + assert_eq!(deny_rule.destination_addrs, expected_destination_addrs); + assert!(deny_rule.destination_ports.is_empty()); + assert!(deny_rule.protocols.is_empty()); +} + +#[sqlx::test] +async fn test_destination_alias_any_protocol_preserves_addresses_and_ports( + _: PgPoolOptions, + options: PgConnectOptions, +) { + set_test_license_business(); + let pool = setup_pool(options).await; + + let mut rng = thread_rng(); + + let mut location = WireguardNetwork::default() + .set_address(["10.0.0.1/16".parse().unwrap()]) + .unwrap(); + location.acl_enabled = true; + let location = location.save(&pool).await.unwrap(); + + create_test_users_and_devices(&mut rng, &pool, vec![&location]).await; + + let destination_alias = AclAlias { + name: "any protocol destination alias".to_string(), + kind: AliasKind::Destination, + addresses: vec![ + "192.168.110.0/24".parse().unwrap(), + "192.168.120.1/32".parse().unwrap(), + ], + ports: vec![ + crate::enterprise::db::models::acl::PortRange::new(80, 80).into(), + crate::enterprise::db::models::acl::PortRange::new(1000, 1005).into(), + ], + protocols: vec![Protocol::Tcp.into(), Protocol::Udp.into()], + any_address: false, + any_port: false, + any_protocol: true, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + let acl_rule = AclRule { + name: "any protocol destination alias rule".to_string(), + state: RuleState::Applied, + allow_all_users: true, + use_manual_destination_settings: false, + ..Default::default() + }; + + create_acl_rule( + &pool, + acl_rule, + vec![location.id], + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + vec![destination_alias.id], + ) + .await; + + let mut conn = pool.acquire().await.unwrap(); + let generated_firewall_rules = try_get_location_firewall_config(&location, &mut conn) + .await + .unwrap() + .unwrap() + .rules; + + let expected_source_addrs = [ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.1.1".to_string(), + end: "10.0.1.2".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.2.1".to_string(), + end: "10.0.2.2".to_string(), + })), + }, + ]; + let expected_destination_addrs = [ + IpAddress { + address: Some(Address::IpSubnet("192.168.110.0/24".to_string())), + }, + IpAddress { + address: Some(Address::Ip("192.168.120.1".to_string())), + }, + ]; + let expected_ports = [ + Port { + port: Some(PortInner::SinglePort(80)), + }, + Port { + port: Some(PortInner::PortRange( + defguard_proto::enterprise::firewall::PortRange { + start: 1000, + end: 1005, + }, + )), + }, + ]; + + assert_eq!(generated_firewall_rules.len(), 2); + + let allow_rule = &generated_firewall_rules[0]; + assert_eq!(allow_rule.verdict, i32::from(FirewallPolicy::Allow)); + assert_eq!(allow_rule.source_addrs, expected_source_addrs); + assert_eq!(allow_rule.destination_addrs, expected_destination_addrs); + assert_eq!(allow_rule.destination_ports, expected_ports); + assert!(allow_rule.protocols.is_empty()); + + let deny_rule = &generated_firewall_rules[1]; + assert_eq!(deny_rule.verdict, i32::from(FirewallPolicy::Deny)); + assert!(deny_rule.source_addrs.is_empty()); + assert_eq!(deny_rule.destination_addrs, expected_destination_addrs); + assert!(deny_rule.destination_ports.is_empty()); + assert!(deny_rule.protocols.is_empty()); +} diff --git a/crates/defguard_core/src/enterprise/firewall/tests/mod.rs b/crates/defguard_core/src/enterprise/firewall/tests/mod.rs index 6a78e4b09e..fccbd40a4a 100644 --- a/crates/defguard_core/src/enterprise/firewall/tests/mod.rs +++ b/crates/defguard_core/src/enterprise/firewall/tests/mod.rs @@ -75,6 +75,87 @@ fn random_network_device_with_id(rng: &mut R, id: Id) -> Device { device } +fn expected_ipv4_source_range_for_user(user_id: Id) -> IpAddress { + let user_octet = user_id as u8; + IpAddress { + address: Some(Address::IpRange(IpRange { + start: format!("10.0.{user_octet}.1"), + end: format!("10.0.{user_octet}.2"), + })), + } +} + +async fn create_test_user_with_devices( + rng: &mut R, + pool: &PgPool, + test_locations: &[&WireguardNetwork], +) -> User { + let user: User = rng.r#gen(); + let user = user.save(pool).await.unwrap(); + + for device_number in 1u8..=2 { + let device = Device { + id: NoId, + name: format!("device-{}-{device_number}", user.id), + user_id: user.id, + device_type: DeviceType::User, + description: None, + wireguard_pubkey: String::default(), + created: NaiveDateTime::default(), + configured: true, + }; + let device = device.save(pool).await.unwrap(); + + for location in test_locations { + let wireguard_ips = location + .address() + .iter() + .map(|subnet| match subnet { + IpNetwork::V4(ipv4_network) => { + let octets = ipv4_network.network().octets(); + IpAddr::V4(Ipv4Addr::new( + octets[0], + octets[1], + user.id as u8, + device_number, + )) + } + IpNetwork::V6(ipv6_network) => { + let mut octets = ipv6_network.network().octets(); + octets[14] = user.id as u8; + octets[15] = device_number; + IpAddr::V6(Ipv6Addr::from(octets)) + } + }) + .collect(); + + WireguardNetworkDevice { + device_id: device.id, + wireguard_network_id: location.id, + wireguard_ips, + } + .insert(pool) + .await + .unwrap(); + } + } + + user +} + +async fn add_users_to_group(pool: &PgPool, group_id: Id, users: &[&User]) { + for user in users { + query!( + "INSERT INTO group_user (user_id, group_id) VALUES ($1, $2)", + user.id, + group_id + ) + .execute(pool) + .await + .unwrap(); + } +} + async fn create_test_users_and_devices( rng: &mut ThreadRng, pool: &PgPool, @@ -2105,6 +2186,570 @@ async fn test_no_allowed_users_ipv4(_: PgPoolOptions, options: PgConnectOptions) } } +#[sqlx::test] +async fn test_allow_all_groups_expands_all_group_members_into_firewall_sources( + _: PgPoolOptions, + options: PgConnectOptions, +) { + set_test_license_business(); + let pool = setup_pool(options).await; + let mut rng = thread_rng(); + + let mut location = WireguardNetwork::default() + .set_address(["10.0.0.1/16".parse().unwrap()]) + .unwrap(); + location.acl_enabled = true; + let location = location.save(&pool).await.unwrap(); + + let grouped_allowed_user_a = create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + let grouped_allowed_user_b = create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + let grouped_denied_user = create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + let explicitly_allowed_ungrouped_user = + create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + let ungrouped_blocked_user = create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + + let first_group = Group { + name: "allow-all-groups-first".into(), + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + let second_group = Group { + name: "allow-all-groups-second".into(), + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + add_users_to_group( + &pool, + first_group.id, + &[&grouped_allowed_user_a, &grouped_denied_user], + ) + .await; + add_users_to_group(&pool, second_group.id, &[&grouped_allowed_user_b]).await; + + let acl_rule = AclRule { + name: "allow all groups source expansion".into(), + state: RuleState::Applied, + allow_all_groups: true, + addresses: vec!["192.168.10.0/24".parse().unwrap()], + ports: vec![PortRange::new(443, 443).into()], + protocols: vec![Protocol::Tcp.into()], + any_address: false, + any_port: false, + any_protocol: false, + use_manual_destination_settings: true, + ..Default::default() + }; + + create_acl_rule( + &pool, + acl_rule, + vec![location.id], + vec![explicitly_allowed_ungrouped_user.id], + vec![grouped_denied_user.id], + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ) + .await; + + let mut conn = pool.acquire().await.unwrap(); + let generated_firewall_rules = try_get_location_firewall_config(&location, &mut conn) + .await + .unwrap() + .unwrap() + .rules; + + assert_eq!(generated_firewall_rules.len(), 2); + + let mut expected_allowed_user_ids = vec![ + grouped_allowed_user_a.id, + grouped_allowed_user_b.id, + explicitly_allowed_ungrouped_user.id, + ]; + expected_allowed_user_ids.sort_unstable(); + let expected_source_addrs: Vec<_> = expected_allowed_user_ids + .into_iter() + .map(expected_ipv4_source_range_for_user) + .collect(); + + let allow_rule = &generated_firewall_rules[0]; + assert_eq!(allow_rule.verdict, i32::from(FirewallPolicy::Allow)); + assert_eq!(allow_rule.source_addrs, expected_source_addrs); + assert_eq!( + allow_rule.destination_addrs, + [IpAddress { + address: Some(Address::IpSubnet("192.168.10.0/24".to_string())), + }] + ); + assert_eq!( + allow_rule.destination_ports, + [Port { + port: Some(PortInner::SinglePort(443)), + }] + ); + assert_eq!(allow_rule.protocols, [i32::from(Protocol::Tcp)]); + assert!( + allow_rule + .source_addrs + .iter() + .all(|addr| addr != &expected_ipv4_source_range_for_user(grouped_denied_user.id)) + ); + assert!( + allow_rule + .source_addrs + .iter() + .all(|addr| addr != &expected_ipv4_source_range_for_user(ungrouped_blocked_user.id)) + ); + + let deny_rule = &generated_firewall_rules[1]; + assert_eq!(deny_rule.verdict, i32::from(FirewallPolicy::Deny)); + assert!(deny_rule.source_addrs.is_empty()); + assert_eq!(deny_rule.destination_addrs, allow_rule.destination_addrs); + assert!(deny_rule.destination_ports.is_empty()); + assert!(deny_rule.protocols.is_empty()); +} + +#[sqlx::test] +async fn test_allow_all_groups_deduplicates_shared_group_members_before_source_resolution( + _: PgPoolOptions, + options: PgConnectOptions, +) { + set_test_license_business(); + let pool = setup_pool(options).await; + let mut rng = thread_rng(); + + let mut location = WireguardNetwork::default() + .set_address(["10.0.0.1/16".parse().unwrap()]) + .unwrap(); + location.acl_enabled = true; + let location = location.save(&pool).await.unwrap(); + + let shared_grouped_user = create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + let first_group_only_user = create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + let second_group_only_user = create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + let explicitly_allowed_ungrouped_user = + create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + let ungrouped_blocked_user = create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + + let first_group = Group { + name: "allow-all-groups-dedup-first".into(), + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + let second_group = Group { + name: "allow-all-groups-dedup-second".into(), + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + add_users_to_group( + &pool, + first_group.id, + &[&shared_grouped_user, &first_group_only_user], + ) + .await; + add_users_to_group( + &pool, + second_group.id, + &[&shared_grouped_user, &second_group_only_user], + ) + .await; + + let acl_rule = AclRule { + name: "allow all groups dedup source expansion".into(), + state: RuleState::Applied, + allow_all_groups: true, + addresses: vec!["192.168.30.0/24".parse().unwrap()], + ports: vec![PortRange::new(443, 443).into()], + protocols: vec![Protocol::Tcp.into()], + any_address: false, + any_port: false, + any_protocol: false, + use_manual_destination_settings: true, + ..Default::default() + }; + + let acl_rule_info = create_acl_rule( + &pool, + acl_rule, + vec![location.id], + vec![explicitly_allowed_ungrouped_user.id], + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ) + .await; + + let mut conn = pool.acquire().await.unwrap(); + + let allowed_users = acl_rule_info + .get_all_allowed_users(&mut conn) + .await + .unwrap(); + let denied_users = acl_rule_info.get_all_denied_users(&mut conn).await.unwrap(); + let mut resolved_source_user_ids: Vec<_> = + super::get_source_users(allowed_users, &denied_users) + .into_iter() + .map(|user| user.id) + .collect(); + + let mut expected_source_user_ids = vec![ + shared_grouped_user.id, + first_group_only_user.id, + second_group_only_user.id, + explicitly_allowed_ungrouped_user.id, + ]; + expected_source_user_ids.sort_unstable(); + resolved_source_user_ids.sort_unstable(); + + assert_eq!(resolved_source_user_ids, expected_source_user_ids); + assert_eq!(resolved_source_user_ids.len(), 4); + + let generated_firewall_rules = try_get_location_firewall_config(&location, &mut conn) + .await + .unwrap() + .unwrap() + .rules; + + assert_eq!(generated_firewall_rules.len(), 2); + + let expected_source_addrs: Vec<_> = expected_source_user_ids + .into_iter() + .map(expected_ipv4_source_range_for_user) + .collect(); + + let allow_rule = &generated_firewall_rules[0]; + assert_eq!(allow_rule.verdict, i32::from(FirewallPolicy::Allow)); + assert_eq!(allow_rule.source_addrs, expected_source_addrs); + assert_eq!( + allow_rule.destination_addrs, + [IpAddress { + address: Some(Address::IpSubnet("192.168.30.0/24".to_string())), + }] + ); + assert_eq!( + allow_rule.destination_ports, + [Port { + port: Some(PortInner::SinglePort(443)), + }] + ); + assert_eq!(allow_rule.protocols, [i32::from(Protocol::Tcp)]); + assert!( + allow_rule + .source_addrs + .iter() + .all(|addr| addr != &expected_ipv4_source_range_for_user(ungrouped_blocked_user.id)) + ); + + let deny_rule = &generated_firewall_rules[1]; + assert_eq!(deny_rule.verdict, i32::from(FirewallPolicy::Deny)); + assert!(deny_rule.source_addrs.is_empty()); + assert_eq!(deny_rule.destination_addrs, allow_rule.destination_addrs); + assert!(deny_rule.destination_ports.is_empty()); + assert!(deny_rule.protocols.is_empty()); +} + +#[sqlx::test] +async fn test_deny_all_groups_excludes_members_of_every_group_from_firewall_sources( + _: PgPoolOptions, + options: PgConnectOptions, +) { + set_test_license_business(); + let pool = setup_pool(options).await; + let mut rng = thread_rng(); + + let mut location = WireguardNetwork::default() + .set_address(["10.0.0.1/16".parse().unwrap()]) + .unwrap(); + location.acl_enabled = true; + let location = location.save(&pool).await.unwrap(); + + let grouped_denied_user_a = create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + let grouped_denied_user_b = create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + let grouped_denied_user_c = create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + let ungrouped_allowed_user = create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + let explicitly_denied_ungrouped_user = + create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + + let first_group = Group { + name: "deny-all-groups-first".into(), + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + let second_group = Group { + name: "deny-all-groups-second".into(), + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + add_users_to_group( + &pool, + first_group.id, + &[&grouped_denied_user_a, &grouped_denied_user_c], + ) + .await; + add_users_to_group(&pool, second_group.id, &[&grouped_denied_user_b]).await; + + let acl_rule = AclRule { + name: "deny all groups source filtering".into(), + state: RuleState::Applied, + allow_all_users: true, + deny_all_groups: true, + addresses: vec!["192.168.20.0/24".parse().unwrap()], + ports: vec![PortRange::new(8443, 8443).into()], + protocols: vec![Protocol::Tcp.into()], + any_address: false, + any_port: false, + any_protocol: false, + use_manual_destination_settings: true, + ..Default::default() + }; + + create_acl_rule( + &pool, + acl_rule, + vec![location.id], + Vec::new(), + vec![explicitly_denied_ungrouped_user.id], + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ) + .await; + + let mut conn = pool.acquire().await.unwrap(); + let generated_firewall_rules = try_get_location_firewall_config(&location, &mut conn) + .await + .unwrap() + .unwrap() + .rules; + + assert_eq!(generated_firewall_rules.len(), 2); + + let allow_rule = &generated_firewall_rules[0]; + assert_eq!(allow_rule.verdict, i32::from(FirewallPolicy::Allow)); + assert_eq!( + allow_rule.source_addrs, + [expected_ipv4_source_range_for_user( + ungrouped_allowed_user.id + )] + ); + assert_eq!( + allow_rule.destination_addrs, + [IpAddress { + address: Some(Address::IpSubnet("192.168.20.0/24".to_string())), + }] + ); + assert_eq!( + allow_rule.destination_ports, + [Port { + port: Some(PortInner::SinglePort(8443)), + }] + ); + assert_eq!(allow_rule.protocols, [i32::from(Protocol::Tcp)]); + for denied_user in [ + grouped_denied_user_a.id, + grouped_denied_user_b.id, + grouped_denied_user_c.id, + explicitly_denied_ungrouped_user.id, + ] { + assert!( + allow_rule + .source_addrs + .iter() + .all(|addr| addr != &expected_ipv4_source_range_for_user(denied_user)) + ); + } + + let deny_rule = &generated_firewall_rules[1]; + assert_eq!(deny_rule.verdict, i32::from(FirewallPolicy::Deny)); + assert!(deny_rule.source_addrs.is_empty()); + assert_eq!(deny_rule.destination_addrs, allow_rule.destination_addrs); + assert!(deny_rule.destination_ports.is_empty()); + assert!(deny_rule.protocols.is_empty()); +} + +#[sqlx::test] +async fn test_deny_all_groups_deduplicates_shared_group_members_before_source_filtering( + _: PgPoolOptions, + options: PgConnectOptions, +) { + set_test_license_business(); + let pool = setup_pool(options).await; + let mut rng = thread_rng(); + + let mut location = WireguardNetwork::default() + .set_address(["10.0.0.1/16".parse().unwrap()]) + .unwrap(); + location.acl_enabled = true; + let location = location.save(&pool).await.unwrap(); + + let shared_grouped_denied_user = + create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + let first_group_only_denied_user = + create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + let second_group_only_denied_user = + create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + let ungrouped_allowed_user = create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + let explicitly_denied_ungrouped_user = + create_test_user_with_devices(&mut rng, &pool, &[&location]).await; + + let first_group = Group { + name: "deny-all-groups-dedup-first".into(), + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + let second_group = Group { + name: "deny-all-groups-dedup-second".into(), + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + add_users_to_group( + &pool, + first_group.id, + &[&shared_grouped_denied_user, &first_group_only_denied_user], + ) + .await; + add_users_to_group( + &pool, + second_group.id, + &[&shared_grouped_denied_user, &second_group_only_denied_user], + ) + .await; + + let acl_rule = AclRule { + name: "deny all groups dedup source filtering".into(), + state: RuleState::Applied, + allow_all_users: true, + deny_all_groups: true, + addresses: vec!["192.168.40.0/24".parse().unwrap()], + ports: vec![PortRange::new(8443, 8443).into()], + protocols: vec![Protocol::Tcp.into()], + any_address: false, + any_port: false, + any_protocol: false, + use_manual_destination_settings: true, + ..Default::default() + }; + + let acl_rule_info = create_acl_rule( + &pool, + acl_rule, + vec![location.id], + Vec::new(), + vec![explicitly_denied_ungrouped_user.id], + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ) + .await; + + let mut conn = pool.acquire().await.unwrap(); + + let allowed_users = acl_rule_info + .get_all_allowed_users(&mut conn) + .await + .unwrap(); + let denied_users = acl_rule_info.get_all_denied_users(&mut conn).await.unwrap(); + + let mut denied_user_ids: Vec<_> = denied_users.iter().map(|user| user.id).collect(); + let mut expected_denied_user_ids = vec![ + shared_grouped_denied_user.id, + first_group_only_denied_user.id, + second_group_only_denied_user.id, + explicitly_denied_ungrouped_user.id, + ]; + denied_user_ids.sort_unstable(); + expected_denied_user_ids.sort_unstable(); + + assert_eq!(denied_user_ids, expected_denied_user_ids); + assert_eq!(denied_user_ids.len(), 4); + + let mut resolved_source_user_ids: Vec<_> = + super::get_source_users(allowed_users, &denied_users) + .into_iter() + .map(|user| user.id) + .collect(); + resolved_source_user_ids.sort_unstable(); + assert_eq!(resolved_source_user_ids, [ungrouped_allowed_user.id]); + + let generated_firewall_rules = try_get_location_firewall_config(&location, &mut conn) + .await + .unwrap() + .unwrap() + .rules; + + assert_eq!(generated_firewall_rules.len(), 2); + + let allow_rule = &generated_firewall_rules[0]; + assert_eq!(allow_rule.verdict, i32::from(FirewallPolicy::Allow)); + assert_eq!( + allow_rule.source_addrs, + [expected_ipv4_source_range_for_user( + ungrouped_allowed_user.id + )] + ); + assert_eq!( + allow_rule.destination_addrs, + [IpAddress { + address: Some(Address::IpSubnet("192.168.40.0/24".to_string())), + }] + ); + assert_eq!( + allow_rule.destination_ports, + [Port { + port: Some(PortInner::SinglePort(8443)), + }] + ); + assert_eq!(allow_rule.protocols, [i32::from(Protocol::Tcp)]); + for denied_user in expected_denied_user_ids { + assert!( + allow_rule + .source_addrs + .iter() + .all(|addr| addr != &expected_ipv4_source_range_for_user(denied_user)) + ); + } + + let deny_rule = &generated_firewall_rules[1]; + assert_eq!(deny_rule.verdict, i32::from(FirewallPolicy::Deny)); + assert!(deny_rule.source_addrs.is_empty()); + assert_eq!(deny_rule.destination_addrs, allow_rule.destination_addrs); + assert!(deny_rule.destination_ports.is_empty()); + assert!(deny_rule.protocols.is_empty()); +} + #[sqlx::test] async fn test_empty_manual_destination_only_acl(_: PgPoolOptions, options: PgConnectOptions) { set_test_license_business(); From 6bee2264f5a6ff9e8243a7ff5c30abc6c83c072f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 23 Mar 2026 09:47:54 +0100 Subject: [PATCH 3/8] display upgrade modal when accessing tokens tab --- .../UserProfilePage/UserProfilePage.tsx | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/web/src/pages/user-profile/UserProfilePage/UserProfilePage.tsx b/web/src/pages/user-profile/UserProfilePage/UserProfilePage.tsx index 2d1a0a0469..4e9f00c6d3 100644 --- a/web/src/pages/user-profile/UserProfilePage/UserProfilePage.tsx +++ b/web/src/pages/user-profile/UserProfilePage/UserProfilePage.tsx @@ -9,10 +9,12 @@ import { Tabs } from '../../../shared/defguard-ui/components/Tabs/Tabs'; import type { TabsItem } from '../../../shared/defguard-ui/components/Tabs/types'; import { useAuth } from '../../../shared/hooks/useAuth'; import { + getLicenseInfoQueryOptions, getUserApiTokensQueryOptions, getUserAuthKeysQueryOptions, userProfileQueryOptions, } from '../../../shared/query'; +import { canUseBusinessFeature, licenseActionCheck } from '../../../shared/utils/license'; import { createUserProfileStore, UserProfileContext } from './hooks/useUserProfilePage'; import { ProfileApiTokensTab } from './tabs/ProfileApiTokensTab/ProfileApiTokensTab'; import { ProfileAuthKeysTab } from './tabs/ProfileAuthKeysTab/ProfileAuthKeysTab'; @@ -40,8 +42,16 @@ export const UserProfilePage = () => { const { data: userProfile } = useSuspenseQuery(userProfileQueryOptions(username)); const { data: userAuthKeys } = useSuspenseQuery(getUserAuthKeysQueryOptions(username)); + const { data: licenseInfo } = useQuery(getLicenseInfoQueryOptions); + const hasBusinessFeaturesAccess = useMemo((): boolean => { + if (!isAdmin || licenseInfo === undefined) { + return false; + } + + return canUseBusinessFeature(licenseInfo).result; + }, [isAdmin, licenseInfo]); const { data: userApiTokens } = useQuery( - getUserApiTokensQueryOptions(username, isAdmin), + getUserApiTokensQueryOptions(username, isAdmin && hasBusinessFeaturesAccess), ); const pageTitle = useMemo(() => { @@ -75,6 +85,16 @@ export const UserProfilePage = () => { [navigate], ); + const setApiTokensTabIfAllowed = useCallback(() => { + if (!isAdmin || licenseInfo === undefined) { + return; + } + + licenseActionCheck(canUseBusinessFeature(licenseInfo), () => { + setActiveTab(UserProfileTab.ApiTokens); + }); + }, [isAdmin, licenseInfo, setActiveTab]); + const tabsConfiguration = useMemo(() => { const res: TabsItem[] = [ { @@ -96,11 +116,11 @@ export const UserProfilePage = () => { title: m.profile_tabs_api(), active: activeTab === UserProfileTab.ApiTokens, hidden: !isAdmin, - onClick: () => setActiveTab(UserProfileTab.ApiTokens), + onClick: setApiTokensTabIfAllowed, }, ]; return res; - }, [activeTab, setActiveTab, isAdmin]); + }, [isAdmin, setActiveTab, setApiTokensTabIfAllowed, activeTab]); const RenderActiveTab = useMemo(() => { switch (activeTab) { From 79105e14893b3638fa16cf0205d97030cedf7db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 23 Mar 2026 09:51:15 +0100 Subject: [PATCH 4/8] hide pending icon when license is not available --- web/src/shared/components/Navigation/Navigation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/shared/components/Navigation/Navigation.tsx b/web/src/shared/components/Navigation/Navigation.tsx index bf0eb46b74..f7cc59a092 100644 --- a/web/src/shared/components/Navigation/Navigation.tsx +++ b/web/src/shared/components/Navigation/Navigation.tsx @@ -277,7 +277,7 @@ const NavItem = ({ return false; }, [license, licenseTier]); - const showPending = isPresent(pendingCount) && pendingCount > 0; + const showPending = !showLock && isPresent(pendingCount) && pendingCount > 0; const showRight = showPending || (showLock && isPresent(licenseTier)); return ( From 8965a1b0762b6806c936851b03c9f99eb9ab8515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 23 Mar 2026 10:20:42 +0100 Subject: [PATCH 5/8] add missing upgrade modals in ACL tables --- .../AliasesPage/tabs/AliasesDeployedTab.tsx | 21 +++++++------------ .../AliasesPage/tabs/AliasesPendingTab.tsx | 9 +++++++- .../DestinationDeployedTab.tsx | 14 ++++++------- .../DestinationPendingTab.tsx | 14 +++++++++++-- web/src/pages/RulesPage/RulesTable.tsx | 13 +++++++++--- .../pages/RulesPage/tabs/RulesDeployedTab.tsx | 20 +++++++----------- 6 files changed, 53 insertions(+), 38 deletions(-) diff --git a/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx b/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx index 7d787b104a..5c7d496220 100644 --- a/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx +++ b/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx @@ -1,4 +1,4 @@ -import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { useMemo, useState } from 'react'; import { m } from '../../../paraglide/messages'; @@ -8,13 +8,10 @@ import type { ButtonProps } from '../../../shared/defguard-ui/components/Button/ import { EmptyStateFlexible } from '../../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; import { Search } from '../../../shared/defguard-ui/components/Search/Search'; import { TableTop } from '../../../shared/defguard-ui/components/table/TableTop/TableTop'; -import { - getAliasesQueryOptions, - getLicenseInfoQueryOptions, - getRulesQueryOptions, -} from '../../../shared/query'; +import { getAliasesQueryOptions, getRulesQueryOptions } from '../../../shared/query'; import { canUseBusinessFeature, licenseActionCheck } from '../../../shared/utils/license'; import { DeletionBlockedModal } from '../../Acl/components/DeletionBlockedModal/DeletionBlockedModal'; +import { useRuleDeps } from '../../RulesPage/useRuleDeps'; import { AliasTable } from '../AliasTable'; export const AliasesDeployedTab = () => { @@ -25,9 +22,7 @@ export const AliasesDeployedTab = () => { const isEmpty = aliases.length === 0; const navigate = useNavigate(); const [search, setSearch] = useState(''); - const { data: licenseInfo, isFetching: licenseFetching } = useQuery( - getLicenseInfoQueryOptions, - ); + const { license, loading } = useRuleDeps(); const { data: rules } = useSuspenseQuery(getRulesQueryOptions); const rulesByAliasId = useMemo(() => { const map: Record = {}; @@ -51,15 +46,15 @@ export const AliasesDeployedTab = () => { iconLeft: 'add-alias', variant: 'primary', testId: 'add-alias', - disabled: licenseFetching, + disabled: loading, onClick: () => { - if (licenseInfo === undefined) return; - licenseActionCheck(canUseBusinessFeature(licenseInfo), () => { + if (license === undefined) return; + licenseActionCheck(canUseBusinessFeature(license), () => { navigate({ to: '/acl/add-alias' }); }); }, }), - [navigate, licenseFetching, licenseInfo], + [navigate, loading, license], ); const filteredAliases = useMemo(() => { diff --git a/web/src/pages/AliasesPage/tabs/AliasesPendingTab.tsx b/web/src/pages/AliasesPage/tabs/AliasesPendingTab.tsx index 7465930ecc..93f10d2068 100644 --- a/web/src/pages/AliasesPage/tabs/AliasesPendingTab.tsx +++ b/web/src/pages/AliasesPage/tabs/AliasesPendingTab.tsx @@ -5,6 +5,8 @@ import { Button } from '../../../shared/defguard-ui/components/Button/Button'; import { EmptyStateFlexible } from '../../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; import { TableTop } from '../../../shared/defguard-ui/components/table/TableTop/TableTop'; import { getAliasesQueryOptions, getRulesQueryOptions } from '../../../shared/query'; +import { canUseBusinessFeature, licenseActionCheck } from '../../../shared/utils/license'; +import { useRuleDeps } from '../../RulesPage/useRuleDeps'; import { AliasTable } from '../AliasTable'; export const AliasesPendingTab = () => { @@ -12,6 +14,7 @@ export const AliasesPendingTab = () => { ...getAliasesQueryOptions, select: (resp) => resp.data.filter((alias) => alias.state !== AclStatus.Applied), }); + const { license, loading } = useRuleDeps(); const { data: rules } = useSuspenseQuery(getRulesQueryOptions); const isEmpty = aliases.length === 0; const { mutate: applyAliases, isPending } = useMutation({ @@ -39,8 +42,12 @@ export const AliasesPendingTab = () => { iconLeft="deploy" text={`Deploy all pending (${aliases.length})`} loading={isPending} + disabled={loading} onClick={() => { - applyAliases(aliases.map((alias) => alias.id)); + if (license === undefined) return; + licenseActionCheck(canUseBusinessFeature(license), () => { + applyAliases(aliases.map((alias) => alias.id)); + }); }} /> )} diff --git a/web/src/pages/DestinationsPage/tabs/DestinationDeployedTab/DestinationDeployedTab.tsx b/web/src/pages/DestinationsPage/tabs/DestinationDeployedTab/DestinationDeployedTab.tsx index 7280f98295..b25a644d78 100644 --- a/web/src/pages/DestinationsPage/tabs/DestinationDeployedTab/DestinationDeployedTab.tsx +++ b/web/src/pages/DestinationsPage/tabs/DestinationDeployedTab/DestinationDeployedTab.tsx @@ -1,4 +1,4 @@ -import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { useMemo } from 'react'; import { AclStatus } from '../../../../shared/api/types'; @@ -6,7 +6,6 @@ import type { ButtonProps } from '../../../../shared/defguard-ui/components/Butt import { EmptyStateFlexible } from '../../../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; import { getDestinationsQueryOptions, - getLicenseInfoQueryOptions, getRulesQueryOptions, } from '../../../../shared/query'; import { @@ -14,6 +13,7 @@ import { licenseActionCheck, } from '../../../../shared/utils/license'; import { DeletionBlockedModal } from '../../../Acl/components/DeletionBlockedModal/DeletionBlockedModal'; +import { useRuleDeps } from '../../../RulesPage/useRuleDeps'; import { DestinationsTable } from '../../components/DestinationsTable'; export const DestinationDeployedTab = () => { @@ -24,25 +24,25 @@ export const DestinationDeployedTab = () => { }); const navigate = useNavigate(); - const { data: licenseInfo, isFetching } = useQuery(getLicenseInfoQueryOptions); const { data: rules } = useSuspenseQuery(getRulesQueryOptions); + const { license, loading } = useRuleDeps(); const addButtonProps = useMemo( (): ButtonProps => ({ text: 'Add new destination', variant: 'primary', iconLeft: 'add-location', - disabled: isFetching, + disabled: loading, onClick: () => { - if (licenseInfo === undefined) return; - licenseActionCheck(canUseBusinessFeature(licenseInfo), () => { + if (license === undefined) return; + licenseActionCheck(canUseBusinessFeature(license), () => { navigate({ to: '/acl/add-destination', }); }); }, }), - [navigate, isFetching, licenseInfo], + [navigate, loading, license], ); return ( diff --git a/web/src/pages/DestinationsPage/tabs/DestinationPendingTab/DestinationPendingTab.tsx b/web/src/pages/DestinationsPage/tabs/DestinationPendingTab/DestinationPendingTab.tsx index 5aff03f7ce..902e2dbe26 100644 --- a/web/src/pages/DestinationsPage/tabs/DestinationPendingTab/DestinationPendingTab.tsx +++ b/web/src/pages/DestinationsPage/tabs/DestinationPendingTab/DestinationPendingTab.tsx @@ -8,6 +8,11 @@ import { getDestinationsQueryOptions, getRulesQueryOptions, } from '../../../../shared/query'; +import { + canUseBusinessFeature, + licenseActionCheck, +} from '../../../../shared/utils/license'; +import { useRuleDeps } from '../../../RulesPage/useRuleDeps'; import { DestinationsTable } from '../../components/DestinationsTable'; export const DestinationPendingTab = () => { @@ -17,6 +22,7 @@ export const DestinationPendingTab = () => { resp.data.filter((destination) => destination.state !== AclStatus.Applied), }); const { data: rules } = useSuspenseQuery(getRulesQueryOptions); + const { license, loading } = useRuleDeps(); const { mutate, isPending } = useMutation({ mutationFn: api.acl.destination.applyDestinations, @@ -30,11 +36,15 @@ export const DestinationPendingTab = () => { text: `Deploy all pending (${destinations.length})`, iconLeft: 'deploy', loading: isPending, + disabled: loading, onClick: () => { - mutate(destinations.map((destination) => destination.id)); + if (license === undefined) return; + licenseActionCheck(canUseBusinessFeature(license), () => { + mutate(destinations.map((destination) => destination.id)); + }); }, }), - [isPending, mutate, destinations], + [isPending, mutate, destinations, license, loading], ); return ( diff --git a/web/src/pages/RulesPage/RulesTable.tsx b/web/src/pages/RulesPage/RulesTable.tsx index fb4af4a27d..2a17257999 100644 --- a/web/src/pages/RulesPage/RulesTable.tsx +++ b/web/src/pages/RulesPage/RulesTable.tsx @@ -352,7 +352,9 @@ export const RulesTable = ({ icon: 'disabled', text: m.controls_disable(), onClick: () => { - toggleRule(row.id); + licenseActionCheck(canUseBusinessFeature(license), () => { + toggleRule(row.id); + }); }, }); } else { @@ -360,7 +362,9 @@ export const RulesTable = ({ icon: 'check', text: m.controls_enable(), onClick: () => { - toggleRule(row.id); + licenseActionCheck(canUseBusinessFeature(license), () => { + toggleRule(row.id); + }); }, }); } @@ -369,8 +373,11 @@ export const RulesTable = ({ topItems.push({ icon: 'deploy', text: m.controls_deploy(), + onClick: () => { - deployRule([row.id]); + licenseActionCheck(canUseBusinessFeature(license), () => { + deployRule([row.id]); + }); }, }); break; diff --git a/web/src/pages/RulesPage/tabs/RulesDeployedTab.tsx b/web/src/pages/RulesPage/tabs/RulesDeployedTab.tsx index 96305ca4c7..0990d80f51 100644 --- a/web/src/pages/RulesPage/tabs/RulesDeployedTab.tsx +++ b/web/src/pages/RulesPage/tabs/RulesDeployedTab.tsx @@ -1,4 +1,4 @@ -import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { useMemo } from 'react'; import { AclStatus } from '../../../shared/api/types'; @@ -6,7 +6,7 @@ import { TableSkeleton } from '../../../shared/components/skeleton/TableSkeleton import type { ButtonProps } from '../../../shared/defguard-ui/components/Button/types'; import { EmptyStateFlexible } from '../../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; -import { getLicenseInfoQueryOptions, getRulesQueryOptions } from '../../../shared/query'; +import { getRulesQueryOptions } from '../../../shared/query'; import { canUseBusinessFeature, licenseActionCheck } from '../../../shared/utils/license'; import { RulesTable } from '../RulesTable'; import { useRuleDeps } from '../useRuleDeps'; @@ -21,30 +21,26 @@ export const RulesDeployedTab = () => { const navigate = useNavigate(); - const { data: licenseInfo, isFetching: licenseFetching } = useQuery( - getLicenseInfoQueryOptions, - ); + const { aliases, destinations, groups, locations, users, devices, license, loading } = + useRuleDeps(); const buttonProps = useMemo( (): ButtonProps => ({ variant: 'primary', text: 'Create new rule', iconLeft: 'add-rule', - disabled: licenseFetching, + disabled: loading, onClick: () => { - if (licenseInfo === undefined) return; + if (license === undefined) return; - licenseActionCheck(canUseBusinessFeature(licenseInfo), () => { + licenseActionCheck(canUseBusinessFeature(license), () => { navigate({ to: '/acl/add-rule' }); }); }, }), - [navigate, licenseFetching, licenseInfo], + [navigate, loading, license], ); - const { aliases, destinations, groups, locations, users, devices, license, loading } = - useRuleDeps(); - return ( <> {isEmpty && ( From ad17e7aca62ffb8534aaef1cee87a8a1b1b5a48f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 23 Mar 2026 10:23:32 +0100 Subject: [PATCH 6/8] add missing single-destination deploy action --- .../components/DestinationsTable.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/web/src/pages/DestinationsPage/components/DestinationsTable.tsx b/web/src/pages/DestinationsPage/components/DestinationsTable.tsx index 91579c5003..0e335b5409 100644 --- a/web/src/pages/DestinationsPage/components/DestinationsTable.tsx +++ b/web/src/pages/DestinationsPage/components/DestinationsTable.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { createColumnHelper, @@ -7,6 +7,7 @@ import { } from '@tanstack/react-table'; import { useMemo, useState } from 'react'; import { m } from '../../../paraglide/messages'; +import api from '../../../shared/api/api'; import { type AclDestination, AclProtocolName, @@ -71,6 +72,12 @@ export const DestinationsTable = ({ getLicenseInfoQueryOptions, ); + const { mutate: applyDestinations } = useMutation({ + mutationFn: api.acl.destination.applyDestinations, + meta: { + invalidate: ['acl'], + }, + }); const columns = useMemo( () => [ columnHelper.accessor('name', { @@ -207,6 +214,18 @@ export const DestinationsTable = ({ ], }, ]; + if (row.state === 'Modified') { + menuItems[0].items.splice(1, 0, { + text: 'Deploy', + icon: 'deploy', + onClick: () => { + if (licenseInfo === undefined) return; + licenseActionCheck(canUseBusinessFeature(licenseInfo), () => { + applyDestinations([row.id]); + }); + }, + }); + } return ; }, }), @@ -218,6 +237,7 @@ export const DestinationsTable = ({ licenseFetching, licenseInfo, disableBlockedModal, + applyDestinations, ], ); From 1977020c9abffa6cca3181b7e47974a4073ea26c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 23 Mar 2026 11:23:55 +0100 Subject: [PATCH 7/8] show empty page instead of upgrade modal --- web/messages/en/profile.json | 13 +-- .../UserProfilePage/UserProfilePage.tsx | 87 ++++++++++++------- .../ProfileApiTokensTab.tsx | 43 +++++++-- .../ProfileApiTokensTable.tsx | 2 +- .../UserProfilePage/tabs/types.ts | 10 +++ 5 files changed, 113 insertions(+), 42 deletions(-) diff --git a/web/messages/en/profile.json b/web/messages/en/profile.json index 3aba70509c..2451ba7c68 100644 --- a/web/messages/en/profile.json +++ b/web/messages/en/profile.json @@ -48,9 +48,12 @@ "profile_auth_keys_table_col_name": "Key name", "profile_auth_keys_table_menu_download_ssh": "Download SSH key", "profile_auth_keys_table_menu_download_gpg": "Download GPG key", - "profile_api_title": "API tokens", - "profile_api_add": "Add new API token", - "profile_api_empty_title": "You don't have any API tokens.", - "profile_api_empty_subtitle": "To add one, click the button below.", - "profile_api_col_name": "Token name" + "profile_api_tokens_title": "API tokens", + "profile_api_tokens_add": "Add new API token", + "profile_api_tokens_loading_title": "Checking API token availability...", + "profile_api_tokens_empty_title": "You don't have any API tokens.", + "profile_api_tokens_empty_subtitle": "To add one, click the button below.", + "profile_api_tokens_unavailable_title": "API Tokens Unavailable", + "profile_api_tokens_unavailable_subtitle": "Upgrade to the Business or Enterprise plan to enable API tokens and add one.", + "profile_api_tokens_col_name": "Token name" } diff --git a/web/src/pages/user-profile/UserProfilePage/UserProfilePage.tsx b/web/src/pages/user-profile/UserProfilePage/UserProfilePage.tsx index 4e9f00c6d3..3bacf32a7f 100644 --- a/web/src/pages/user-profile/UserProfilePage/UserProfilePage.tsx +++ b/web/src/pages/user-profile/UserProfilePage/UserProfilePage.tsx @@ -14,13 +14,18 @@ import { getUserAuthKeysQueryOptions, userProfileQueryOptions, } from '../../../shared/query'; -import { canUseBusinessFeature, licenseActionCheck } from '../../../shared/utils/license'; +import { canUseBusinessFeature } from '../../../shared/utils/license'; import { createUserProfileStore, UserProfileContext } from './hooks/useUserProfilePage'; import { ProfileApiTokensTab } from './tabs/ProfileApiTokensTab/ProfileApiTokensTab'; import { ProfileAuthKeysTab } from './tabs/ProfileAuthKeysTab/ProfileAuthKeysTab'; import { ProfileDetailsTab } from './tabs/ProfileDetailsTab/ProfileDetailsTab'; import { ProfileDevicesTab } from './tabs/ProfileDevicesTab/ProfileDevicesTab'; -import { UserProfileTab, type UserProfileTabValue } from './tabs/types'; +import { + ApiTokensTabAvailability, + type ApiTokensTabAvailabilityValue, + UserProfileTab, + type UserProfileTabValue, +} from './tabs/types'; const defaultTab = UserProfileTab.Details; @@ -43,15 +48,24 @@ export const UserProfilePage = () => { const { data: userProfile } = useSuspenseQuery(userProfileQueryOptions(username)); const { data: userAuthKeys } = useSuspenseQuery(getUserAuthKeysQueryOptions(username)); const { data: licenseInfo } = useQuery(getLicenseInfoQueryOptions); - const hasBusinessFeaturesAccess = useMemo((): boolean => { - if (!isAdmin || licenseInfo === undefined) { - return false; + const apiTokensTabAvailability = useMemo((): ApiTokensTabAvailabilityValue => { + if (!isAdmin) { + return ApiTokensTabAvailability.Hidden; + } + + if (licenseInfo === undefined) { + return ApiTokensTabAvailability.Loading; } - return canUseBusinessFeature(licenseInfo).result; + return canUseBusinessFeature(licenseInfo).result + ? ApiTokensTabAvailability.Available + : ApiTokensTabAvailability.Unavailable; }, [isAdmin, licenseInfo]); - const { data: userApiTokens } = useQuery( - getUserApiTokensQueryOptions(username, isAdmin && hasBusinessFeaturesAccess), + const { data: userApiTokens, isPending: userApiTokensPending } = useQuery( + getUserApiTokensQueryOptions( + username, + apiTokensTabAvailability === ApiTokensTabAvailability.Available, + ), ); const pageTitle = useMemo(() => { @@ -86,14 +100,12 @@ export const UserProfilePage = () => { ); const setApiTokensTabIfAllowed = useCallback(() => { - if (!isAdmin || licenseInfo === undefined) { + if (apiTokensTabAvailability === ApiTokensTabAvailability.Hidden) { return; } - licenseActionCheck(canUseBusinessFeature(licenseInfo), () => { - setActiveTab(UserProfileTab.ApiTokens); - }); - }, [isAdmin, licenseInfo, setActiveTab]); + setActiveTab(UserProfileTab.ApiTokens); + }, [apiTokensTabAvailability, setActiveTab]); const tabsConfiguration = useMemo(() => { const res: TabsItem[] = [ @@ -115,36 +127,51 @@ export const UserProfilePage = () => { { title: m.profile_tabs_api(), active: activeTab === UserProfileTab.ApiTokens, - hidden: !isAdmin, + hidden: apiTokensTabAvailability === ApiTokensTabAvailability.Hidden, onClick: setApiTokensTabIfAllowed, }, ]; return res; - }, [isAdmin, setActiveTab, setApiTokensTabIfAllowed, activeTab]); + }, [apiTokensTabAvailability, setActiveTab, setApiTokensTabIfAllowed, activeTab]); - const RenderActiveTab = useMemo(() => { + const activeTabContent = useMemo(() => { switch (activeTab) { case UserProfileTab.Details: - return ProfileDetailsTab; + return ; case UserProfileTab.Devices: - return ProfileDevicesTab; + return ; case UserProfileTab.AuthKeys: - return ProfileAuthKeysTab; + return ; case UserProfileTab.ApiTokens: - return ProfileApiTokensTab; + return ( + + ); + default: + return ; } - }, [activeTab]); + }, [activeTab, apiTokensTabAvailability, userApiTokensPending]); useEffect(() => { - if (activeTab === 'api-tokens' && !isAdmin) { - navigate({ - from: '/user/$username', - search: { - tab: 'details', - }, - }); + if ( + activeTab !== UserProfileTab.ApiTokens || + apiTokensTabAvailability !== ApiTokensTabAvailability.Hidden + ) { + return; } - }, [activeTab, isAdmin, navigate]); + + navigate({ + from: '/user/$username', + search: { + tab: UserProfileTab.Details, + }, + }); + }, [activeTab, apiTokensTabAvailability, navigate]); // biome-ignore lint/correctness/useExhaustiveDependencies: side effect useEffect(() => { @@ -177,7 +204,7 @@ export const UserProfilePage = () => { - + {activeTabContent} ); diff --git a/web/src/pages/user-profile/UserProfilePage/tabs/ProfileApiTokensTab/ProfileApiTokensTab.tsx b/web/src/pages/user-profile/UserProfilePage/tabs/ProfileApiTokensTab/ProfileApiTokensTab.tsx index e8d64ae8b4..67aae645da 100644 --- a/web/src/pages/user-profile/UserProfilePage/tabs/ProfileApiTokensTab/ProfileApiTokensTab.tsx +++ b/web/src/pages/user-profile/UserProfilePage/tabs/ProfileApiTokensTab/ProfileApiTokensTab.tsx @@ -8,24 +8,55 @@ import { openModal } from '../../../../../shared/hooks/modalControls/modalsSubje import { ModalName } from '../../../../../shared/hooks/modalControls/modalTypes'; import { ProfileTabHeader } from '../../components/ProfileTabHeader/ProfileTabHeader'; import { useUserProfile } from '../../hooks/useUserProfilePage'; +import { ApiTokensTabAvailability, type ApiTokensTabAvailabilityValue } from '../types'; import { ProfileApiTokensTable } from './components/ProfileApiTokensTable/ProfileApiTokensTable'; import { AddApiTokenModal } from './modals/AddApiTokenModal/AddApiTokenModal'; import { RenameApiTokenModal } from './modals/RenameApiTokenModal/RenameApiTokenModal'; -export const ProfileApiTokensTab = () => { +type Props = { + availability: ApiTokensTabAvailabilityValue; + isLoading: boolean; +}; + +export const ProfileApiTokensTab = ({ availability, isLoading }: Props) => { + if (availability === ApiTokensTabAvailability.Hidden) { + return null; + } + + if (availability === ApiTokensTabAvailability.Loading || isLoading) { + return ( + + ); + } + + if (availability === ApiTokensTabAvailability.Unavailable) { + return ( + + ); + } + + return ; +}; + +const AvailableProfileApiTokensTab = () => { const username = useUserProfile((s) => s.user.username); const apiTokens = useUserProfile((s) => s.apiTokens); + return ( <> {apiTokens.length === 0 && ( { openModal(ModalName.AddApiToken, { username, @@ -37,9 +68,9 @@ export const ProfileApiTokensTab = () => { {apiTokens.length > 0 && ( - +