From 14b426aad2d545578abcf6ec7dc6d33b51b0ea94 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 | 40 ++++++++- src/pages/client/clientAPI/types.ts | 2 + src/pages/client/hooks/useClientStore.tsx | 2 + .../GlobalSettingsTab/GlobalSettingsTab.tsx | 81 +++++++++++++++++++ 7 files changed, 184 insertions(+), 2 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..996f1415 100644 --- a/src/pages/client/ClientPage.tsx +++ b/src/pages/client/ClientPage.tsx @@ -2,7 +2,7 @@ import './style.scss'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { listen } from '@tauri-apps/api/event'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { shallow } from 'zustand/shallow'; import AutoProvisioningManager from '../../components/AutoProvisioningManager'; @@ -23,10 +23,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 +41,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 +224,39 @@ 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); + } else { + connect({ locationId: loc.id, connectionType: ClientConnectionType.LOCATION }).catch( + () => undefined, + ); + } + } + }) + .catch(() => undefined); + // 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/ClientSettingsPage/components/GlobalSettingsTab/GlobalSettingsTab.tsx b/src/pages/client/pages/ClientSettingsPage/components/GlobalSettingsTab/GlobalSettingsTab.tsx index 4d6dea45..b56a7361 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,16 @@ export const GlobalSettingsTab = () => { +
+
+

{localLL.defaultInstance.title()}

+ +

{localLL.defaultInstance.helper()}

+
+
+ + +
); }; @@ -330,3 +345,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 ( + + ); +};