From 55345020f614f44848f6eb0683760bb9c6eb83c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Wed, 8 Jul 2026 11:37:24 +0200 Subject: [PATCH 1/9] fix #2933 --- web/messages/en/modal.json | 2 + .../CEOpenIdClientModal.tsx | 2 +- .../AssignUserIPModal/AssignUserIPModal.tsx | 40 ++++++++++++++++++- .../AssignUserDeviceIPModal.tsx | 35 +++++++++++++++- web/src/shared/utils/ipFieldDuplicates.ts | 19 +++++++++ 5 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 web/src/shared/utils/ipFieldDuplicates.ts diff --git a/web/messages/en/modal.json b/web/messages/en/modal.json index fb63dd1e57..1a2620dcd0 100644 --- a/web/messages/en/modal.json +++ b/web/messages/en/modal.json @@ -190,6 +190,7 @@ "modal_ce_openid_client_label_redirect": "Redirect URL {index}", "modal_ce_openid_client_label_redirect_add": "Add URL", "modal_ce_openid_client_label_scopes_title": "Scopes", + "modal_ce_openid_client_label_scopes_text": "Scopes define what information this application can request about a user, such as their profile details, email address, or group membership. Select only the scopes required for the application to function.", "modal_ce_openid_client_label_name": "Application name", "modal_ce_openid_client_helper_name": "", "modal_ce_openid_client_helper_redirect_uri": "", @@ -206,6 +207,7 @@ "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_duplicate_error": "This IP address is already used in this location", "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_posture_check_locations_title": "Assign locations", diff --git a/web/src/pages/OpenIdPage/modals/CEOpenIdClientModal/CEOpenIdClientModal.tsx b/web/src/pages/OpenIdPage/modals/CEOpenIdClientModal/CEOpenIdClientModal.tsx index 6a58270b24..3157e42d57 100644 --- a/web/src/pages/OpenIdPage/modals/CEOpenIdClientModal/CEOpenIdClientModal.tsx +++ b/web/src/pages/OpenIdPage/modals/CEOpenIdClientModal/CEOpenIdClientModal.tsx @@ -250,7 +250,7 @@ const ModalContent = ({ reservedNames, openIdClient }: ModalData) => { -

{m.test_placeholder_long()}

+

{m.modal_ce_openid_client_label_scopes_text()}

diff --git a/web/src/pages/UsersOverviewPage/modals/AssignUserIPModal/AssignUserIPModal.tsx b/web/src/pages/UsersOverviewPage/modals/AssignUserIPModal/AssignUserIPModal.tsx index b0eb268528..ef931fcb2a 100644 --- a/web/src/pages/UsersOverviewPage/modals/AssignUserIPModal/AssignUserIPModal.tsx +++ b/web/src/pages/UsersOverviewPage/modals/AssignUserIPModal/AssignUserIPModal.tsx @@ -16,6 +16,10 @@ import { ModalControls } from '../../../../shared/defguard-ui/components/ModalCo import { SuggestedIpInput } from '../../../../shared/defguard-ui/components/SuggestedIPInput/SuggestedIPInput'; import { Snackbar } from '../../../../shared/defguard-ui/providers/snackbar/snackbar'; import { isPresent } from '../../../../shared/defguard-ui/utils/isPresent'; +import { + createZodIssue, + zodIssueMessage, +} from '../../../../shared/defguard-ui/utils/zod'; import { useAppForm } from '../../../../shared/form'; import { closeModal, @@ -24,6 +28,7 @@ import { } from '../../../../shared/hooks/modalControls/modalsSubjects'; import { ModalName } from '../../../../shared/hooks/modalControls/modalTypes'; import type { OpenAssignUserIPModal } from '../../../../shared/hooks/modalControls/types'; +import { getDuplicateIpFieldPaths } from '../../../../shared/utils/ipFieldDuplicates'; import './style.scss'; import { DescriptionBlock } from '../../../../shared/components/DescriptionBlock/DescriptionBlock'; @@ -52,6 +57,25 @@ const formSchema = z.object({ type FormFields = z.infer; +const buildDuplicateFieldErrors = (value: FormFields): Record => { + const errors: Record = {}; + value.locations.forEach((loc, locIdx) => { + const entries = loc.devices.flatMap((dev, devIdx) => + dev.ips.map((ip, ipIdx) => ({ + path: `locations[${locIdx}].devices[${devIdx}].ips[${ipIdx}].modifiable_part`, + ip: + ip.modifiable_part.trim().length > 0 + ? `${ip.network_part}${ip.modifiable_part}` + : '', + })), + ); + getDuplicateIpFieldPaths(entries).forEach((path) => { + errors[path] = m.modal_assign_user_ip_duplicate_error(); + }); + }); + return errors; +}; + export const AssignUserIPModal = () => { const [isOpen, setOpen] = useState(false); const [modalData, setModalData] = useState(null); @@ -169,6 +193,20 @@ const AssignmentForm = ({ onSubmit: formSchema, }, onSubmit: async ({ value }) => { + const duplicateFields = buildDuplicateFieldErrors(value); + if (Object.keys(duplicateFields).length > 0) { + form.setErrorMap({ + onSubmit: { + fields: Object.fromEntries( + Object.entries(duplicateFields).map(([path, message]) => [ + path, + createZodIssue(message, [path]), + ]), + ), + }, + }); + return; + } await updateDevices(value); }, }); @@ -250,7 +288,7 @@ const AssignmentForm = ({ data={ipData} value={field.state.value} loading={field.state.meta.isValidating} - error={field.state.meta.errors[0]?.toString()} + error={zodIssueMessage(field.state.meta.errors[0])} onChange={(val) => field.handleChange(val ?? '')} onBlur={field.handleBlur} helper={m.form_helper_assigned_ip_address()} diff --git a/web/src/shared/components/modals/AssignUserDeviceIPModal/AssignUserDeviceIPModal.tsx b/web/src/shared/components/modals/AssignUserDeviceIPModal/AssignUserDeviceIPModal.tsx index a237dc829e..ca14ccbd46 100644 --- a/web/src/shared/components/modals/AssignUserDeviceIPModal/AssignUserDeviceIPModal.tsx +++ b/web/src/shared/components/modals/AssignUserDeviceIPModal/AssignUserDeviceIPModal.tsx @@ -11,6 +11,7 @@ import { ModalControls } from '../../../defguard-ui/components/ModalControls/Mod import { SuggestedIpInput } from '../../../defguard-ui/components/SuggestedIPInput/SuggestedIPInput'; import { Snackbar } from '../../../defguard-ui/providers/snackbar/snackbar'; import { isPresent } from '../../../defguard-ui/utils/isPresent'; +import { createZodIssue, zodIssueMessage } from '../../../defguard-ui/utils/zod'; import { useAppForm } from '../../../form'; import { closeModal, @@ -19,6 +20,7 @@ import { } from '../../../hooks/modalControls/modalsSubjects'; import { ModalName } from '../../../hooks/modalControls/modalTypes'; import type { OpenAssignUserDeviceIPModal } from '../../../hooks/modalControls/types'; +import { getDuplicateIpFieldPaths } from '../../../utils/ipFieldDuplicates'; import { IpAssignmentCard } from '../../IpAssignmentCard/IpAssignmentCard'; import { IpAssignmentDeviceSection } from '../../IpAssignmentDeviceSection/IpAssignmentDeviceSection'; import './style.scss'; @@ -43,6 +45,23 @@ const formSchema = z.object({ type FormFields = z.infer; +const buildDuplicateFieldErrors = (value: FormFields): Record => { + const errors: Record = {}; + value.locations.forEach((loc, locIdx) => { + const entries = loc.ips.map((ip, ipIdx) => ({ + path: `locations[${locIdx}].ips[${ipIdx}].modifiable_part`, + ip: + ip.modifiable_part.trim().length > 0 + ? `${ip.network_part}${ip.modifiable_part}` + : '', + })); + getDuplicateIpFieldPaths(entries).forEach((path) => { + errors[path] = m.modal_assign_user_ip_duplicate_error(); + }); + }); + return errors; +}; + export const AssignUserDeviceIPModal = () => { const [isOpen, setOpen] = useState(false); const [modalData, setModalData] = useState(null); @@ -141,6 +160,20 @@ const AssignmentForm = ({ onSubmit: formSchema, }, onSubmit: async ({ value }) => { + const duplicateFields = buildDuplicateFieldErrors(value); + if (Object.keys(duplicateFields).length > 0) { + form.setErrorMap({ + onSubmit: { + fields: Object.fromEntries( + Object.entries(duplicateFields).map(([path, message]) => [ + path, + createZodIssue(message, [path]), + ]), + ), + }, + }); + return; + } await updateDevice(value); }, }); @@ -219,7 +252,7 @@ const AssignmentForm = ({ data={ipData} value={field.state.value} loading={field.state.meta.isValidating} - error={field.state.meta.errors[0]?.toString()} + error={zodIssueMessage(field.state.meta.errors[0])} onChange={(val) => field.handleChange(val ?? '')} onBlur={field.handleBlur} helper={m.form_helper_assigned_ip_address()} diff --git a/web/src/shared/utils/ipFieldDuplicates.ts b/web/src/shared/utils/ipFieldDuplicates.ts new file mode 100644 index 0000000000..1a7126e1d8 --- /dev/null +++ b/web/src/shared/utils/ipFieldDuplicates.ts @@ -0,0 +1,19 @@ +export type IpFieldEntry = { + path: string; + ip: string; +}; + +export const getDuplicateIpFieldPaths = (entries: IpFieldEntry[]): Set => { + const firstPathByIp = new Map(); + const duplicatePaths = new Set(); + for (const entry of entries) { + if (entry.ip.length === 0) continue; + const firstPath = firstPathByIp.get(entry.ip); + if (firstPath === undefined) { + firstPathByIp.set(entry.ip, entry.path); + } else { + duplicatePaths.add(entry.path); + } + } + return duplicatePaths; +}; From 3f2bf777456bfc2c916eddb66525af7b7e2d8a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Wed, 8 Jul 2026 11:41:29 +0200 Subject: [PATCH 2/9] fix #2931 --- .../modals/CEGroupModal/CEGroupModal.tsx | 30 ++++++++++--------- .../GroupsPage/modals/CEGroupModal/style.scss | 12 -------- 2 files changed, 16 insertions(+), 26 deletions(-) delete mode 100644 web/src/pages/GroupsPage/modals/CEGroupModal/style.scss diff --git a/web/src/pages/GroupsPage/modals/CEGroupModal/CEGroupModal.tsx b/web/src/pages/GroupsPage/modals/CEGroupModal/CEGroupModal.tsx index fc8742b4a6..1aa82f652b 100644 --- a/web/src/pages/GroupsPage/modals/CEGroupModal/CEGroupModal.tsx +++ b/web/src/pages/GroupsPage/modals/CEGroupModal/CEGroupModal.tsx @@ -1,27 +1,28 @@ -import z from 'zod'; -import { m } from '../../../../paraglide/messages'; -import { Modal } from '../../../../shared/defguard-ui/components/Modal/Modal'; -import { - closeModal, - subscribeCloseModal, - subscribeOpenModal, -} from '../../../../shared/hooks/modalControls/modalsSubjects'; -import { ModalName } from '../../../../shared/hooks/modalControls/modalTypes'; -import type { OpenCEGroupModal } from '../../../../shared/hooks/modalControls/types'; -import './style.scss'; import { useMutation } from '@tanstack/react-query'; import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from 'react'; +import z from 'zod'; +import { m } from '../../../../paraglide/messages'; import api from '../../../../shared/api/api'; import type { CreateGroupRequest, User } from '../../../../shared/api/types'; +import { DescriptionBlock } from '../../../../shared/components/DescriptionBlock/DescriptionBlock'; import { SelectionSection } from '../../../../shared/components/SelectionSection/SelectionSection'; import type { SelectionOption } from '../../../../shared/components/SelectionSection/type'; import { Checkbox } from '../../../../shared/defguard-ui/components/Checkbox/Checkbox'; import { Divider } from '../../../../shared/defguard-ui/components/Divider/Divider'; +import { Modal } from '../../../../shared/defguard-ui/components/Modal/Modal'; import { ModalControls } from '../../../../shared/defguard-ui/components/ModalControls/ModalControls'; +import { SizedBox } from '../../../../shared/defguard-ui/components/SizedBox/SizedBox'; import { ThemeSpacing } from '../../../../shared/defguard-ui/types'; import { isPresent } from '../../../../shared/defguard-ui/utils/isPresent'; import { useAppForm } from '../../../../shared/form'; import { formChangeLogic } from '../../../../shared/formLogic'; +import { + closeModal, + subscribeCloseModal, + subscribeOpenModal, +} from '../../../../shared/hooks/modalControls/modalsSubjects'; +import { ModalName } from '../../../../shared/hooks/modalControls/modalTypes'; +import type { OpenCEGroupModal } from '../../../../shared/hooks/modalControls/types'; interface ModalState extends OpenCEGroupModal { step: 'start' | 'users'; @@ -225,9 +226,10 @@ const StartStep = ({ reservedNames, setModalState, groupInfo, startForm }: StepP -

{m.modal_add_group_admin_title()}

-

{m.modal_add_group_admin_explain()}

- + +

{m.modal_add_group_admin_explain()}

+
+ p { - &:nth-of-type(1) { - font: var(--t-body-sm-500); - } - - &:nth-of-type(2) { - font: var(--t-body-sm-400); - color: var(--fg-muted); - } - } -} From f65d5f226bac3edcf0eaf3ac2f1ca2093f591f98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Wed, 8 Jul 2026 11:43:50 +0200 Subject: [PATCH 3/9] Update defguard-ui --- web/src/shared/defguard-ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index fe79945735..51c2f3f110 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit fe799457355647ef99b75484993bd8d3882c0319 +Subproject commit 51c2f3f11026c58bbd0873400eeaaec54b6a1f82 From b7fdb8d7c232a83b20b5b44ec4458994148255ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Wed, 8 Jul 2026 13:33:59 +0200 Subject: [PATCH 4/9] fix #3148 fix #3039 --- web/src/app/App.tsx | 25 +++++++++++++++++++ .../ProfileGeneralCard/ProfileGeneralCard.tsx | 11 +++++++- web/src/shared/defguard-ui | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 46956be785..080cd88b20 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -2,11 +2,36 @@ import './day'; import { QueryClientProvider } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; +import { useEffect } from 'react'; import { AppThemeProvider } from '../shared/providers/AppThemeProvider'; import { queryClient } from './query'; import { router } from './router'; export const App = () => { + useEffect(() => { + // Safety net: if a modal is open and the user navigates to a route that + // doesn't render it, ModalFoundation's unmount cleanup can fail to fire, + // leaving body scroll locked with no modal actually on screen. + const modalsRoot = document.getElementById('modals-root'); + const rootElement = document.getElementById('root'); + if (!modalsRoot || !rootElement) return; + + const observer = new MutationObserver(() => { + if ( + modalsRoot.childElementCount === 0 && + rootElement.style.overflowY === 'hidden' + ) { + rootElement.style.overflowY = 'auto'; + } + }); + + observer.observe(modalsRoot, { childList: true }); + + return () => { + observer.disconnect(); + }; + }, []); + return ( diff --git a/web/src/pages/user-profile/UserProfilePage/tabs/ProfileDetailsTab/components/ProfileGeneralCard/ProfileGeneralCard.tsx b/web/src/pages/user-profile/UserProfilePage/tabs/ProfileDetailsTab/components/ProfileGeneralCard/ProfileGeneralCard.tsx index 72e1ad0a12..f902d8c44a 100644 --- a/web/src/pages/user-profile/UserProfilePage/tabs/ProfileDetailsTab/components/ProfileGeneralCard/ProfileGeneralCard.tsx +++ b/web/src/pages/user-profile/UserProfilePage/tabs/ProfileDetailsTab/components/ProfileGeneralCard/ProfileGeneralCard.tsx @@ -1,6 +1,7 @@ import './style.scss'; import { revalidateLogic, useStore } from '@tanstack/react-form'; import { useMutation } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; import { useEffect } from 'react'; import z from 'zod'; import { useShallow } from 'zustand/react/shallow'; @@ -59,6 +60,7 @@ const zodSchema = z.object({ type FormFields = z.infer; export const ProfileGeneralCard = () => { + const navigate = useNavigate({ from: '/user/$username' }); const profileUser = useUserProfile((s) => s.user); const isAdmin = useAuth((s) => s.isAdmin); @@ -95,10 +97,17 @@ export const ProfileGeneralCard = () => { onChange: zodSchema, }, onSubmit: async ({ value }) => { + const previousUsername = profileUser.username; await mutateAsync({ - username: profileUser.username, + username: previousUsername, body: { ...profileUser, ...value }, }); + if (value.username !== previousUsername) { + navigate({ + to: '/user/$username', + params: { username: value.username }, + }); + } }, }); diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index 51c2f3f110..97a3cc2185 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit 51c2f3f11026c58bbd0873400eeaaec54b6a1f82 +Subproject commit 97a3cc218576e3d8631d0a762768827b8346d22a From f3b3139f98166f70bd3781207e166ecf17e87fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Wed, 8 Jul 2026 13:36:57 +0200 Subject: [PATCH 5/9] fix #3153 --- .../src/enterprise/handlers/device_posture.rs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/crates/defguard_core/src/enterprise/handlers/device_posture.rs b/crates/defguard_core/src/enterprise/handlers/device_posture.rs index 635e2f53da..fedcfd9f9f 100644 --- a/crates/defguard_core/src/enterprise/handlers/device_posture.rs +++ b/crates/defguard_core/src/enterprise/handlers/device_posture.rs @@ -669,7 +669,6 @@ pub async fn create_device_posture( responses( (status = 200, description = "Valid device posture OS and client versions", body = DevicePostureVersionMetadata), (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - enterprise license required") ), security( ("cookie" = []), @@ -681,11 +680,7 @@ pub async fn create_device_posture( /// # Errors /// /// Returns an error when the requester is unauthorized or lacks the required license. -pub async fn get_device_posture_versions( - _license: LicenseGated, - _admin: AdminRole, - session: SessionInfo, -) -> ApiResult { +pub async fn get_device_posture_versions(_admin: AdminRole, session: SessionInfo) -> ApiResult { debug!( "User {} fetching device posture version metadata", session.user.username @@ -708,7 +703,6 @@ pub async fn get_device_posture_versions( responses( (status = 200, description = "Paginated list of device posture check policies", body = [ApiDevicePosture]), (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - enterprise license required"), (status = 500, description = "Internal server error") ), security( @@ -717,7 +711,6 @@ pub async fn get_device_posture_versions( ) )] pub async fn list_device_postures( - _license: LicenseGated, _admin: AdminRole, session: SessionInfo, pagination: Query, @@ -783,7 +776,6 @@ pub async fn list_device_postures( responses( (status = 200, description = "Device posture check policy", body = ApiDevicePosture), (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - enterprise license required"), (status = 404, description = "Not found"), (status = 500, description = "Internal server error") ), @@ -793,7 +785,6 @@ pub async fn list_device_postures( ) )] pub async fn get_device_posture( - _license: LicenseGated, _admin: AdminRole, session: SessionInfo, Path(id): Path, From 69ab254739d88d09028cb27f3788624bf57c0062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Wed, 8 Jul 2026 13:58:25 +0200 Subject: [PATCH 6/9] fix tests for posture endpoints --- .../tests/integration/api/device_posture.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/defguard_core/tests/integration/api/device_posture.rs b/crates/defguard_core/tests/integration/api/device_posture.rs index 2cfa4d0174..0ef650c786 100644 --- a/crates/defguard_core/tests/integration/api/device_posture.rs +++ b/crates/defguard_core/tests/integration/api/device_posture.rs @@ -58,16 +58,22 @@ async fn test_device_posture_enterprise_license_required( let edit = make_edit("test"); let saved = get_cached_license().clone(); - // no license → 403 + // no license → GET is accessible, POST is forbidden set_cached_license(None); let response = client.get("/api/v1/device-posture").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response = client + .post("/api/v1/device-posture") + .json(&edit) + .send() + .await; assert_eq!(response.status(), StatusCode::FORBIDDEN); client.assert_event_queue_is_empty(); - // business-only license (default from make_test_client) → 403 + // business-only license (default from make_test_client) → GET is accessible, POST is forbidden set_cached_license(saved.clone()); // restore Business tier let response = client.get("/api/v1/device-posture").send().await; - assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(response.status(), StatusCode::OK); let response = client .post("/api/v1/device-posture") .json(&edit) From 87ab2837557f5e013a7cb53c6a8fca5353580e33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Wed, 8 Jul 2026 14:14:00 +0200 Subject: [PATCH 7/9] fix #3152 --- .../src/grpc/proxy/client_mfa.rs | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/crates/defguard_core/src/grpc/proxy/client_mfa.rs b/crates/defguard_core/src/grpc/proxy/client_mfa.rs index 7669da3927..c8f70697f2 100644 --- a/crates/defguard_core/src/grpc/proxy/client_mfa.rs +++ b/crates/defguard_core/src/grpc/proxy/client_mfa.rs @@ -227,17 +227,17 @@ impl ClientMfaServer { pubkey: request.pubkey.clone(), device_posture_data: request.posture_data.clone(), }; - let posture_result = validate_posture(&self.pool, &posture_request) - .await - .map_err(|err| match err { - PostureCheckError::NoActiveEnterpriseLicense => Status::failed_precondition( - "enterprise license required for posture checks", - ), - PostureCheckError::DbError(e) => { - error!("DB error during posture validation: {e}"); - Status::internal("unexpected error") - } - })?; + let posture_result = match validate_posture(&self.pool, &posture_request).await { + Ok(result) => result, + Err(PostureCheckError::NoActiveEnterpriseLicense) => { + debug!("No active license - skipping posture check for location {location}"); + PostureResult::Pass + } + Err(PostureCheckError::DbError(e)) => { + error!("DB error during posture validation: {e}"); + return Err(Status::internal("unexpected error")); + } + }; let (ip, _user_agent) = parse_client_ip_agent(&info).map_err(Status::internal)?; let context = @@ -930,18 +930,17 @@ impl ClientMfaServer { Self::validate_location_access(&self.pool, &location, &user_info).await?; // Evaluate posture. - let posture_result = - validate_posture(&self.pool, &request) - .await - .map_err(|err| match err { - PostureCheckError::NoActiveEnterpriseLicense => Status::failed_precondition( - "enterprise license required for posture checks", - ), - PostureCheckError::DbError(e) => { - error!("DB error during posture validation: {e}"); - Status::internal("unexpected error") - } - })?; + let posture_result = match validate_posture(&self.pool, &request).await { + Ok(result) => result, + Err(PostureCheckError::NoActiveEnterpriseLicense) => { + debug!("No active license - skipping posture check for location {location}"); + PostureResult::Pass + } + Err(PostureCheckError::DbError(e)) => { + error!("DB error during posture validation: {e}"); + return Err(Status::internal("unexpected error")); + } + }; // Posture check failed - return payload with reasons if let PostureResult::Fail(reasons) = posture_result { From dcf8beac0c718f620e7ceca4957c5f7b1985a024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Thu, 9 Jul 2026 11:16:36 +0200 Subject: [PATCH 8/9] fix #2947 --- web/messages/en/openid.json | 3 +- .../form/EditOktaProviderForm.tsx | 69 ++++++++++++------- 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/web/messages/en/openid.json b/web/messages/en/openid.json index 4372bdab69..6af1014ad7 100644 --- a/web/messages/en/openid.json +++ b/web/messages/en/openid.json @@ -55,7 +55,8 @@ "settings_openid_provider_label_okta_directory_sync_client_id": "Directory sync client ID", "settings_openid_provider_helper_okta_directory_sync_client_id": "", "settings_openid_provider_label_okta_directory_sync_client_private_key": "Directory sync client private key", - "settings_openid_provider_helper_okta_directory_sync_client_private_key": "", + "settings_openid_provider_helper_okta_directory_sync_client_private_key": "Leave blank to keep the current key unchanged.", + "settings_openid_provider_placeholder_okta_private_jwk": "Change private key", "settings_openid_provider_label_jumpcloud_api_key": "JumpCloud API key", "settings_openid_provider_helper_jumpcloud_api_key": "", "settings_openid_provider_label_jumpcloud_region": "JumpCloud region", diff --git a/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditOktaProviderForm.tsx b/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditOktaProviderForm.tsx index 50ac9067f7..a1cc6a8aba 100644 --- a/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditOktaProviderForm.tsx +++ b/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditOktaProviderForm.tsx @@ -1,3 +1,4 @@ +import { omit } from 'lodash-es'; import { useMemo } from 'react'; import z from 'zod'; import { m } from '../../../../paraglide/messages'; @@ -32,30 +33,34 @@ const discriminatedSchema = z.discriminatedUnion('directory_sync_enabled', [ syncSchema, ]); -const validationSchema = syncSchema - .omit({ okta_dirsync_client_id: true, okta_private_jwk: true }) - .extend({ - okta_dirsync_client_id: z.string(), - okta_private_jwk: z.string(), - }) - .superRefine((val, ctx) => { - if (val.directory_sync_enabled) { - if (val.okta_dirsync_client_id.trim().length === 0) { - ctx.addIssue({ - path: ['okta_dirsync_client_id'], - code: 'custom', - message: m.form_error_required(), - }); - } - if (val.okta_private_jwk.trim().length === 0) { - ctx.addIssue({ - path: ['okta_private_jwk'], - code: 'custom', - message: m.form_error_required(), - }); +const makeValidationSchema = (hadDirectorySyncConfigured: boolean) => + syncSchema + .omit({ okta_dirsync_client_id: true, okta_private_jwk: true }) + .extend({ + okta_dirsync_client_id: z.string(), + okta_private_jwk: z.string(), + }) + .superRefine((val, ctx) => { + if (val.directory_sync_enabled) { + if (val.okta_dirsync_client_id.trim().length === 0) { + ctx.addIssue({ + path: ['okta_dirsync_client_id'], + code: 'custom', + message: m.form_error_required(), + }); + } + // The private key is never sent back by the backend, so a blank value here means + // "keep the existing key" rather than "no key was ever set" - only require it when + // directory sync is being configured for the first time. + if (!hadDirectorySyncConfigured && val.okta_private_jwk.trim().length === 0) { + ctx.addIssue({ + path: ['okta_private_jwk'], + code: 'custom', + message: m.form_error_required(), + }); + } } - } - }); + }); type FormFields = z.infer; @@ -83,6 +88,13 @@ export const EditOktaProviderForm = ({ }; }, [provider]); + const hadDirectorySyncConfigured = Boolean(provider.okta_dirsync_client_id); + + const validationSchema = useMemo( + () => makeValidationSchema(hadDirectorySyncConfigured), + [hadDirectorySyncConfigured], + ); + const form = useAppForm({ defaultValues, validationLogic: formChangeLogic, @@ -91,6 +103,10 @@ export const EditOktaProviderForm = ({ onChange: validationSchema, }, onSubmit: async ({ value }) => { + if ('okta_private_jwk' in value && value.okta_private_jwk.trim().length === 0) { + await onSubmit(omit(value, ['okta_private_jwk'])); + return; + } await onSubmit(value); }, }); @@ -242,7 +258,12 @@ export const EditOktaProviderForm = ({ {(field) => ( Date: Thu, 9 Jul 2026 13:02:54 +0200 Subject: [PATCH 9/9] fixes #2629 --- .../defguard_core/src/handlers/wireguard.rs | 21 ++++++++-- .../integration/api/enterprise_settings.rs | 40 +++++++++++++++---- .../pages/UsersOverviewPage/UsersTable.tsx | 1 + .../ProfileDevicesTable.tsx | 26 ++++++++++-- .../AddDeviceModalStartStep.tsx | 7 +++- .../store/useAddUserDeviceModal.tsx | 3 ++ .../EditUserDeviceModal.tsx | 27 ++++++++----- web/src/shared/hooks/modalControls/types.ts | 1 + 8 files changed, 99 insertions(+), 27 deletions(-) diff --git a/crates/defguard_core/src/handlers/wireguard.rs b/crates/defguard_core/src/handlers/wireguard.rs index 01a5143a8a..ff96b79b46 100644 --- a/crates/defguard_core/src/handlers/wireguard.rs +++ b/crates/defguard_core/src/handlers/wireguard.rs @@ -1034,17 +1034,32 @@ pub(crate) async fn modify_device( debug!("User {} updating device {device_id}", session.user.username); let settings = EnterpriseSettings::get(&appstate.pool).await?; - if settings.only_client_activation && !session.is_admin { + if settings.admin_device_management && !session.is_admin { warn!( - "User {} tried to add a device, but manual device management is disaled", + "User {} tried to edit a device, but manual device management is disaled", session.user.username ); return Err(WebError::Forbidden("Manual device management is disabled")); } let mut device = device_for_admin_or_self(&appstate.pool, &session, device_id).await?; - // store device before mods let before = device.clone(); + + if settings.only_client_activation + && !session.is_admin + && (data.wireguard_pubkey != before.wireguard_pubkey + || data.description != before.description) + { + warn!( + "User {} tried to modify fields other than device name for device {device_id}, but \ + only client activation is enabled", + session.user.username + ); + return Err(WebError::BadRequest( + "Only the device name can be edited when only client activation is enabled".into(), + )); + } + let networks = WireguardNetwork::all(&appstate.pool).await?; if networks.is_empty() { diff --git a/crates/defguard_core/tests/integration/api/enterprise_settings.rs b/crates/defguard_core/tests/integration/api/enterprise_settings.rs index 7e4380ea3f..4704557464 100644 --- a/crates/defguard_core/tests/integration/api/enterprise_settings.rs +++ b/crates/defguard_core/tests/integration/api/enterprise_settings.rs @@ -353,6 +353,11 @@ async fn dg25_12_test_enforce_client_activation_only(_: PgPoolOptions, options: .send() .await; assert_eq!(response.status(), StatusCode::CREATED); + let created_device: serde_json::Value = response.json().await; + let device_id = created_device["device"]["id"].as_i64().unwrap(); + let device_pubkey = created_device["device"]["wireguard_pubkey"] + .as_str() + .unwrap(); // ensure normal users can't manage devices let auth = Auth::new("hpotter", "pass123"); @@ -371,23 +376,42 @@ async fn dg25_12_test_enforce_client_activation_only(_: PgPoolOptions, options: .await; assert_eq!(response.status(), StatusCode::FORBIDDEN); - // modify + // modify: renaming an existing device is still allowed let device = json!({ "name": "modifieddevice", - "wireguard_pubkey": "AJwxGkzvVVn5Q1xjpCDFo5RJSU9KOPHeoEixYaj+20M=", + "wireguard_pubkey": device_pubkey, }); - let response = client.put("/api/v1/device/2").json(&device).send().await; - - assert_eq!(response.status(), StatusCode::FORBIDDEN); + let response = client + .put(format!("/api/v1/device/{device_id}")) + .json(&device) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); - // delete + // modify: changing the pubkey is not allowed let device = json!({ "name": "modifieddevice", "wireguard_pubkey": "AJwxGkzvVVn5Q1xjpCDFo5RJSU9KOPHeoEixYaj+20M=", }); - let response = client.put("/api/v1/device/2").json(&device).send().await; + let response = client + .put(format!("/api/v1/device/{device_id}")) + .json(&device) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!(response.status(), StatusCode::FORBIDDEN); + // modify: changing the description is not allowed + let device = json!({ + "name": "modifieddevice", + "wireguard_pubkey": device_pubkey, + "description": "new description", + }); + let response = client + .put(format!("/api/v1/device/{device_id}")) + .json(&device) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[sqlx::test] diff --git a/web/src/pages/UsersOverviewPage/UsersTable.tsx b/web/src/pages/UsersOverviewPage/UsersTable.tsx index 8feaf24da2..7afc615a48 100644 --- a/web/src/pages/UsersOverviewPage/UsersTable.tsx +++ b/web/src/pages/UsersOverviewPage/UsersTable.tsx @@ -627,6 +627,7 @@ export const UsersTable = () => { reservedNames: reservedDeviceNames, reservedPubkeys, username, + hidePubkey: false, }); }, }, 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 54ddbf654c..1a78ea8be7 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 @@ -114,10 +114,19 @@ const DevicesTable = ({ rowData }: { rowData: RowData[] }) => { useAddUserDeviceModal.getState().open({ devices, user, + hideManualConfiguration: + !isAdmin && (enterpriseSettings?.only_client_activation ?? false), }); }, }), - [canModifyDevices, devices, user, info.network_present], + [ + canModifyDevices, + devices, + user, + info.network_present, + enterpriseSettings?.only_client_activation, + isAdmin, + ], ); const makeRowMenu = useCallback( @@ -132,6 +141,8 @@ const DevicesTable = ({ rowData }: { rowData: RowData[] }) => { reservedNames: reservedNames, reservedPubkeys: reservedPubkeys, username, + hidePubkey: + !isAdmin && (enterpriseSettings?.only_client_activation ?? false), }); }, }, @@ -158,7 +169,10 @@ const DevicesTable = ({ rowData }: { rowData: RowData[] }) => { }, }); } - if (row.networks.some((n) => n.location_mfa_mode === LocationMfaMode.Disabled)) { + if ( + row.networks.some((n) => n.location_mfa_mode === LocationMfaMode.Disabled) && + (!enterpriseSettings?.only_client_activation || isAdmin) + ) { items.push({ text: m.profile_devices_menu_show_config(), onClick: () => { @@ -192,7 +206,13 @@ const DevicesTable = ({ rowData }: { rowData: RowData[] }) => { }); return [{ items }]; }, - [reservedNames, username, isAdmin, reservedPubkeys], + [ + reservedNames, + username, + isAdmin, + reservedPubkeys, + enterpriseSettings?.only_client_activation, + ], ); const tableColumns = useMemo( diff --git a/web/src/shared/components/modals/AddUserDeviceModal/steps/AddDeviceModalStartStep/AddDeviceModalStartStep.tsx b/web/src/shared/components/modals/AddUserDeviceModal/steps/AddDeviceModalStartStep/AddDeviceModalStartStep.tsx index 08c50ea8be..53f1e091ab 100644 --- a/web/src/shared/components/modals/AddUserDeviceModal/steps/AddDeviceModalStartStep/AddDeviceModalStartStep.tsx +++ b/web/src/shared/components/modals/AddUserDeviceModal/steps/AddDeviceModalStartStep/AddDeviceModalStartStep.tsx @@ -1,6 +1,7 @@ import './style.scss'; import { useMutation } from '@tanstack/react-query'; import { useState } from 'react'; +import { useShallow } from 'zustand/react/shallow'; import { m } from '../../../../../../paraglide/messages'; import api from '../../../../../api/api'; import { Fold } from '../../../../../defguard-ui/components/Fold/Fold'; @@ -15,7 +16,9 @@ import manualImage from './assets/manual.png'; export const AddDeviceModalStartStep = () => { const [advancedOpen, setAdvancedOpen] = useState(false); - const user = useAddUserDeviceModal((s) => s.user); + const [user, hideManualConfiguration] = useAddUserDeviceModal( + useShallow((s) => [s.user, s.hideManualConfiguration]), + ); const { mutate: startClientActivation, isPending } = useMutation({ mutationFn: api.user.startClientActivation, @@ -32,7 +35,7 @@ export const AddDeviceModalStartStep = () => { if (!user) return null; - const showManualSetup = user.has_non_mfa_location_access; + const showManualSetup = user.has_non_mfa_location_access && !hideManualConfiguration; return (
diff --git a/web/src/shared/components/modals/AddUserDeviceModal/store/useAddUserDeviceModal.tsx b/web/src/shared/components/modals/AddUserDeviceModal/store/useAddUserDeviceModal.tsx index 22410284aa..436b02deac 100644 --- a/web/src/shared/components/modals/AddUserDeviceModal/store/useAddUserDeviceModal.tsx +++ b/web/src/shared/components/modals/AddUserDeviceModal/store/useAddUserDeviceModal.tsx @@ -12,6 +12,7 @@ interface StoreValues { user?: User; devices?: UserDevice[]; createDeviceResponse?: AddDeviceResponse; + hideManualConfiguration: boolean; manualConfig?: { publicKey: string; privateKey?: string; @@ -21,6 +22,7 @@ interface StoreValues { type OpenValues = { user: User; devices: UserDevice[]; + hideManualConfiguration: boolean; }; interface Store extends StoreValues { @@ -30,6 +32,7 @@ interface Store extends StoreValues { } const defaults: StoreValues = { + hideManualConfiguration: false, isOpen: false, step: AddUserDeviceModalStep.StartChoice, devices: undefined, diff --git a/web/src/shared/components/modals/EditUserDeviceModal/EditUserDeviceModal.tsx b/web/src/shared/components/modals/EditUserDeviceModal/EditUserDeviceModal.tsx index c772a960cd..18afa660f3 100644 --- a/web/src/shared/components/modals/EditUserDeviceModal/EditUserDeviceModal.tsx +++ b/web/src/shared/components/modals/EditUserDeviceModal/EditUserDeviceModal.tsx @@ -1,7 +1,7 @@ import { useStore } from '@tanstack/react-form'; import { useMutation } from '@tanstack/react-query'; import { cloneDeep } from 'lodash-es'; -import { useEffect, useMemo, useState } from 'react'; +import { Fragment, useEffect, useMemo, useState } from 'react'; import z from 'zod'; import { m } from '../../../../paraglide/messages'; import api from '../../../api/api'; @@ -72,6 +72,7 @@ const ModalContent = ({ reservedNames, reservedPubkeys, username, + hidePubkey, }: OpenEditDeviceModal) => { const formSchema = useMemo( () => @@ -131,16 +132,20 @@ const ModalContent = ({ /> )} - - - {(field) => ( - - )} - + {!hidePubkey && ( + + + + {(field) => ( + + )} + + + )}