diff --git a/.sqlx/query-9a0b4836c2a451e6a0a91b934dbc778ac069bc3aef805503a384b218bc6a2f0c.json b/.sqlx/query-9a0b4836c2a451e6a0a91b934dbc778ac069bc3aef805503a384b218bc6a2f0c.json new file mode 100644 index 000000000..524e1e23e --- /dev/null +++ b/.sqlx/query-9a0b4836c2a451e6a0a91b934dbc778ac069bc3aef805503a384b218bc6a2f0c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM wireguard_network_device WHERE wireguard_network_id = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "9a0b4836c2a451e6a0a91b934dbc778ac069bc3aef805503a384b218bc6a2f0c" +} diff --git a/crates/defguard_common/src/db/models/device.rs b/crates/defguard_common/src/db/models/device.rs index 51e5ef765..e1a143032 100644 --- a/crates/defguard_common/src/db/models/device.rs +++ b/crates/defguard_common/src/db/models/device.rs @@ -333,7 +333,7 @@ impl WireguardNetworkDevice { &self.ips_as_network(), self.is_authorized, self.authorized_at, - self.preshared_key + self.preshared_key, ) .execute(executor) .await?; @@ -511,6 +511,25 @@ impl WireguardNetworkDevice { .fetch_one(executor) .await } + + /// Check if any device is assigned to a given network. + pub async fn has_devices_in_network<'e, E>( + executor: E, + network_id: Id, + ) -> Result + where + E: PgExecutor<'e>, + { + let result = query_scalar!( + "SELECT EXISTS(SELECT 1 FROM wireguard_network_device \ + WHERE wireguard_network_id = $1)", + network_id + ) + .fetch_one(executor) + .await?; + + Ok(result.unwrap_or(false)) + } } #[derive(Debug, Error)] diff --git a/crates/defguard_core/src/enterprise/handlers/openid_login.rs b/crates/defguard_core/src/enterprise/handlers/openid_login.rs index 3c2e41dc2..a7078488a 100644 --- a/crates/defguard_core/src/enterprise/handlers/openid_login.rs +++ b/crates/defguard_core/src/enterprise/handlers/openid_login.rs @@ -672,6 +672,7 @@ pub(crate) async fn auth_callback( #[cfg(test)] mod test { + use super::*; use crate::{ enterprise::{ license::{License, LicenseTier, set_cached_license}, @@ -680,8 +681,6 @@ mod test { grpc::proto::enterprise::license::LicenseLimits, }; - use super::*; - #[test] fn test_prune_username() { // Test RemoveForbidden handling diff --git a/crates/defguard_core/src/enterprise/ldap/tests.rs b/crates/defguard_core/src/enterprise/ldap/tests.rs index ed342fcb1..dbb46c008 100644 --- a/crates/defguard_core/src/enterprise/ldap/tests.rs +++ b/crates/defguard_core/src/enterprise/ldap/tests.rs @@ -5,19 +5,21 @@ use ldap3::SearchEntry; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::*; -use crate::enterprise::license::{License, LicenseTier, set_cached_license}; -use crate::enterprise::{ - ldap::{ - model::{extract_rdn_value, get_users_without_ldap_path, user_from_searchentry}, - sync::{ - Authority, compute_group_sync_changes, compute_user_sync_changes, - extract_intersecting_users, +use crate::{ + enterprise::{ + ldap::{ + model::{extract_rdn_value, get_users_without_ldap_path, user_from_searchentry}, + sync::{ + Authority, compute_group_sync_changes, compute_user_sync_changes, + extract_intersecting_users, + }, + test_client::{LdapEvent, group_to_test_attrs, user_to_test_attrs}, }, - test_client::{LdapEvent, group_to_test_attrs, user_to_test_attrs}, + license::{License, LicenseTier, set_cached_license}, + limits::get_counts, }, - limits::get_counts, + grpc::proto::enterprise::license::LicenseLimits, }; -use crate::grpc::proto::enterprise::license::LicenseLimits; const PASSWORD: &str = "test_password"; diff --git a/crates/defguard_core/src/handlers/gateway.rs b/crates/defguard_core/src/handlers/gateway.rs index e5c8425c1..fd3a488db 100644 --- a/crates/defguard_core/src/handlers/gateway.rs +++ b/crates/defguard_core/src/handlers/gateway.rs @@ -1,7 +1,6 @@ use axum::{ Json, - extract::rejection::JsonRejection, - extract::{Path, State}, + extract::{Path, State, rejection::JsonRejection}, }; use chrono::NaiveDateTime; use defguard_common::db::{Id, models::gateway::Gateway}; diff --git a/crates/defguard_core/src/handlers/wireguard.rs b/crates/defguard_core/src/handlers/wireguard.rs index 90846c2a2..3a8adda26 100644 --- a/crates/defguard_core/src/handlers/wireguard.rs +++ b/crates/defguard_core/src/handlers/wireguard.rs @@ -50,6 +50,7 @@ pub(crate) struct WireguardNetworkInfo { network: WireguardNetwork, gateways: Vec, allowed_groups: Vec, + has_devices: bool, } #[derive(Deserialize, Serialize, ToSchema)] @@ -325,8 +326,20 @@ pub(crate) async fn modify_network( let mut network = find_network(network_id, &appstate.pool).await?; // store network before mods let before = network.clone(); - network.address = data.parse_addresses()?; + let new_addresses = data.parse_addresses()?; + // Block network address changes if any device is assigned to the network + if before.address != new_addresses + && WireguardNetworkDevice::has_devices_in_network(&appstate.pool, network_id).await? + { + return Err(WebError::BadRequest( + "Cannot change network address while devices are assigned to this network. \ + Remove all devices first." + .into(), + )); + } + + network.address = new_addresses; network.allowed_ips = data.parse_allowed_ips(); network.name = data.name; @@ -473,10 +486,13 @@ pub(crate) async fn list_networks(_role: AdminRole, State(appstate): State { let allowed_groups = network.fetch_allowed_groups(&appstate.pool).await?; let gateways = GatewayInfo::find_by_location_id(&appstate.pool, network_id).await?; + let has_devices = + WireguardNetworkDevice::has_devices_in_network(&appstate.pool, network_id).await?; let network_info = WireguardNetworkInfo { network, gateways, allowed_groups, + has_devices, }; ApiResponse::json(network_info, StatusCode::OK) } diff --git a/crates/defguard_core/tests/integration/api/wireguard.rs b/crates/defguard_core/tests/integration/api/wireguard.rs index 396a9ea75..ec2591b82 100644 --- a/crates/defguard_core/tests/integration/api/wireguard.rs +++ b/crates/defguard_core/tests/integration/api/wireguard.rs @@ -486,7 +486,8 @@ async fn test_network_address_reassignment(_: PgPoolOptions, options: PgConnectO // network details let response = client.get("/api/v1/network/1").send().await; assert_eq!(response.status(), StatusCode::OK); - let network_from_details: WireguardNetwork = response.json().await; + let network_details: serde_json::Value = response.json().await; + let network_id = network_details["id"].as_i64().unwrap(); // create devices let device = json!({ @@ -499,6 +500,8 @@ async fn test_network_address_reassignment(_: PgPoolOptions, options: PgConnectO .send() .await; assert_eq!(response.status(), StatusCode::CREATED); + let device1: serde_json::Value = response.json().await; + let device1_id = device1["device"]["id"].as_i64().unwrap(); let device = json!({ "name": "device2", "wireguard_pubkey": "ZqDlG4LQZRO9v57Sd27AHdtTLxegbMp5oVThjYrg21I=", @@ -509,9 +512,11 @@ async fn test_network_address_reassignment(_: PgPoolOptions, options: PgConnectO .send() .await; assert_eq!(response.status(), StatusCode::CREATED); + let device2: serde_json::Value = response.json().await; + let device2_id = device2["device"]["id"].as_i64().unwrap(); // ensure IPs were assigned for new devices - let network_devices = WireguardNetworkDevice::find_by_device(&client_state.pool, 1) + let network_devices = WireguardNetworkDevice::find_by_device(&client_state.pool, device1_id) .await .unwrap() .unwrap(); @@ -519,7 +524,7 @@ async fn test_network_address_reassignment(_: PgPoolOptions, options: PgConnectO network_devices[0].wireguard_ips, vec![IpAddr::V4(Ipv4Addr::new(10, 1, 1, 2))], ); - let network_devices = WireguardNetworkDevice::find_by_device(&client_state.pool, 2) + let network_devices = WireguardNetworkDevice::find_by_device(&client_state.pool, device2_id) .await .unwrap() .unwrap(); @@ -528,13 +533,9 @@ async fn test_network_address_reassignment(_: PgPoolOptions, options: PgConnectO vec![IpAddr::V4(Ipv4Addr::new(10, 1, 1, 3))], ); - // delete the first device - let response = client.delete("/api/v1/device/1").json(&device).send().await; - assert_eq!(response.status(), StatusCode::OK); - - // modify network addresses + // trying to modify network addresses while devices exist should fail let network = json!({ - "id": network_from_details.id, + "id": network_id, "name": "network", "address": "10.1.1.1/24,fc00::1/112", "port": 55555, @@ -552,21 +553,54 @@ async fn test_network_address_reassignment(_: PgPoolOptions, options: PgConnectO "service_location_mode": "disabled" }); let response = client - .put(format!("/api/v1/network/{}", network_from_details.id)) + .put(format!("/api/v1/network/{network_id}")) + .json(&network) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // delete both devices + let response = client + .delete(format!("/api/v1/device/{device1_id}")) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let response = client + .delete(format!("/api/v1/device/{device2_id}")) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // now modify network addresses should succeed + let response = client + .put(format!("/api/v1/network/{network_id}")) .json(&network) .send() .await; assert_eq!(response.status(), StatusCode::OK); - // ensure IPv4 address wasn't reassigned - let network_devices = WireguardNetworkDevice::find_by_device(&client_state.pool, 2) + // re-create a device and verify it gets IPs in both subnets + let device = json!({ + "name": "device3", + "wireguard_pubkey": "o/8q3kmv5nnbrcb/7aceQWGE44a0yI707wObXRyyWGU=", + }); + let response = client + .post("/api/v1/device/admin") + .json(&device) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let device3: serde_json::Value = response.json().await; + let device3_id = device3["device"]["id"].as_i64().unwrap(); + + let network_devices = WireguardNetworkDevice::find_by_device(&client_state.pool, device3_id) .await .unwrap() .unwrap(); assert_eq!( network_devices[0].wireguard_ips, vec![ - IpAddr::V4(Ipv4Addr::new(10, 1, 1, 3)), + IpAddr::V4(Ipv4Addr::new(10, 1, 1, 2)), IpAddr::V6(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 2)), ], ); @@ -834,43 +868,11 @@ async fn test_network_size_validation(_: PgPoolOptions, options: PgConnectOption assert_eq!(response.status(), StatusCode::OK); let network_from_details: WireguardNetwork = response.json().await; - // create devices - let device = json!({ - "name": "device1", - "wireguard_pubkey": "LQKsT6/3HWKuJmMulH63R8iK+5sI8FyYEL6WDIi6lQU=", - }); - let response = client - .post("/api/v1/device/admin") - .json(&device) - .send() - .await; - assert_eq!(response.status(), StatusCode::CREATED); - let device = json!({ - "name": "device2", - "wireguard_pubkey": "ZqDlG4LQZRO9v57Sd27AHdtTLxegbMp5oVThjYrg21I=", - }); - let response = client - .post("/api/v1/device/admin") - .json(&device) - .send() - .await; - assert_eq!(response.status(), StatusCode::CREATED); - let device = json!({ - "name": "device3", - "wireguard_pubkey": "o/8q3kmv5nnbrcb/7aceQWGE44a0yI707wObXRyyWGU=", - }); - let response = client - .post("/api/v1/device/admin") - .json(&device) - .send() - .await; - assert_eq!(response.status(), StatusCode::CREATED); - - // try to add subnet with not enough IPs + // try to add subnet with invalid mask (/0) let network = json!({ "id": network_from_details.id, "name": "network", - "address": "10.1.1.1/24,10.2.1.1/30", + "address": "10.2.0.1/24,10.1.1.1/0", "port": 55555, "endpoint": "192.168.4.14", "allowed_ips": "10.1.1.0/24", @@ -892,11 +894,11 @@ async fn test_network_size_validation(_: PgPoolOptions, options: PgConnectOption .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); - // try to add subnet with invalid mask + // try to add no network (empty address) let network = json!({ "id": network_from_details.id, "name": "network", - "address": "10.2.0.1/24,10.1.1.1/0", + "address": "", "port": 55555, "endpoint": "192.168.4.14", "allowed_ips": "10.1.1.0/24", @@ -917,15 +919,43 @@ async fn test_network_size_validation(_: PgPoolOptions, options: PgConnectOption .send() .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} - // try to add no network - let network = json!({ - "id": network_from_details.id, +/// Test that modifying a network's address is blocked when any devices are assigned. +/// Also verifies that non-address modifications still succeed. +#[sqlx::test] +async fn test_modify_network_blocked_by_devices(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (client, _client_state) = make_test_client(pool).await; + + let auth = Auth::new("admin", "pass123"); + let response = &client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // create network + let response = make_network(&client, "network").await; + let network: WireguardNetwork = response.json().await; + + // create a device for the admin user — it gets auto-assigned to the network + let device = json!({ + "name": "device1", + "wireguard_pubkey": "LQKsT6/3HWKuJmMulH63R8iK+5sI8FyYEL6WDIi6lQU=", + }); + let response = client + .post("/api/v1/device/admin") + .json(&device) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // try to modify the network address — should be rejected because a device exists + let modified = json!({ "name": "network", - "address": "", + "address": "10.2.2.1/24", "port": 55555, "endpoint": "192.168.4.14", - "allowed_ips": "10.1.1.0/24", + "allowed_ips": "10.2.2.0/24", "dns": "1.1.1.1", "mtu": 1420, "fwmark": 0, @@ -938,9 +968,37 @@ async fn test_network_size_validation(_: PgPoolOptions, options: PgConnectOption "service_location_mode": "disabled" }); let response = client - .put(format!("/api/v1/network/{}", network_from_details.id)) - .json(&network) + .put(format!("/api/v1/network/{}", network.id)) + .json(&modified) .send() .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let body: serde_json::Value = response.json().await; + assert!(body["msg"].as_str().is_some()); + + // verify that modifying other fields (not address) still works + let modified_name_only = json!({ + "name": "renamed-network", + "address": "10.1.1.1/24", + "port": 55555, + "endpoint": "192.168.4.14", + "allowed_ips": "10.1.1.0/24", + "dns": "1.1.1.1", + "mtu": 1420, + "fwmark": 0, + "allowed_groups": [], + "keepalive_interval": 25, + "peer_disconnect_threshold": 300, + "acl_enabled": false, + "acl_default_allow": false, + "location_mfa_mode": "disabled", + "service_location_mode": "disabled" + }); + let response = client + .put(format!("/api/v1/network/{}", network.id)) + .json(&modified_name_only) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); } diff --git a/crates/defguard_core/tests/integration/common.rs b/crates/defguard_core/tests/integration/common.rs index f043201d2..2310f1135 100644 --- a/crates/defguard_core/tests/integration/common.rs +++ b/crates/defguard_core/tests/integration/common.rs @@ -6,7 +6,7 @@ use defguard_common::{ }, }; use defguard_core::enterprise::license::{License, LicenseTier, set_cached_license}; -use secrecy::ExposeSecret; +use secrecy::{ExposeSecret, SecretString}; use sqlx::PgPool; fn set_test_license_business() { @@ -29,6 +29,7 @@ pub(crate) async fn init_config( ) -> DefGuardConfig { let url = custom_defguard_url.unwrap_or("http://localhost:8000"); let mut config = DefGuardConfig::new_test_config(); + config.default_admin_password = SecretString::new("pass123".into()); initialize_current_settings(pool) .await .expect("Could not initialize current settings in the database"); diff --git a/web/messages/en/location.json b/web/messages/en/location.json index 9f9789e9f..7b7bd2fd0 100644 --- a/web/messages/en/location.json +++ b/web/messages/en/location.json @@ -1,5 +1,7 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "location_delete_success": "Location deleted", - "location_delete_failed": "Failed to delete location" + "location_delete_failed": "Failed to delete location", + "location_edit_failed": "Failed to update location", + "location_edit_failed_has_devices": "Gateway VPN IP address and netmask can’t be changed while devices are active on this network. To proceed, remove all devices from the current network first." } diff --git a/web/src/pages/EditLocationPage/EditLocationPage.tsx b/web/src/pages/EditLocationPage/EditLocationPage.tsx index 2daeb2b3a..5cff622cf 100644 --- a/web/src/pages/EditLocationPage/EditLocationPage.tsx +++ b/web/src/pages/EditLocationPage/EditLocationPage.tsx @@ -18,6 +18,7 @@ import { EditPageFormSection } from '../../shared/components/EditPageFormSection import type { SelectionOption } from '../../shared/components/SelectionSection/type'; import { InfoBanner } from '../../shared/defguard-ui/components/InfoBanner/InfoBanner'; import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { Snackbar } from '../../shared/defguard-ui/providers/snackbar/snackbar'; import { ThemeSpacing } from '../../shared/defguard-ui/types'; import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; import { useAppForm } from '../../shared/form'; @@ -122,6 +123,9 @@ const EditLocationForm = ({ location }: { location: NetworkLocation }) => { replace: true, }); }, + onError: () => { + Snackbar.error(m.location_edit_failed()); + }, }); const { mutate: deleteLocation, isPending: deletePending } = useMutation({ @@ -205,9 +209,23 @@ const EditLocationForm = ({ location }: { location: NetworkLocation }) => { + {location.has_devices && ( + <> + + + + )} {(field) => ( - + )} diff --git a/web/src/pages/NetworkDevicesPage/NetworkDevicesTable.tsx b/web/src/pages/NetworkDevicesPage/NetworkDevicesTable.tsx index ab9309f09..0efb21007 100644 --- a/web/src/pages/NetworkDevicesPage/NetworkDevicesTable.tsx +++ b/web/src/pages/NetworkDevicesPage/NetworkDevicesTable.tsx @@ -46,7 +46,7 @@ export const NetworkDevicesTable = ({ networkDevices }: Props) => { const { mutate: deleteDevice } = useMutation({ mutationFn: api.network_device.deleteDevice, meta: { - invalidate: ['device', 'network'], + invalidate: [['device', 'network'], ['network']], }, }); diff --git a/web/src/pages/NetworkDevicesPage/modals/AddNetworkDeviceModal/AddNetworkDeviceModal.tsx b/web/src/pages/NetworkDevicesPage/modals/AddNetworkDeviceModal/AddNetworkDeviceModal.tsx index af41a550e..4e33f9e45 100644 --- a/web/src/pages/NetworkDevicesPage/modals/AddNetworkDeviceModal/AddNetworkDeviceModal.tsx +++ b/web/src/pages/NetworkDevicesPage/modals/AddNetworkDeviceModal/AddNetworkDeviceModal.tsx @@ -271,7 +271,7 @@ type SubmitError = { }; const mutationMeta = { - invalidate: ['device', 'network'], + invalidate: [['device', 'network'], ['network']], }; const FormStep = ({ diff --git a/web/src/pages/NetworkDevicesPage/modals/EditNetworkDeviceModal/EditNetworkDeviceModal.tsx b/web/src/pages/NetworkDevicesPage/modals/EditNetworkDeviceModal/EditNetworkDeviceModal.tsx index 61b87bff4..a2385e1da 100644 --- a/web/src/pages/NetworkDevicesPage/modals/EditNetworkDeviceModal/EditNetworkDeviceModal.tsx +++ b/web/src/pages/NetworkDevicesPage/modals/EditNetworkDeviceModal/EditNetworkDeviceModal.tsx @@ -59,7 +59,7 @@ const ModalContent = ({ device, reservedNames }: ModalData) => { const { mutateAsync: editDevice } = useMutation({ mutationFn: api.network_device.editDevice, meta: { - invalidate: ['device', 'network'], + invalidate: [['device', 'network'], ['network']], }, onSuccess: () => { closeModal(modalNameValue); diff --git a/web/src/pages/SetupPage/steps/style.scss b/web/src/pages/SetupPage/steps/style.scss index f7845ed5a..2460221f2 100644 --- a/web/src/pages/SetupPage/steps/style.scss +++ b/web/src/pages/SetupPage/steps/style.scss @@ -24,6 +24,12 @@ } } + .content { + .controls { + padding-top: 0; + } + } + .password-checklist { & > p { font: var(--t-body-sm-600); diff --git a/web/src/pages/UsersOverviewPage/UsersOverviewPage.tsx b/web/src/pages/UsersOverviewPage/UsersOverviewPage.tsx index bbd6b2bd3..328ad1aad 100644 --- a/web/src/pages/UsersOverviewPage/UsersOverviewPage.tsx +++ b/web/src/pages/UsersOverviewPage/UsersOverviewPage.tsx @@ -3,7 +3,10 @@ import './style.scss'; import { Suspense } from 'react'; import { m } from '../../paraglide/messages'; import { AddAuthKeyModal } from '../../shared/components/modals/AddAuthKeyModal/AddAuthKeyModal'; +import { AssignUserDeviceIPModal } from '../../shared/components/modals/AssignUserDeviceIPModal/AssignUserDeviceIPModal'; import { ChangePasswordModal } from '../../shared/components/modals/ChangePasswordModal/ChangePasswordModal'; +import { EditUserDeviceModal } from '../../shared/components/modals/EditUserDeviceModal/EditUserDeviceModal'; +import { UserDeviceConfigModal } from '../../shared/components/modals/UserDeviceConfigModal/UserDeviceConfigModal'; import { TableSkeleton } from '../../shared/components/skeleton/TableSkeleton/TableSkeleton'; import { TablePageLayout } from '../../shared/layout/TablePageLayout/TablePageLayout'; import { AddNewDeviceModal } from './modals/AddNewDeviceModal/AddNewDeviceModal'; @@ -27,6 +30,9 @@ export const UsersOverviewPage = () => { + + + diff --git a/web/src/pages/UsersOverviewPage/UsersTable.tsx b/web/src/pages/UsersOverviewPage/UsersTable.tsx index 9c485e59f..0619aad9b 100644 --- a/web/src/pages/UsersOverviewPage/UsersTable.tsx +++ b/web/src/pages/UsersOverviewPage/UsersTable.tsx @@ -16,7 +16,7 @@ import { orderBy } from 'lodash-es'; import { useCallback, useMemo, useState } from 'react'; import { m } from '../../paraglide/messages'; import api from '../../shared/api/api'; -import type { UsersListItem } from '../../shared/api/types'; +import type { Device, UsersListItem } from '../../shared/api/types'; import { useSelectionModal } from '../../shared/components/modals/SelectionModal/useSelectionModal'; import type { SelectionOption } from '../../shared/components/SelectionSection/type'; import { TableValuesListCell } from '../../shared/components/TableValuesListCell/TableValuesListCell'; @@ -437,9 +437,80 @@ export const UsersTable = () => { [], ); + const { mutate: deleteDevice } = useMutation({ + mutationFn: api.device.deleteDevice, + meta: { + invalidate: [['user-overview'], ['user'], ['network']], + }, + }); + + const makeDeviceRowMenu = useCallback( + ( + device: Device, + username: string, + reservedDeviceNames: string[], + ): MenuItemsGroup[] => [ + { + items: [ + { + text: m.controls_edit(), + icon: 'edit', + onClick: () => { + openModal(ModalName.EditUserDevice, { + device, + reservedNames: reservedDeviceNames, + username, + }); + }, + }, + { + text: m.profile_devices_menu_ip_settings(), + icon: 'gateway', + testId: 'assign-device-ip', + onClick: () => { + api.device + .getDeviceIps(username, device.id) + .then(({ data: locationData }) => { + openModal(ModalName.AssignUserDeviceIP, { + device, + username, + locationData, + }); + }) + .catch((error) => { + Snackbar.error('Failed to load device IP settings'); + console.error(error); + }); + }, + }, + { + text: m.profile_devices_menu_show_config(), + onClick: () => { + api.device.getDeviceConfigs(device).then((modalData) => { + openModal(ModalName.UserDeviceConfig, modalData); + }); + }, + icon: 'config', + }, + { + text: m.controls_delete(), + onClick: () => { + deleteDevice(device.id); + }, + variant: 'danger', + icon: 'delete', + }, + ], + }, + ], + [deleteDevice], + ); + const renderExpanded = useCallback( - (row: Row, isLast = false) => - row.original.devices.map((device, deviceIndex) => { + (row: Row, isLast = false) => { + const username = row.original.username; + const reservedDeviceNames = row.original.devices.map((d) => d.name); + return row.original.devices.map((device, deviceIndex) => { const lastRow = isLast && deviceIndex === row.original.devices.length - 1; const latestNetwork = orderBy( device.networks.filter((n) => isPresent(n.last_connected_at)), @@ -454,6 +525,7 @@ export const UsersTable = () => { const connectionDate = latestNetwork?.last_connected_at ? displayDate(latestNetwork.last_connected_at) : neverConnected; + const menuItems = makeDeviceRowMenu(device, username, reservedDeviceNames); return ( { {connectionDate} - + + + ); - }), - [], + }); + }, + [makeDeviceRowMenu], ); const table = useReactTable({ diff --git a/web/src/pages/user-profile/UserProfilePage/tabs/ProfileDevicesTab/components/ProfileDevicesTable/ProfileDevicesTable.tsx b/web/src/pages/user-profile/UserProfilePage/tabs/ProfileDevicesTab/components/ProfileDevicesTable/ProfileDevicesTable.tsx index 9624aee28..996583d85 100644 --- a/web/src/pages/user-profile/UserProfilePage/tabs/ProfileDevicesTab/components/ProfileDevicesTable/ProfileDevicesTable.tsx +++ b/web/src/pages/user-profile/UserProfilePage/tabs/ProfileDevicesTab/components/ProfileDevicesTable/ProfileDevicesTable.tsx @@ -102,7 +102,7 @@ const DevicesTable = ({ rowData }: { rowData: RowData[] }) => { const { mutate: deleteDevice } = useMutation({ mutationFn: api.device.deleteDevice, meta: { - invalidate: [['user-overview'], ['user', username]], + invalidate: [['user-overview'], ['user', username], ['network']], }, }); diff --git a/web/src/routes/_wizard/setup.tsx b/web/src/routes/_wizard/setup.tsx index bebc95579..356be06b2 100644 --- a/web/src/routes/_wizard/setup.tsx +++ b/web/src/routes/_wizard/setup.tsx @@ -6,7 +6,6 @@ import { SetupPageStep, type SetupPageStepValue } from '../../pages/SetupPage/ty import { useSetupWizardStore } from '../../pages/SetupPage/useSetupWizardStore'; import api from '../../shared/api/api'; import type { InitialSetupStepValue } from '../../shared/api/types'; -import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; import { useApp } from '../../shared/hooks/useApp'; import { getSettingsEssentialsQueryOptions } from '../../shared/query'; @@ -20,14 +19,17 @@ const handleWizardRedirect = async ({ location: ParsedLocation; client: QueryClient; }) => { - let settingsEssentials = useApp.getState().settingsEssentials; - if (!isPresent(settingsEssentials)) { - settingsEssentials = (await client.ensureQueryData(getSettingsEssentialsQueryOptions)) - .data; - useApp.setState({ - settingsEssentials, - }); - } + // Reset wizard state on every navigation to clear any stale/corrupt sessionStorage data. + // The correct step will be restored from the backend below. + useSetupWizardStore.getState().reset(); + + // Always fetch fresh settings from the backend to get the current wizard step, + // bypassing any stale cached data. + const settingsEssentials = (await client.fetchQuery(getSettingsEssentialsQueryOptions)) + .data; + useApp.setState({ + settingsEssentials, + }); const applyWizardStepFromServer = (step: InitialSetupStepValue) => { const stepMap: Record = { diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 1a68b47d6..5e3be4826 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -566,12 +566,19 @@ export interface NetworkLocation { acl_default_allow: boolean; location_mfa_mode: LocationMfaModeValue; service_location_mode: LocationServiceModeValue; + has_devices: boolean; } export interface EditNetworkLocation extends Omit< NetworkLocation, - 'gateways' | 'connected_at' | 'id' | 'connected' | 'allowed_ips' | 'address' + | 'gateways' + | 'connected_at' + | 'id' + | 'connected' + | 'allowed_ips' + | 'address' + | 'has_devices' > { allowed_ips: string; address: string; diff --git a/web/src/shared/components/modals/AddUserDeviceModal/steps/AddDeviceModalManualSetupStep/AddDeviceModalManualSetupStep.tsx b/web/src/shared/components/modals/AddUserDeviceModal/steps/AddDeviceModalManualSetupStep/AddDeviceModalManualSetupStep.tsx index 64e6c3ff5..2b178dbae 100644 --- a/web/src/shared/components/modals/AddUserDeviceModal/steps/AddDeviceModalManualSetupStep/AddDeviceModalManualSetupStep.tsx +++ b/web/src/shared/components/modals/AddUserDeviceModal/steps/AddDeviceModalManualSetupStep/AddDeviceModalManualSetupStep.tsx @@ -63,7 +63,7 @@ export const AddDeviceModalManualSetupStep = () => { const { mutateAsync: createDevice } = useMutation({ mutationFn: api.device.addDevice, meta: { - invalidate: [['user-overview'], ['user']], + invalidate: [['user-overview'], ['user'], ['network']], }, });