From 7dcf0b6a183fbf85e4f123ff09233bb43d2a296a Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:03:32 +0200 Subject: [PATCH 1/2] fix conflicting route behaviour when connecting to an mfa location --- .../LocationCard/api/connectError.test.ts | 31 +++++++++ .../LocationCard/api/connectError.tsx | 11 ++++ .../LocationCard/context/context.tsx | 6 ++ .../OverviewLocationCard.tsx | 26 +++++--- .../shared/providers/TauriEventProvider.tsx | 66 ++++++++++++------- new-ui/src/shared/rust-api/api.ts | 4 ++ new-ui/src/shared/rust-api/types.ts | 1 + src-tauri/src/commands.rs | 11 ++++ src-tauri/src/gui.rs | 1 + 9 files changed, 124 insertions(+), 33 deletions(-) create mode 100644 new-ui/src/shared/components/LocationCard/api/connectError.test.ts diff --git a/new-ui/src/shared/components/LocationCard/api/connectError.test.ts b/new-ui/src/shared/components/LocationCard/api/connectError.test.ts new file mode 100644 index 00000000..b1b1c82f --- /dev/null +++ b/new-ui/src/shared/components/LocationCard/api/connectError.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ConnectionType, type LocationInfo } from '../../../rust-api/types'; +import { findRouteConflicts } from './connectError'; + +const mocks = vi.hoisted(() => ({ checkRouteConflict: vi.fn() })); + +vi.mock('@tauri-apps/api/event', () => ({ emitTo: vi.fn() })); +vi.mock('../../../rust-api/api', () => ({ + api: { checkRouteConflict: mocks.checkRouteConflict }, +})); + +const location = { id: 7 } as LocationInfo; +const conflict = { id: 3, connection_type: ConnectionType.Location, name: 'Office' }; + +describe('findRouteConflicts', () => { + it('returns the conflicts only for a route conflict error', async () => { + mocks.checkRouteConflict.mockRejectedValueOnce({ + kind: 'routeConflict', + message: 'conflict', + conflicts: [conflict], + }); + await expect(findRouteConflicts(location)).resolves.toEqual([conflict]); + expect(mocks.checkRouteConflict).toHaveBeenCalledWith(7); + + mocks.checkRouteConflict.mockResolvedValueOnce(undefined); + await expect(findRouteConflicts(location)).resolves.toEqual([]); + + mocks.checkRouteConflict.mockRejectedValueOnce({ kind: 'other', message: 'db' }); + await expect(findRouteConflicts(location)).resolves.toEqual([]); + }); +}); diff --git a/new-ui/src/shared/components/LocationCard/api/connectError.tsx b/new-ui/src/shared/components/LocationCard/api/connectError.tsx index e7890ba9..f61fbfa9 100644 --- a/new-ui/src/shared/components/LocationCard/api/connectError.tsx +++ b/new-ui/src/shared/components/LocationCard/api/connectError.tsx @@ -32,6 +32,17 @@ export const parseConnectError = (err: unknown): ConnectError | null => { return result.success ? result.data : null; }; +export const findRouteConflicts = ( + location: LocationInfo, +): Promise => + api.checkRouteConflict(location.id).then( + () => [], + (err: unknown) => { + const connectError = parseConnectError(err); + return connectError?.kind === 'routeConflict' ? connectError.conflicts : []; + }, + ); + export const handleFullViewConnectError = ( location: LocationInfo, err: unknown, diff --git a/new-ui/src/shared/components/LocationCard/context/context.tsx b/new-ui/src/shared/components/LocationCard/context/context.tsx index edb19ada..3bad3050 100644 --- a/new-ui/src/shared/components/LocationCard/context/context.tsx +++ b/new-ui/src/shared/components/LocationCard/context/context.tsx @@ -23,6 +23,7 @@ import { shouldStartMfa, usableMfaMethods, } from '../../../utils/mfa'; +import { findRouteConflicts, openRouteConflictInFullView } from '../api/connectError'; import { LocationCardViews, type LocationCardViewsValue, @@ -136,6 +137,11 @@ export const LocationCardProvider = ({ ); const startMfa = useCallback(async () => { + const conflicts = await findRouteConflicts(location); + if (conflicts.length > 0) { + await openRouteConflictInFullView({ location, conflicts }); + return; + } mfaStarted.current = true; const appConfig = await api.getAppConfig(); setAutoConnectOpenid(appConfig.auto_start_openid_mfa); diff --git a/new-ui/src/shared/components/OverviewLocationCard/OverviewLocationCard.tsx b/new-ui/src/shared/components/OverviewLocationCard/OverviewLocationCard.tsx index 4561959e..5bcc0b61 100644 --- a/new-ui/src/shared/components/OverviewLocationCard/OverviewLocationCard.tsx +++ b/new-ui/src/shared/components/OverviewLocationCard/OverviewLocationCard.tsx @@ -20,7 +20,11 @@ import { isPresent } from '../../utils/isPresent'; import { ConnectionAbility, resolveMfaStepPlan, shouldStartMfa } from '../../utils/mfa'; import { Divider } from '../Divider/Divider'; import { IconKind } from '../Icon'; -import { handleFullViewConnectError } from '../LocationCard/api/connectError'; +import { + findRouteConflicts, + handleFullViewConnectError, + openRouteConflictModal, +} from '../LocationCard/api/connectError'; import { ConnectButton } from '../LocationCard/components/ConnectButton/ConnectButton'; import { connectBlockedTooltip } from '../LocationCard/components/ConnectButton/consts'; import { LocationCardConnectionInfo } from '../LocationCard/components/LocationCardConnectionInfo/LocationCardConnectionInfo'; @@ -95,13 +99,19 @@ export const OverviewLocationCard = ({ location, instance }: Props) => { if (shouldStartMfa(location)) { const stepPlan = resolveMfaStepPlan(location, [], instance); - useConnectModal.getState().open({ - view: mfaMethodToConnectModalView(stepPlan[0]), - location, - stepPlan, - autoStartOpenId: appConfig.auto_start_openid_mfa, - mfaMethod: stepPlan[0], - }); + const openMfa = () => + useConnectModal.getState().open({ + view: mfaMethodToConnectModalView(stepPlan[0]), + location, + stepPlan, + autoStartOpenId: appConfig.auto_start_openid_mfa, + mfaMethod: stepPlan[0], + }); + void findRouteConflicts(location).then((conflicts) => + conflicts.length > 0 + ? openRouteConflictModal(location, conflicts, openMfa) + : openMfa(), + ); return; } diff --git a/new-ui/src/shared/providers/TauriEventProvider.tsx b/new-ui/src/shared/providers/TauriEventProvider.tsx index 8e353a2b..c1e7407f 100644 --- a/new-ui/src/shared/providers/TauriEventProvider.tsx +++ b/new-ui/src/shared/providers/TauriEventProvider.tsx @@ -8,6 +8,7 @@ import { runConfigureFactorsRequest } from '../../pages/full/ConfigureMfaPage/ho import { mfaMethodToConnectModalView } from '../../pages/full/OverviewPage/components/ConnectModal/hooks/types'; import { useConnectModal } from '../../pages/full/OverviewPage/components/ConnectModal/hooks/useConnectModal'; import { + findRouteConflicts, handleFullViewConnectError, openRouteConflictModal, type RouteConflictPayload, @@ -27,7 +28,7 @@ import { type TunnelsDisabledPayload, } from '../rust-api/types'; import { useAppStore } from '../store/useAppStore'; -import { resolveMfaStepPlan } from '../utils/mfa'; +import { resolveMfaStepPlan, shouldStartMfa } from '../utils/mfa'; export const TauriEventProvider = ({ children }: PropsWithChildren) => { const navigate = useNavigate(); @@ -35,6 +36,34 @@ export const TauriEventProvider = ({ children }: PropsWithChildren) => { const { setViewSelection } = useAppData(); useEffect(() => { + const openMfaModal = async (location: LocationInfo) => { + const appConfig = await api.getAppConfig(); + // A failed refresh keeps the cached instances. With none, the plan uses + // the steps' own flags, which beats dropping the prompt. + let instances = queryClient.getQueryData(getInstancesQueryOptions.queryKey); + try { + instances = await queryClient.fetchQuery(getInstancesQueryOptions); + } catch (err) { + void logError(`MFA prompt: failed to load instances: ${err}`); + } + const instance = instances?.find(({ id }) => id === location.instance_id); + const stepPlan = resolveMfaStepPlan(location, [], instance); + + await navigate({ to: '/full/overview' }); + useConnectModal.getState().open({ + view: mfaMethodToConnectModalView(stepPlan[0]), + location, + stepPlan, + autoStartOpenId: appConfig.auto_start_openid_mfa, + mfaMethod: stepPlan[0], + }); + }; + const openMfaModalOrLog = (location: LocationInfo) => { + void openMfaModal(location).catch((err) => { + void logError(`Failed to open the MFA prompt: ${err}`); + }); + }; + const unlisteners = Promise.all([ listen(TauriEvent.AddInstance, (event) => { void debug(`UI Received event AddInstance (${event.payload.url})`); @@ -74,30 +103,13 @@ export const TauriEventProvider = ({ children }: PropsWithChildren) => { connection_type: ConnectionType.Location, active: false, }; - void (async () => { - const appConfig = await api.getAppConfig(); - // A failed refresh keeps the cached instances. With none, the plan uses - // the steps' own flags, which beats dropping the prompt. - let instances = queryClient.getQueryData(getInstancesQueryOptions.queryKey); - try { - instances = await queryClient.fetchQuery(getInstancesQueryOptions); - } catch (err) { - void logError(`MfaTrigger: failed to load instances: ${err}`); - } - const instance = instances?.find(({ id }) => id === location.instance_id); - const stepPlan = resolveMfaStepPlan(location, [], instance); - - await navigate({ to: '/full/overview' }); - useConnectModal.getState().open({ - view: mfaMethodToConnectModalView(stepPlan[0]), - location, - stepPlan, - autoStartOpenId: appConfig.auto_start_openid_mfa, - mfaMethod: stepPlan[0], - }); - })().catch((err) => { - void logError(`MfaTrigger: failed to open the MFA prompt: ${err}`); - }); + void findRouteConflicts(location).then((conflicts) => + conflicts.length > 0 + ? openRouteConflictModal(location, conflicts, () => + openMfaModalOrLog(location), + ) + : openMfaModalOrLog(location), + ); } }, ), @@ -114,6 +126,10 @@ export const TauriEventProvider = ({ children }: PropsWithChildren) => { listen(TauriEvent.RouteConflict, (event) => { if (getCurrentWindow().label !== WindowId.FullView) return; const { location, conflicts } = event.payload; + if (shouldStartMfa(location)) { + openRouteConflictModal(location, conflicts, () => openMfaModalOrLog(location)); + return; + } // The connect is not awaited, so the confirm modal closes before a new error can reopen it. const connect = () => { void api diff --git a/new-ui/src/shared/rust-api/api.ts b/new-ui/src/shared/rust-api/api.ts index f21f3145..d3a2f5d9 100644 --- a/new-ui/src/shared/rust-api/api.ts +++ b/new-ui/src/shared/rust-api/api.ts @@ -65,6 +65,9 @@ const setLocationMfaStepPlan = (args: SetLocationMfaStepPlanArgs): Promise const connect = (args: ConnectionArgs): Promise => invoke(TauriCommand.Connect, args); +const checkRouteConflict = (locationId: number): Promise => + invoke(TauriCommand.CheckRouteConflict, { locationId }); + const disconnect = (args: ConnectionArgs): Promise => invoke(TauriCommand.Disconnect, args); @@ -348,6 +351,7 @@ export const api = { setLocationMfaStepPlan, // Connections connect, + checkRouteConflict, disconnect, getLastConnection, getConnectionHistory, diff --git a/new-ui/src/shared/rust-api/types.ts b/new-ui/src/shared/rust-api/types.ts index 2ccf9135..eb17ee50 100644 --- a/new-ui/src/shared/rust-api/types.ts +++ b/new-ui/src/shared/rust-api/types.ts @@ -122,6 +122,7 @@ export const TauriCommand = { SetLocationMfaStepPlan: 'set_location_mfa_step_plan', // Connections Connect: 'connect', + CheckRouteConflict: 'check_route_conflict', Disconnect: 'disconnect', LastConnection: 'last_connection', AllConnections: 'all_connections', diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index aa516e06..a5246abd 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -316,6 +316,17 @@ pub async fn connect( Ok(()) } +#[tauri::command(async)] +pub async fn check_route_conflict(location_id: Id) -> Result<(), ConnectError> { + let location = Location::find_by_id(&*DB_POOL, location_id) + .await? + .ok_or(Error::NotFound)?; + ConnectionTarget::Location(location) + .ensure_no_route_conflict(&DB_POOL, None) + .await?; + Ok(()) +} + #[tauri::command(async)] pub async fn start_global_logwatcher(handle: AppHandle) -> Result<(), Error> { let result = spawn_global_log_watcher_task(&handle, tracing::Level::DEBUG).await; diff --git a/src-tauri/src/gui.rs b/src-tauri/src/gui.rs index f5c28fab..88aeeaae 100644 --- a/src-tauri/src/gui.rs +++ b/src-tauri/src/gui.rs @@ -170,6 +170,7 @@ pub fn run_app() { save_device_config, all_instances, connect, + check_route_conflict, disconnect, update_instance, location_stats, From 762de2bd5ca9c8d18a826864695f71d31c055481 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:08:28 +0200 Subject: [PATCH 2/2] fix --- new-ui/src/shared/components/Button/style.scss | 1 + src-tauri/permissions/default.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/new-ui/src/shared/components/Button/style.scss b/new-ui/src/shared/components/Button/style.scss index a7f27e37..e11b12ce 100644 --- a/new-ui/src/shared/components/Button/style.scss +++ b/new-ui/src/shared/components/Button/style.scss @@ -55,6 +55,7 @@ font: var(--btn-font); color: inherit; user-select: none; + white-space: nowrap; @include animate(color); } diff --git a/src-tauri/permissions/default.toml b/src-tauri/permissions/default.toml index 610a1aba..0c0b6a3e 100644 --- a/src-tauri/permissions/default.toml +++ b/src-tauri/permissions/default.toml @@ -31,6 +31,7 @@ commands.allow = [ "save_device_config", "all_instances", "connect", + "check_route_conflict", "disconnect", "update_instance", "location_stats",