Skip to content

Commit b15b325

Browse files
Faerbitclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 35d3a54 commit b15b325

9 files changed

Lines changed: 220 additions & 4 deletions

File tree

src-tauri/src/app_config.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ pub struct AppConfig {
7171
pub log_level: LevelFilter,
7272
/// In seconds. How much time after last network activity the connection is automatically dropped.
7373
pub peer_alive_period: u32,
74+
/// Instance ID to automatically connect to on startup. None means no auto-connect.
75+
pub default_instance: Option<i64>,
76+
/// Whether to also trigger MFA for locations that require it during auto-connect.
77+
pub auto_connect_mfa: bool,
7478
}
7579

7680
// Important: keep in sync with client store default in frontend
@@ -82,6 +86,8 @@ impl Default for AppConfig {
8286
tray_theme: AppTrayTheme::Color,
8387
log_level: LevelFilter::Info,
8488
peer_alive_period: 300,
89+
default_instance: None,
90+
auto_connect_mfa: false,
8591
}
8692
}
8793
}

src/i18n/en/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,17 @@ If you are an admin/devops - all your customers (instances) and all their tunnel
201201
title: 'Updates',
202202
checkboxTitle: 'Check for updates',
203203
},
204+
defaultInstance: {
205+
title: 'Default instance',
206+
helper:
207+
'The instance that will be automatically connected when the client is launched.',
208+
options: {
209+
none: 'None',
210+
},
211+
},
212+
autoConnectMfa: {
213+
title: 'Connect MFA locations on startup',
214+
},
204215
},
205216
},
206217
},

src/i18n/i18n-types.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,28 @@ type RootTranslation = {
478478
*/
479479
checkboxTitle: string
480480
}
481+
defaultInstance: {
482+
/**
483+
* D​e​f​a​u​l​t​ ​i​n​s​t​a​n​c​e
484+
*/
485+
title: string
486+
/**
487+
* 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​.
488+
*/
489+
helper: string
490+
options: {
491+
/**
492+
* N​o​n​e
493+
*/
494+
none: string
495+
}
496+
}
497+
autoConnectMfa: {
498+
/**
499+
* 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
500+
*/
501+
title: string
502+
}
481503
}
482504
}
483505
}
@@ -2168,6 +2190,28 @@ export type TranslationFunctions = {
21682190
*/
21692191
checkboxTitle: () => LocalizedString
21702192
}
2193+
defaultInstance: {
2194+
/**
2195+
* Default instance
2196+
*/
2197+
title: () => LocalizedString
2198+
/**
2199+
* The instance that will be automatically connected when the client is launched.
2200+
*/
2201+
helper: () => LocalizedString
2202+
options: {
2203+
/**
2204+
* None
2205+
*/
2206+
none: () => LocalizedString
2207+
}
2208+
}
2209+
autoConnectMfa: {
2210+
/**
2211+
* Connect MFA locations on startup
2212+
*/
2213+
title: () => LocalizedString
2214+
}
21712215
}
21722216
}
21732217
}

src/pages/client/ClientPage.tsx

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ import './style.scss';
22

33
import { useQuery, useQueryClient } from '@tanstack/react-query';
44
import { listen } from '@tauri-apps/api/event';
5-
import { useEffect } from 'react';
5+
import { error } from '@tauri-apps/plugin-log';
6+
import { useEffect, useRef } from 'react';
67
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
78
import { shallow } from 'zustand/shallow';
89
import AutoProvisioningManager from '../../components/AutoProvisioningManager';
910
import { useI18nContext } from '../../i18n/i18n-react';
1011
import { DeepLinkProvider } from '../../shared/components/providers/DeepLinkProvider';
1112
import { useToaster } from '../../shared/defguard-ui/hooks/toasts/useToaster';
1213
import { routes } from '../../shared/routes';
14+
import { errorDetail } from '../../shared/utils/errorDetail';
1315
import { clientApi } from './clientAPI/clientApi';
1416
import { ClientSideBar } from './components/ClientSideBar/ClientSideBar';
1517
import { MfaModalProvider } from './components/MfaModalProvider';
@@ -23,10 +25,11 @@ import {
2325
ClientConnectionType,
2426
type CommonWireguardFields,
2527
type DeadConDroppedPayload,
28+
LocationMfaType,
2629
TauriEventKey,
2730
} from './types';
2831

29-
const { getInstances, getTunnels, getAppConfig } = clientApi;
32+
const { getInstances, getTunnels, getAppConfig, getLocations, connect } = clientApi;
3033

3134
export const ClientPage = () => {
3235
const queryClient = useQueryClient();
@@ -40,6 +43,8 @@ export const ClientPage = () => {
4043
state.listChecked,
4144
state.setListChecked,
4245
]);
46+
// Ref (not state) so the flag persists across re-renders without triggering them.
47+
const autoConnectAttempted = useRef(false);
4348
const location = useLocation();
4449
const toaster = useToaster();
4550
const openDeadConDroppedModal = useDeadConDroppedModal((s) => s.open);
@@ -221,6 +226,47 @@ export const ClientPage = () => {
221226
// eslint-disable-next-line react-hooks/exhaustive-deps
222227
}, [appConfig]);
223228

229+
// Auto-connect the configured default instance once on startup.
230+
useEffect(() => {
231+
if (autoConnectAttempted.current || !instances || !appConfig) return;
232+
const defaultInstanceId = appConfig.default_instance;
233+
if (defaultInstanceId === null) return;
234+
const instance = instances.find((i) => i.id === defaultInstanceId);
235+
if (!instance) return;
236+
autoConnectAttempted.current = true;
237+
setClientState({
238+
selectedInstance: { id: instance.id, type: ClientConnectionType.LOCATION },
239+
});
240+
getLocations({ instanceId: instance.id })
241+
.then((locations) => {
242+
for (const loc of locations) {
243+
const mfaEnabled =
244+
loc.location_mfa_mode === LocationMfaType.INTERNAL ||
245+
loc.location_mfa_mode === LocationMfaType.EXTERNAL;
246+
// MFA locations must go through the modal flow which collects
247+
// credentials before calling connect — calling connect directly
248+
// would mark the location active in the DB without a tunnel.
249+
if (mfaEnabled) {
250+
if (appConfig.auto_connect_mfa) openMFAModal(loc, true);
251+
} else {
252+
connect({ locationId: loc.id, connectionType: ClientConnectionType.LOCATION }).catch(
253+
(e) => {
254+
const detail = errorDetail(e);
255+
error(`Auto-connect failed for location ${loc.id}: ${detail}`);
256+
toaster.error(LL.common.messages.errorWithMessage({ message: detail }));
257+
},
258+
);
259+
}
260+
}
261+
})
262+
.catch((e) => {
263+
const detail = errorDetail(e);
264+
error(`Auto-connect failed to fetch locations for instance ${instance.id}: ${detail}`);
265+
toaster.error(LL.common.messages.errorWithMessage({ message: detail }));
266+
});
267+
// eslint-disable-next-line react-hooks/exhaustive-deps
268+
}, [instances, appConfig]);
269+
224270
// navigate to carousel on first app Launch
225271
useEffect(() => {
226272
if (!location.pathname.includes(routes.client.carousel) && firstLaunch) {

src/pages/client/clientAPI/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ export type AppConfig = {
8080
tray_theme: TrayIconTheme;
8181
check_for_updates: boolean;
8282
peer_alive_period: number;
83+
default_instance: number | null;
84+
auto_connect_mfa: boolean;
8385
};
8486

8587
export type ProvisioningConfig = {

src/pages/client/hooks/useClientStore.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ const defaultValues: StoreValues = {
3535
tray_theme: 'color',
3636
check_for_updates: true,
3737
peer_alive_period: 300,
38+
default_instance: null,
39+
auto_connect_mfa: false,
3840
},
3941
};
4042

src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/MFAModal.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,18 @@ const OpenIDMFALogin = ({
392392
const localLL = LL.modals.mfa.authentication;
393393
const { openLink } = clientApi;
394394
const displayName = openidDisplayName || 'OpenID provider';
395+
const autoConnect = useMFAModal((state) => state.autoConnect);
396+
397+
// When triggered by startup auto-connect, open the browser and advance to
398+
// the pending screen without requiring a button click.
399+
useEffect(() => {
400+
if (autoConnect) {
401+
openLink(`${proxyUrl}openid/mfa?token=${token}`);
402+
setScreen('openid_pending');
403+
}
404+
// only fire once when this screen first mounts during auto-connect
405+
// eslint-disable-next-line react-hooks/exhaustive-deps
406+
}, []);
395407

396408
return (
397409
<div className="mfa-modal-content">

src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/useMFAModal.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import type { CommonWireguardFields } from '../../../../../../types';
55
const defaultValues: StoreValues = {
66
isOpen: false,
77
instance: undefined,
8+
autoConnect: false,
89
};
910

1011
export const useMFAModal = createWithEqualityFn<Store>(
1112
(set) => ({
1213
...defaultValues,
13-
open: (instance) => set({ instance, isOpen: true }),
14+
open: (instance, autoConnect = false) => set({ instance, isOpen: true, autoConnect }),
1415
close: () => set({ isOpen: false }),
1516
reset: () => set(defaultValues),
1617
}),
@@ -22,10 +23,11 @@ type Store = StoreValues & StoreMethods;
2223
type StoreValues = {
2324
isOpen: boolean;
2425
instance?: CommonWireguardFields;
26+
autoConnect: boolean;
2527
};
2628

2729
type StoreMethods = {
28-
open: (instance: CommonWireguardFields) => void;
30+
open: (instance: CommonWireguardFields, autoConnect?: boolean) => void;
2931
close: () => void;
3032
reset: () => void;
3133
};

src/pages/client/pages/ClientSettingsPage/components/GlobalSettingsTab/GlobalSettingsTab.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import { zodResolver } from '@hookform/resolvers/zod';
44
import { useMutation } from '@tanstack/react-query';
55
import { useCallback, useEffect, useMemo, useState } from 'react';
66
import {
7+
type Control,
78
type SubmitHandler,
89
type UseControllerProps,
910
useController,
1011
useForm,
12+
useWatch,
1113
} from 'react-hook-form';
1214
import { z } from 'zod';
1315
import { shallow } from 'zustand/shallow';
@@ -39,6 +41,7 @@ import {
3941
type TrayIconTheme,
4042
} from '../../../../clientAPI/types';
4143
import { useClientStore } from '../../../../hooks/useClientStore';
44+
import type { DefguardInstance } from '../../../../types';
4245

4346
type FormFields = AppConfig;
4447

@@ -81,6 +84,8 @@ export const GlobalSettingsTab = () => {
8184
required_error: LL.form.errors.required(),
8285
})
8386
.gte(120, LL.form.errors.minValue({ min: 120 })),
87+
default_instance: z.number().nullable(),
88+
auto_connect_mfa: z.boolean(),
8489
}),
8590
[LL.form.errors],
8691
);
@@ -139,6 +144,26 @@ export const GlobalSettingsTab = () => {
139144
</header>
140145
<FormInput controller={{ control, name: 'peer_alive_period' }} type="number" />
141146
</section>
147+
<section>
148+
<header>
149+
<h2>{localLL.defaultInstance.title()}</h2>
150+
<Helper initialPlacement="right">
151+
<p>{localLL.defaultInstance.helper()}</p>
152+
</Helper>
153+
</header>
154+
<DefaultInstanceSelect controller={{ control, name: 'default_instance' }} />
155+
<AutoConnectMfaOption control={control} controller={{ control, name: 'auto_connect_mfa' }} />
156+
</section>
157+
<section>
158+
<header>
159+
<h2>{localLL.defaultInstance.title()}</h2>
160+
<Helper initialPlacement="right">
161+
<p>{localLL.defaultInstance.helper()}</p>
162+
</Helper>
163+
</header>
164+
<DefaultInstanceSelect controller={{ control, name: 'default_instance' }} />
165+
<AutoConnectMfaOption control={control} controller={{ control, name: 'auto_connect_mfa' }} />
166+
</section>
142167
</form>
143168
);
144169
};
@@ -330,3 +355,69 @@ const CheckForUpdatesOption = ({ controller }: FormMemberProps) => {
330355
/>
331356
);
332357
};
358+
359+
const AutoConnectMfaOption = ({
360+
controller,
361+
control,
362+
}: FormMemberProps & { control: Control<FormFields> }) => {
363+
const { LL } = useI18nContext();
364+
const localLL = LL.pages.client.pages.settingsPage.tabs.global;
365+
const defaultInstance = useWatch({ control, name: 'default_instance' });
366+
367+
return (
368+
<FormCheckBox
369+
labelPlacement="right"
370+
label={localLL.autoConnectMfa.title()}
371+
controller={controller}
372+
disabled={defaultInstance === null}
373+
/>
374+
);
375+
};
376+
377+
const DefaultInstanceSelect = ({ controller }: FormMemberProps) => {
378+
const { LL } = useI18nContext();
379+
const localLL = LL.pages.client.pages.settingsPage.tabs.global.defaultInstance;
380+
const instances = useClientStore((state) => state.instances);
381+
382+
const options = useMemo((): SelectOption<number | null>[] => {
383+
const noneOption: SelectOption<number | null> = {
384+
key: -1,
385+
label: localLL.options.none(),
386+
value: null,
387+
};
388+
const instanceOptions: SelectOption<number | null>[] = instances.map(
389+
(instance: DefguardInstance) => ({
390+
key: instance.id,
391+
label: instance.name,
392+
value: instance.id,
393+
}),
394+
);
395+
return [noneOption, ...instanceOptions];
396+
}, [instances, localLL.options]);
397+
398+
const renderSelected = useCallback(
399+
(value: number | null): SelectSelectedValue => {
400+
const option = options.find((o) => o.value === value);
401+
if (option) {
402+
return {
403+
key: option.key,
404+
displayValue: option.label,
405+
};
406+
}
407+
return {
408+
key: -1,
409+
displayValue: localLL.options.none(),
410+
};
411+
},
412+
[options, localLL.options],
413+
);
414+
415+
return (
416+
<FormSelect
417+
sizeVariant={SelectSizeVariant.STANDARD}
418+
options={options}
419+
renderSelected={renderSelected}
420+
controller={controller}
421+
/>
422+
);
423+
};

0 commit comments

Comments
 (0)