From b15b3256e71cc5eb8aeeb95aef6437be16b4c1cf Mon Sep 17 00:00:00 2001 From: Faerbit Date: Sat, 25 Apr 2026 17:11:12 +0200 Subject: [PATCH] Add default instance auto-connect on startup Adds a settings dropdown to configure which instance should be automatically connected when the client launches. A companion checkbox enables triggering MFA prompts during auto-connect for locations that require it (disabled by default). Selecting "None" (the default) preserves the existing behaviour of no auto-connect. Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/app_config.rs | 6 ++ src/i18n/en/index.ts | 11 +++ src/i18n/i18n-types.ts | 44 +++++++++ src/pages/client/ClientPage.tsx | 50 +++++++++- src/pages/client/clientAPI/types.ts | 2 + src/pages/client/hooks/useClientStore.tsx | 2 + .../modals/MFAModal/MFAModal.tsx | 12 +++ .../modals/MFAModal/useMFAModal.ts | 6 +- .../GlobalSettingsTab/GlobalSettingsTab.tsx | 91 +++++++++++++++++++ 9 files changed, 220 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 492d9023..10b2f19b 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -71,6 +71,10 @@ pub struct AppConfig { pub log_level: LevelFilter, /// In seconds. How much time after last network activity the connection is automatically dropped. pub peer_alive_period: u32, + /// Instance ID to automatically connect to on startup. None means no auto-connect. + pub default_instance: Option, + /// Whether to also trigger MFA for locations that require it during auto-connect. + pub auto_connect_mfa: bool, } // Important: keep in sync with client store default in frontend @@ -82,6 +86,8 @@ impl Default for AppConfig { tray_theme: AppTrayTheme::Color, log_level: LevelFilter::Info, peer_alive_period: 300, + default_instance: None, + auto_connect_mfa: false, } } } diff --git a/src/i18n/en/index.ts b/src/i18n/en/index.ts index 2dca9b4f..c47a45b1 100644 --- a/src/i18n/en/index.ts +++ b/src/i18n/en/index.ts @@ -201,6 +201,17 @@ If you are an admin/devops - all your customers (instances) and all their tunnel title: 'Updates', checkboxTitle: 'Check for updates', }, + defaultInstance: { + title: 'Default instance', + helper: + 'The instance that will be automatically connected when the client is launched.', + options: { + none: 'None', + }, + }, + autoConnectMfa: { + title: 'Connect MFA locations on startup', + }, }, }, }, diff --git a/src/i18n/i18n-types.ts b/src/i18n/i18n-types.ts index 088f2ae0..b9e12a7c 100644 --- a/src/i18n/i18n-types.ts +++ b/src/i18n/i18n-types.ts @@ -478,6 +478,28 @@ type RootTranslation = { */ checkboxTitle: string } + defaultInstance: { + /** + * D​e​f​a​u​l​t​ ​i​n​s​t​a​n​c​e + */ + title: string + /** + * T​h​e​ ​i​n​s​t​a​n​c​e​ ​t​h​a​t​ ​w​i​l​l​ ​b​e​ ​a​u​t​o​m​a​t​i​c​a​l​l​y​ ​c​o​n​n​e​c​t​e​d​ ​w​h​e​n​ ​t​h​e​ ​c​l​i​e​n​t​ ​i​s​ ​l​a​u​n​c​h​e​d​. + */ + helper: string + options: { + /** + * N​o​n​e + */ + none: string + } + } + autoConnectMfa: { + /** + * C​o​n​n​e​c​t​ ​M​F​A​ ​l​o​c​a​t​i​o​n​s​ ​o​n​ ​s​t​a​r​t​u​p + */ + title: string + } } } } @@ -2168,6 +2190,28 @@ export type TranslationFunctions = { */ checkboxTitle: () => LocalizedString } + defaultInstance: { + /** + * Default instance + */ + title: () => LocalizedString + /** + * The instance that will be automatically connected when the client is launched. + */ + helper: () => LocalizedString + options: { + /** + * None + */ + none: () => LocalizedString + } + } + autoConnectMfa: { + /** + * Connect MFA locations on startup + */ + title: () => LocalizedString + } } } } diff --git a/src/pages/client/ClientPage.tsx b/src/pages/client/ClientPage.tsx index 9358bf44..5706aea2 100644 --- a/src/pages/client/ClientPage.tsx +++ b/src/pages/client/ClientPage.tsx @@ -2,7 +2,8 @@ import './style.scss'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { listen } from '@tauri-apps/api/event'; -import { useEffect } from 'react'; +import { error } from '@tauri-apps/plugin-log'; +import { useEffect, useRef } from 'react'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { shallow } from 'zustand/shallow'; import AutoProvisioningManager from '../../components/AutoProvisioningManager'; @@ -10,6 +11,7 @@ import { useI18nContext } from '../../i18n/i18n-react'; import { DeepLinkProvider } from '../../shared/components/providers/DeepLinkProvider'; import { useToaster } from '../../shared/defguard-ui/hooks/toasts/useToaster'; import { routes } from '../../shared/routes'; +import { errorDetail } from '../../shared/utils/errorDetail'; import { clientApi } from './clientAPI/clientApi'; import { ClientSideBar } from './components/ClientSideBar/ClientSideBar'; import { MfaModalProvider } from './components/MfaModalProvider'; @@ -23,10 +25,11 @@ import { ClientConnectionType, type CommonWireguardFields, type DeadConDroppedPayload, + LocationMfaType, TauriEventKey, } from './types'; -const { getInstances, getTunnels, getAppConfig } = clientApi; +const { getInstances, getTunnels, getAppConfig, getLocations, connect } = clientApi; export const ClientPage = () => { const queryClient = useQueryClient(); @@ -40,6 +43,8 @@ export const ClientPage = () => { state.listChecked, state.setListChecked, ]); + // Ref (not state) so the flag persists across re-renders without triggering them. + const autoConnectAttempted = useRef(false); const location = useLocation(); const toaster = useToaster(); const openDeadConDroppedModal = useDeadConDroppedModal((s) => s.open); @@ -221,6 +226,47 @@ export const ClientPage = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [appConfig]); + // Auto-connect the configured default instance once on startup. + useEffect(() => { + if (autoConnectAttempted.current || !instances || !appConfig) return; + const defaultInstanceId = appConfig.default_instance; + if (defaultInstanceId === null) return; + const instance = instances.find((i) => i.id === defaultInstanceId); + if (!instance) return; + autoConnectAttempted.current = true; + setClientState({ + selectedInstance: { id: instance.id, type: ClientConnectionType.LOCATION }, + }); + getLocations({ instanceId: instance.id }) + .then((locations) => { + for (const loc of locations) { + const mfaEnabled = + loc.location_mfa_mode === LocationMfaType.INTERNAL || + loc.location_mfa_mode === LocationMfaType.EXTERNAL; + // MFA locations must go through the modal flow which collects + // credentials before calling connect — calling connect directly + // would mark the location active in the DB without a tunnel. + if (mfaEnabled) { + if (appConfig.auto_connect_mfa) openMFAModal(loc, true); + } else { + connect({ locationId: loc.id, connectionType: ClientConnectionType.LOCATION }).catch( + (e) => { + const detail = errorDetail(e); + error(`Auto-connect failed for location ${loc.id}: ${detail}`); + toaster.error(LL.common.messages.errorWithMessage({ message: detail })); + }, + ); + } + } + }) + .catch((e) => { + const detail = errorDetail(e); + error(`Auto-connect failed to fetch locations for instance ${instance.id}: ${detail}`); + toaster.error(LL.common.messages.errorWithMessage({ message: detail })); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [instances, appConfig]); + // navigate to carousel on first app Launch useEffect(() => { if (!location.pathname.includes(routes.client.carousel) && firstLaunch) { diff --git a/src/pages/client/clientAPI/types.ts b/src/pages/client/clientAPI/types.ts index 95ed9f93..4ee353ff 100644 --- a/src/pages/client/clientAPI/types.ts +++ b/src/pages/client/clientAPI/types.ts @@ -80,6 +80,8 @@ export type AppConfig = { tray_theme: TrayIconTheme; check_for_updates: boolean; peer_alive_period: number; + default_instance: number | null; + auto_connect_mfa: boolean; }; export type ProvisioningConfig = { diff --git a/src/pages/client/hooks/useClientStore.tsx b/src/pages/client/hooks/useClientStore.tsx index d77b509f..efbea551 100644 --- a/src/pages/client/hooks/useClientStore.tsx +++ b/src/pages/client/hooks/useClientStore.tsx @@ -35,6 +35,8 @@ const defaultValues: StoreValues = { tray_theme: 'color', check_for_updates: true, peer_alive_period: 300, + default_instance: null, + auto_connect_mfa: false, }, }; diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/MFAModal.tsx b/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/MFAModal.tsx index 2302090b..14576fd8 100644 --- a/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/MFAModal.tsx +++ b/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/MFAModal.tsx @@ -392,6 +392,18 @@ const OpenIDMFALogin = ({ const localLL = LL.modals.mfa.authentication; const { openLink } = clientApi; const displayName = openidDisplayName || 'OpenID provider'; + const autoConnect = useMFAModal((state) => state.autoConnect); + + // When triggered by startup auto-connect, open the browser and advance to + // the pending screen without requiring a button click. + useEffect(() => { + if (autoConnect) { + openLink(`${proxyUrl}openid/mfa?token=${token}`); + setScreen('openid_pending'); + } + // only fire once when this screen first mounts during auto-connect + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return (
diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/useMFAModal.ts b/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/useMFAModal.ts index 0947f745..a14dc460 100644 --- a/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/useMFAModal.ts +++ b/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/useMFAModal.ts @@ -5,12 +5,13 @@ import type { CommonWireguardFields } from '../../../../../../types'; const defaultValues: StoreValues = { isOpen: false, instance: undefined, + autoConnect: false, }; export const useMFAModal = createWithEqualityFn( (set) => ({ ...defaultValues, - open: (instance) => set({ instance, isOpen: true }), + open: (instance, autoConnect = false) => set({ instance, isOpen: true, autoConnect }), close: () => set({ isOpen: false }), reset: () => set(defaultValues), }), @@ -22,10 +23,11 @@ type Store = StoreValues & StoreMethods; type StoreValues = { isOpen: boolean; instance?: CommonWireguardFields; + autoConnect: boolean; }; type StoreMethods = { - open: (instance: CommonWireguardFields) => void; + open: (instance: CommonWireguardFields, autoConnect?: boolean) => void; close: () => void; reset: () => void; }; diff --git a/src/pages/client/pages/ClientSettingsPage/components/GlobalSettingsTab/GlobalSettingsTab.tsx b/src/pages/client/pages/ClientSettingsPage/components/GlobalSettingsTab/GlobalSettingsTab.tsx index 4d6dea45..9fc5e04c 100644 --- a/src/pages/client/pages/ClientSettingsPage/components/GlobalSettingsTab/GlobalSettingsTab.tsx +++ b/src/pages/client/pages/ClientSettingsPage/components/GlobalSettingsTab/GlobalSettingsTab.tsx @@ -4,10 +4,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation } from '@tanstack/react-query'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { + type Control, type SubmitHandler, type UseControllerProps, useController, useForm, + useWatch, } from 'react-hook-form'; import { z } from 'zod'; import { shallow } from 'zustand/shallow'; @@ -39,6 +41,7 @@ import { type TrayIconTheme, } from '../../../../clientAPI/types'; import { useClientStore } from '../../../../hooks/useClientStore'; +import type { DefguardInstance } from '../../../../types'; type FormFields = AppConfig; @@ -81,6 +84,8 @@ export const GlobalSettingsTab = () => { required_error: LL.form.errors.required(), }) .gte(120, LL.form.errors.minValue({ min: 120 })), + default_instance: z.number().nullable(), + auto_connect_mfa: z.boolean(), }), [LL.form.errors], ); @@ -139,6 +144,26 @@ export const GlobalSettingsTab = () => { +
+
+

{localLL.defaultInstance.title()}

+ +

{localLL.defaultInstance.helper()}

+
+
+ + +
+
+
+

{localLL.defaultInstance.title()}

+ +

{localLL.defaultInstance.helper()}

+
+
+ + +
); }; @@ -330,3 +355,69 @@ const CheckForUpdatesOption = ({ controller }: FormMemberProps) => { /> ); }; + +const AutoConnectMfaOption = ({ + controller, + control, +}: FormMemberProps & { control: Control }) => { + const { LL } = useI18nContext(); + const localLL = LL.pages.client.pages.settingsPage.tabs.global; + const defaultInstance = useWatch({ control, name: 'default_instance' }); + + return ( + + ); +}; + +const DefaultInstanceSelect = ({ controller }: FormMemberProps) => { + const { LL } = useI18nContext(); + const localLL = LL.pages.client.pages.settingsPage.tabs.global.defaultInstance; + const instances = useClientStore((state) => state.instances); + + const options = useMemo((): SelectOption[] => { + const noneOption: SelectOption = { + key: -1, + label: localLL.options.none(), + value: null, + }; + const instanceOptions: SelectOption[] = instances.map( + (instance: DefguardInstance) => ({ + key: instance.id, + label: instance.name, + value: instance.id, + }), + ); + return [noneOption, ...instanceOptions]; + }, [instances, localLL.options]); + + const renderSelected = useCallback( + (value: number | null): SelectSelectedValue => { + const option = options.find((o) => o.value === value); + if (option) { + return { + key: option.key, + displayValue: option.label, + }; + } + return { + key: -1, + displayValue: localLL.options.none(), + }; + }, + [options, localLL.options], + ); + + return ( + + ); +};