diff --git a/.sqlx/query-fc12a1d289e47595f20793bf356f473ca8571c5b3f97746a6a16a440ebec3926.json b/.sqlx/query-fc12a1d289e47595f20793bf356f473ca8571c5b3f97746a6a16a440ebec3926.json new file mode 100644 index 000000000..67adf1cef --- /dev/null +++ b/.sqlx/query-fc12a1d289e47595f20793bf356f473ca8571c5b3f97746a6a16a440ebec3926.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT wn.id AS location_id, wn.name AS location_name, d.id AS device_id, d.name AS device_name, wnd.wireguard_ips AS \"wireguard_ips: Vec\" FROM wireguard_network wn JOIN wireguard_network_device wnd ON wnd.wireguard_network_id = wn.id JOIN device d ON d.id = wnd.device_id JOIN \"user\" u ON d.user_id = u.id WHERE u.username = $1 AND d.id = $2 ORDER BY wn.name", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "location_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "location_name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "device_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "device_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "wireguard_ips: Vec", + "type_info": "InetArray" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "fc12a1d289e47595f20793bf356f473ca8571c5b3f97746a6a16a440ebec3926" +} diff --git a/crates/defguard_core/src/enterprise/firewall/tests/mod.rs b/crates/defguard_core/src/enterprise/firewall/tests/mod.rs index 621fe84aa..40e6d233b 100644 --- a/crates/defguard_core/src/enterprise/firewall/tests/mod.rs +++ b/crates/defguard_core/src/enterprise/firewall/tests/mod.rs @@ -1,4 +1,3 @@ -use crate::enterprise::license::{License, LicenseTier}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use defguard_common::db::{ @@ -27,7 +26,7 @@ use crate::enterprise::{ AclRuleInfo, AclRuleNetwork, AclRuleUser, AliasKind, PortRange, RuleState, }, firewall::try_get_location_firewall_config, - license::set_cached_license, + license::{License, LicenseTier, set_cached_license}, }; mod all_locations; diff --git a/crates/defguard_core/src/grpc/mod.rs b/crates/defguard_core/src/grpc/mod.rs index ef8a277d6..beb66fd8a 100644 --- a/crates/defguard_core/src/grpc/mod.rs +++ b/crates/defguard_core/src/grpc/mod.rs @@ -5,18 +5,6 @@ use std::{ time::{Duration, Instant}, }; -use crate::{ - auth::failed_login::FailedLoginMap, - db::AppEvent, - enterprise::{ - db::models::{ - enterprise_settings::{ClientTrafficPolicy, EnterpriseSettings}, - openid_provider::OpenIdProvider, - }, - is_business_license_active, is_enterprise_license_active, - }, - grpc::{auth::AuthServer, interceptor::JwtInterceptor, worker::WorkerServer}, -}; use defguard_common::{ auth::claims::ClaimsType, config::server_config, @@ -35,6 +23,19 @@ use serde::Serialize; use sqlx::PgPool; use tokio::sync::{broadcast::Sender, mpsc::UnboundedSender}; +use crate::{ + auth::failed_login::FailedLoginMap, + db::AppEvent, + enterprise::{ + db::models::{ + enterprise_settings::{ClientTrafficPolicy, EnterpriseSettings}, + openid_provider::OpenIdProvider, + }, + is_business_license_active, is_enterprise_license_active, + }, + grpc::{auth::AuthServer, interceptor::JwtInterceptor, worker::WorkerServer}, +}; + mod auth; pub mod client_version; pub mod interceptor; diff --git a/crates/defguard_core/src/handlers/static_ips.rs b/crates/defguard_core/src/handlers/static_ips.rs index fe2fac0af..fe285c34d 100644 --- a/crates/defguard_core/src/handlers/static_ips.rs +++ b/crates/defguard_core/src/handlers/static_ips.rs @@ -6,7 +6,7 @@ use axum::{ http::StatusCode, }; use defguard_common::db::Id; -use defguard_static_ip::{LocationDevices, get_ips_for_user}; +use defguard_static_ip::{DeviceLocationIp, LocationDevices, get_ips_for_device, get_ips_for_user}; use serde::Serialize; use crate::{ @@ -20,6 +20,11 @@ pub struct LocationDevicesResponse { pub locations: Vec, } +#[derive(Serialize)] +pub struct DeviceLocationIpsResponse { + pub locations: Vec, +} + pub async fn get_all_user_device_ips( _admin_role: AdminRole, _session: SessionInfo, @@ -33,6 +38,19 @@ pub async fn get_all_user_device_ips( )) } +pub async fn get_device_ips( + _admin_role: AdminRole, + _session: SessionInfo, + Path((username, device_id)): Path<(String, Id)>, + State(state): State, +) -> ApiResult { + let locations = get_ips_for_device(&username, device_id, &state.pool).await?; + Ok(ApiResponse::json( + DeviceLocationIpsResponse { locations }, + StatusCode::OK, + )) +} + #[derive(Deserialize)] pub struct StaticIpAssignment { pub device_id: i64, diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 1b900990a..719a91efe 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -140,7 +140,9 @@ use crate::{ test_ldap_settings, update_settings, }, ssh_authorized_keys::get_authorized_keys, - static_ips::{assign_static_ips, get_all_user_device_ips, validate_ip_assignment}, + static_ips::{ + assign_static_ips, get_all_user_device_ips, get_device_ips, validate_ip_assignment, + }, support::{configuration, logs}, updates::outdated_components, user::{ @@ -476,6 +478,10 @@ pub fn build_webapp( "/device/user/{username}/ip", get(get_all_user_device_ips).post(assign_static_ips), ) + .route( + "/device/user/{username}/ip/{device_id}", + get(get_device_ips), + ) .route( "/device/user/{username}/ip/validate", post(validate_ip_assignment), diff --git a/crates/defguard_gateway_manager/src/handler.rs b/crates/defguard_gateway_manager/src/handler.rs index 3dc908d0e..6b93625a4 100644 --- a/crates/defguard_gateway_manager/src/handler.rs +++ b/crates/defguard_gateway_manager/src/handler.rs @@ -19,6 +19,11 @@ use defguard_common::{ }, messages::peer_stats_update::PeerStatsUpdate, }; +use defguard_core::{ + enterprise::firewall::try_get_location_firewall_config, grpc::GatewayEvent, + handlers::mail::send_gateway_disconnected_email, + location_management::allowed_peers::get_location_allowed_peers, +}; #[cfg(not(test))] use defguard_grpc_tls::{certs as tls_certs, connector::HttpsSchemeConnector}; use defguard_proto::{ @@ -45,12 +50,6 @@ use tokio::{ use tokio_stream::wrappers::UnboundedReceiverStream; use tonic::{Code, Status, transport::Endpoint}; -use defguard_core::{ - enterprise::firewall::try_get_location_firewall_config, grpc::GatewayEvent, - handlers::mail::send_gateway_disconnected_email, - location_management::allowed_peers::get_location_allowed_peers, -}; - use crate::{Client, TEN_SECS, error::GatewayError}; /// One instance per connected Gateway. diff --git a/crates/defguard_grpc_tls/src/connector.rs b/crates/defguard_grpc_tls/src/connector.rs index 16f438ffe..866f7b8f6 100644 --- a/crates/defguard_grpc_tls/src/connector.rs +++ b/crates/defguard_grpc_tls/src/connector.rs @@ -22,11 +22,11 @@ where C: tower_service::Service + Clone + Send + 'static, C::Future: Send, { - type Response = C::Response; type Error = BoxError; type Future = std::pin::Pin< Box> + Send>, >; + type Response = C::Response; fn poll_ready( &mut self, diff --git a/crates/defguard_static_ip/src/lib.rs b/crates/defguard_static_ip/src/lib.rs index d187eb0e0..346ebcaf0 100644 --- a/crates/defguard_static_ip/src/lib.rs +++ b/crates/defguard_static_ip/src/lib.rs @@ -29,6 +29,16 @@ pub struct DeviceIps { pub wireguard_ips: Vec, } +/// Flattened location entry used by the single-device IP endpoint. +/// Each entry represents one location the device belongs to, +/// without wrapping IPs in an inner device array. +#[derive(Serialize)] +pub struct DeviceLocationIp { + pub location_id: i64, + pub location_name: String, + pub wireguard_ips: Vec, +} + #[derive(FromRow)] struct DeviceIpRow { location_id: i64, @@ -110,6 +120,67 @@ pub async fn get_ips_for_user( Ok(locations) } +pub async fn get_ips_for_device( + username: &str, + device_id: Id, + pool: &PgPool, +) -> Result, StaticIpError> { + debug!("Fetching static IPs for device {device_id} of user {username}"); + let rows = sqlx::query_as!( + DeviceIpRow, + "SELECT \ + wn.id AS location_id, \ + wn.name AS location_name, \ + d.id AS device_id, \ + d.name AS device_name, \ + wnd.wireguard_ips AS \"wireguard_ips: Vec\" \ + FROM wireguard_network wn \ + JOIN wireguard_network_device wnd ON wnd.wireguard_network_id = wn.id \ + JOIN device d ON d.id = wnd.device_id \ + JOIN \"user\" u ON d.user_id = u.id \ + WHERE u.username = $1 AND d.id = $2 \ + ORDER BY wn.name", + username, + device_id + ) + .fetch_all(pool) + .await?; + + debug!( + "Found {} location(s) for device {device_id} of user {username}", + rows.len() + ); + let mut locations: Vec = Vec::new(); + + for row in rows { + let network = WireguardNetwork::find_by_id(pool, row.location_id) + .await? + .ok_or(StaticIpError::NetworkNotFound(row.location_id))?; + + let wireguard_ips: Vec = row + .wireguard_ips + .iter() + .filter_map(|ip| { + network + .get_containing_network(*ip) + .map(|net| split_ip(ip, &net)) + }) + .collect(); + + locations.push(DeviceLocationIp { + location_id: row.location_id, + location_name: row.location_name, + wireguard_ips, + }); + } + + debug!( + "Returning IP data for {} location(s) for device {device_id}", + locations.len() + ); + Ok(locations) +} + pub async fn assign_static_ips( device_id: Id, ips: Vec, diff --git a/web/messages/en/modal.json b/web/messages/en/modal.json index dce76d406..3cf374d50 100644 --- a/web/messages/en/modal.json +++ b/web/messages/en/modal.json @@ -130,5 +130,12 @@ "modal_assign_user_ip_success": "{firstName} {lastName}'s IP addresses were successfully updated.", "modal_assign_user_ip_error": "Failed to update IP addresses", "modal_assign_user_ip_validation_error": "Invalid or already taken", - "modal_assign_user_ip_no_locations": "No locations available. Add a location first." + "modal_assign_user_ip_no_locations": "No locations available. Add a location first.", + "modal_assign_user_ip_no_devices": "This user has no devices.", + "modal_assign_user_device_ip_title": "Device IP settings", + "modal_assign_user_device_ip_title_fallback": "Device IP settings", + "modal_assign_user_device_ip_card_title": "{deviceName} IP settings", + "modal_assign_user_device_ip_assignment_description": "You can change the IP address for this device separately in each location/network one-by-one.", + "modal_assign_user_device_ip_success": "{deviceName}'s IP addresses were successfully updated.", + "modal_assign_user_device_ip_error": "Failed to update IP addresses" } diff --git a/web/messages/en/profile.json b/web/messages/en/profile.json index 05cb4c112..5acb1af4e 100644 --- a/web/messages/en/profile.json +++ b/web/messages/en/profile.json @@ -37,6 +37,7 @@ "profile_devices_col_location_connected": "Last connected", "profile_devices_col_never_connected": "Never connected", "profile_devices_menu_show_config": "Show configuration", + "profile_devices_menu_ip_settings": "Device IP settings", "profile_auth_keys_no_data_title": "You don't have any added keys.", "profile_auth_keys_no_data_subtitle": "To add one, click the button below", "profile_auth_keys_no_data_cta": "Add new key", diff --git a/web/src/pages/UsersOverviewPage/UsersTable.tsx b/web/src/pages/UsersOverviewPage/UsersTable.tsx index 26545dcb4..7f2650bf0 100644 --- a/web/src/pages/UsersOverviewPage/UsersTable.tsx +++ b/web/src/pages/UsersOverviewPage/UsersTable.tsx @@ -311,6 +311,7 @@ export const UsersTable = () => { openModal(ModalName.AssignUserIP, { user: rowData, locationData: response.data, + hasDevices: rowData.devices.length > 0, }); }, }, diff --git a/web/src/pages/UsersOverviewPage/modals/AssignUserIPModal/AssignUserIPModal.tsx b/web/src/pages/UsersOverviewPage/modals/AssignUserIPModal/AssignUserIPModal.tsx index 20b7fc0d1..9feb5b9ff 100644 --- a/web/src/pages/UsersOverviewPage/modals/AssignUserIPModal/AssignUserIPModal.tsx +++ b/web/src/pages/UsersOverviewPage/modals/AssignUserIPModal/AssignUserIPModal.tsx @@ -91,13 +91,14 @@ export const AssignUserIPModal = () => { ); }; -const ModalContent = ({ user, locationData }: ModalData) => { +const ModalContent = ({ user, locationData, hasDevices }: ModalData) => { return ( ); }; @@ -107,6 +108,7 @@ type AssignmentFormProps = { firstName: string; lastName: string; locationData: LocationDevicesResponse; + hasDevices: boolean; }; const AssignmentForm = ({ @@ -114,6 +116,7 @@ const AssignmentForm = ({ firstName, lastName, locationData, + hasDevices, }: AssignmentFormProps) => { const [openLocations, setOpenLocations] = useState>(() => new Set()); @@ -216,7 +219,11 @@ const AssignmentForm = ({
{locationData.locations.length === 0 && ( -

{m.modal_assign_user_ip_no_locations()}

+

+ {hasDevices + ? m.modal_assign_user_ip_no_locations() + : m.modal_assign_user_ip_no_devices()} +

)} {locationData.locations.map((location: LocationDevices, locIdx) => ( { + ); }; 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 d109d0ed8..9624aee28 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 @@ -27,6 +27,7 @@ import { TableCell } from '../../../../../../../shared/defguard-ui/components/ta import { TableFlexCell } from '../../../../../../../shared/defguard-ui/components/table/TableFlexCell/TableFlexCell'; import { TableRowContainer } from '../../../../../../../shared/defguard-ui/components/table/TableRowContainer/TableRowContainer'; import { TableTop } from '../../../../../../../shared/defguard-ui/components/table/TableTop/TableTop'; +import { Snackbar } from '../../../../../../../shared/defguard-ui/providers/snackbar/snackbar'; import { isPresent } from '../../../../../../../shared/defguard-ui/utils/isPresent'; import { openModal } from '../../../../../../../shared/hooks/modalControls/modalsSubjects'; import { ModalName } from '../../../../../../../shared/hooks/modalControls/modalTypes'; @@ -120,6 +121,26 @@ const DevicesTable = ({ rowData }: { rowData: RowData[] }) => { }); }, }, + { + text: m.profile_devices_menu_ip_settings(), + icon: 'gateway', + testId: 'assign-device-ip', + onClick: () => { + api.device + .getDeviceIps(username, row.id) + .then(({ data: locationData }) => { + openModal(ModalName.AssignUserDeviceIP, { + device: row, + username, + locationData, + }); + }) + .catch((error) => { + Snackbar.error('Failed to load device IP settings'); + console.error(error); + }); + }, + }, { text: m.profile_devices_menu_show_config(), onClick: () => { diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index cac468834..15de6f6bd 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -41,6 +41,7 @@ import type { DeleteAuthKeyRequest, DeleteGatewayRequest, Device, + DeviceLocationIpsResponse, Edge, EdgeInfo, EditAclAliasRequest, @@ -387,6 +388,8 @@ const api = { client.get(`/network/${networkId}/device/${deviceId}/config`), getUserDeviceIps: (username: string) => client.get(`/device/user/${username}/ip`), + getDeviceIps: (username: string, deviceId: number) => + client.get(`/device/user/${username}/ip/${deviceId}`), assignUserDeviceIps: (username: string, data: AssignStaticIpsRequest) => client.post(`/device/user/${username}/ip`, data), validateUserDeviceIp: (username: string, data: ValidateIpAssignmentRequest) => diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 8e6d8f8f4..e01766a07 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -83,6 +83,16 @@ export interface LocationDevicesResponse { locations: LocationDevices[]; } +export interface DeviceLocationIp { + location_id: number; + location_name: string; + wireguard_ips: AvailableLocationIP[]; +} + +export interface DeviceLocationIpsResponse { + locations: DeviceLocationIp[]; +} + export interface StaticIpAssignment { device_id: number; location_id: number; diff --git a/web/src/shared/components/IpAssignmentDeviceSection/IpAssignmentDeviceSection.tsx b/web/src/shared/components/IpAssignmentDeviceSection/IpAssignmentDeviceSection.tsx index 6b3b701ef..d5b2e21b0 100644 --- a/web/src/shared/components/IpAssignmentDeviceSection/IpAssignmentDeviceSection.tsx +++ b/web/src/shared/components/IpAssignmentDeviceSection/IpAssignmentDeviceSection.tsx @@ -8,7 +8,7 @@ import { ThemeVariable } from '../../defguard-ui/types'; import { isPresent } from '../../defguard-ui/utils/isPresent'; interface Props { - name: string; + name?: string; children: ReactNode; } @@ -48,10 +48,12 @@ export const IpAssignmentDeviceSection = ({ name, children }: Props) => { return (
-
- -

{name}

-
+ {isPresent(name) && ( +
+ +

{name}

+
+ )}
{childrenCount > 0 && diff --git a/web/src/shared/components/modals/AssignUserDeviceIPModal/AssignUserDeviceIPModal.tsx b/web/src/shared/components/modals/AssignUserDeviceIPModal/AssignUserDeviceIPModal.tsx new file mode 100644 index 000000000..11e1b0f7c --- /dev/null +++ b/web/src/shared/components/modals/AssignUserDeviceIPModal/AssignUserDeviceIPModal.tsx @@ -0,0 +1,252 @@ +import { useStore } from '@tanstack/react-form'; +import { useMutation } from '@tanstack/react-query'; +import axios from 'axios'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import z from 'zod'; +import { m } from '../../../../paraglide/messages'; +import api from '../../../api/api'; +import type { DeviceLocationIp, DeviceLocationIpsResponse } from '../../../api/types'; +import { Modal } from '../../../defguard-ui/components/Modal/Modal'; +import { ModalControls } from '../../../defguard-ui/components/ModalControls/ModalControls'; +import { SuggestedIpInput } from '../../../defguard-ui/components/SuggestedIPInput/SuggestedIPInput'; +import { Snackbar } from '../../../defguard-ui/providers/snackbar/snackbar'; +import { isPresent } from '../../../defguard-ui/utils/isPresent'; +import { useAppForm } from '../../../form'; +import { + closeModal, + subscribeCloseModal, + subscribeOpenModal, +} from '../../../hooks/modalControls/modalsSubjects'; +import { ModalName } from '../../../hooks/modalControls/modalTypes'; +import type { OpenAssignUserDeviceIPModal } from '../../../hooks/modalControls/types'; +import { IpAssignmentCard } from '../../IpAssignmentCard/IpAssignmentCard'; +import { IpAssignmentDeviceSection } from '../../IpAssignmentDeviceSection/IpAssignmentDeviceSection'; +import './style.scss'; + +const modalNameValue = ModalName.AssignUserDeviceIP; + +type ModalData = OpenAssignUserDeviceIPModal; + +const formSchema = z.object({ + locations: z.array( + z.object({ + location_id: z.number(), + ips: z.array( + z.object({ + modifiable_part: z.string().trim(), + network_part: z.string(), + }), + ), + }), + ), +}); + +type FormFields = z.infer; + +export const AssignUserDeviceIPModal = () => { + const [isOpen, setOpen] = useState(false); + const [modalData, setModalData] = useState(null); + + useEffect(() => { + const openSub = subscribeOpenModal(modalNameValue, (data) => { + setModalData(data); + setOpen(true); + }); + const closeSub = subscribeCloseModal(modalNameValue, () => setOpen(false)); + return () => { + openSub.unsubscribe(); + closeSub.unsubscribe(); + }; + }, []); + + return ( + setOpen(false)} + afterClose={() => { + setModalData(null); + }} + > + {isPresent(modalData) && ( + + )} + + ); +}; + +type AssignmentFormProps = { + deviceId: number; + deviceName: string; + username: string; + locationData: DeviceLocationIpsResponse; +}; + +const AssignmentForm = ({ + deviceId, + deviceName, + username, + locationData, +}: AssignmentFormProps) => { + const [openLocations, setOpenLocations] = useState>(() => new Set()); + + const defaultValues: FormFields = useMemo( + () => ({ + locations: locationData.locations.map((loc) => ({ + location_id: loc.location_id, + ips: loc.wireguard_ips.map((ip) => ({ + modifiable_part: ip.modifiable_part, + network_part: ip.network_part, + })), + })), + }), + [locationData], + ); + + const { mutateAsync: updateDevice } = useMutation({ + mutationFn: (formData: FormFields) => { + const assignments = formData.locations + .map((loc) => ({ + device_id: deviceId, + location_id: loc.location_id, + ips: loc.ips + .filter((ip) => ip.modifiable_part.length > 0) + .map((ip) => `${ip.network_part}${ip.modifiable_part}`), + })) + .filter((a) => a.ips.length > 0); + return api.device.assignUserDeviceIps(username, assignments); + }, + meta: { + invalidate: [ + ['user-device-ips', username], + ['user', username], + ], + }, + onSuccess: () => { + Snackbar.default(m.modal_assign_user_device_ip_success({ deviceName })); + closeModal(modalNameValue); + }, + onError: (error) => { + console.error('Failed to update IP addresses:', error); + Snackbar.error(m.modal_assign_user_device_ip_error()); + }, + }); + + const form = useAppForm({ + defaultValues, + validators: { + onSubmit: formSchema, + }, + onSubmit: async ({ value }) => { + await updateDevice(value); + }, + }); + + const isSubmitting = useStore(form.store, (s) => s.isSubmitting); + + const toggleLocation = (locationId: number) => { + setOpenLocations((prev) => { + const next = new Set(prev); + if (next.has(locationId)) { + next.delete(locationId); + } else { + next.add(locationId); + } + return next; + }); + }; + + const validateIp = useCallback( + async (value: string, locationId: number) => { + try { + await api.device.validateUserDeviceIp(username, { + device_id: deviceId, + ip: value, + location: locationId, + }); + return undefined; + } catch (e) { + return axios.isAxiosError(e) + ? (e.response?.data?.msg ?? m.modal_assign_user_ip_validation_error()) + : m.modal_assign_user_ip_validation_error(); + } + }, + [username, deviceId], + ); + + return ( + +
+
+

+ {m.modal_assign_user_device_ip_card_title({ deviceName })} +

+

+ {m.modal_assign_user_device_ip_assignment_description()} +

+
+ +
+ {locationData.locations.length === 0 && ( +

{m.modal_assign_user_ip_no_locations()}

+ )} + {locationData.locations.map((location: DeviceLocationIp, locIdx) => ( + toggleLocation(location.location_id)} + > + + {location.wireguard_ips.map((ipData, ipIdx) => ( + + validateIp( + `${ipData.network_part}${value}`, + location.location_id, + ), + }} + > + {(field) => ( + field.handleChange(val ?? '')} + onBlur={field.handleBlur} + /> + )} + + ))} + + + ))} +
+ + form.handleSubmit(), + }} + cancelProps={{ + text: m.controls_cancel(), + disabled: isSubmitting, + onClick: () => closeModal(modalNameValue), + }} + /> +
+
+ ); +}; diff --git a/web/src/shared/components/modals/AssignUserDeviceIPModal/style.scss b/web/src/shared/components/modals/AssignUserDeviceIPModal/style.scss new file mode 100644 index 000000000..0f52e2db4 --- /dev/null +++ b/web/src/shared/components/modals/AssignUserDeviceIPModal/style.scss @@ -0,0 +1,31 @@ +.assign-user-device-ip-modal { + display: flex; + flex-direction: column; + gap: var(--spacing-2xl); + + .device-ip-card { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + + .card-title { + font: var(--t-body-sm-500); + } + + .card-description { + color: var(--fg-neutral); + font: var(--t-body-sm-400); + } + } + + .locations-list { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + + .no-locations { + color: var(--fg-neutral); + font: var(--t-body-sm-400); + } + } +} diff --git a/web/src/shared/hooks/modalControls/modalTypes.ts b/web/src/shared/hooks/modalControls/modalTypes.ts index 3379b964d..d7dd54ff2 100644 --- a/web/src/shared/hooks/modalControls/modalTypes.ts +++ b/web/src/shared/hooks/modalControls/modalTypes.ts @@ -4,6 +4,7 @@ import type { OpenAddApiTokenModal, OpenAddLocationModal, OpenAddNetworkDeviceModal, + OpenAssignUserDeviceIPModal, OpenAssignUserIPModal, OpenAssignUsersToGroupsModal, OpenAuthKeyRenameModal, @@ -57,6 +58,7 @@ export const ModalName = { DeleteLogStreaming: 'deleteLogStreaming', SelfEnrollmentToken: 'selfEnrollmentToken', AssignUserIP: 'assignUserIP', + AssignUserDeviceIP: 'assignUserDeviceIP', } as const; export type ModalNameValue = (typeof ModalName)[keyof typeof ModalName]; @@ -186,6 +188,10 @@ const modalOpenArgsSchema = z.discriminatedUnion('name', [ name: z.literal(ModalName.AssignUserIP), data: z.custom(), }), + z.object({ + name: z.literal(ModalName.AssignUserDeviceIP), + data: z.custom(), + }), ]); export type ModalOpenEvent = z.infer; diff --git a/web/src/shared/hooks/modalControls/types.ts b/web/src/shared/hooks/modalControls/types.ts index a67e8f1f7..a5baea5c0 100644 --- a/web/src/shared/hooks/modalControls/types.ts +++ b/web/src/shared/hooks/modalControls/types.ts @@ -1,6 +1,7 @@ import type { AvailableLocationIpResponse, Device, + DeviceLocationIpsResponse, GroupInfo, LicenseInfo, LicenseTierValue, @@ -112,4 +113,11 @@ export interface OpenAddLocationModal { export interface OpenAssignUserIPModal { user: User; locationData: LocationDevicesResponse; + hasDevices: boolean; +} + +export interface OpenAssignUserDeviceIPModal { + device: Device; + username: string; + locationData: DeviceLocationIpsResponse; }