Skip to content

Commit c7d4286

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 c7d4286

8 files changed

Lines changed: 185 additions & 3 deletions

File tree

src-tauri/proto

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: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ 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 { useEffect, useRef } from 'react';
66
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
77
import { shallow } from 'zustand/shallow';
88
import AutoProvisioningManager from '../../components/AutoProvisioningManager';
@@ -23,10 +23,11 @@ import {
2323
ClientConnectionType,
2424
type CommonWireguardFields,
2525
type DeadConDroppedPayload,
26+
LocationMfaType,
2627
TauriEventKey,
2728
} from './types';
2829

29-
const { getInstances, getTunnels, getAppConfig } = clientApi;
30+
const { getInstances, getTunnels, getAppConfig, getLocations, connect } = clientApi;
3031

3132
export const ClientPage = () => {
3233
const queryClient = useQueryClient();
@@ -40,6 +41,8 @@ export const ClientPage = () => {
4041
state.listChecked,
4142
state.setListChecked,
4243
]);
44+
// Ref (not state) so the flag persists across re-renders without triggering them.
45+
const autoConnectAttempted = useRef(false);
4346
const location = useLocation();
4447
const toaster = useToaster();
4548
const openDeadConDroppedModal = useDeadConDroppedModal((s) => s.open);
@@ -221,6 +224,39 @@ export const ClientPage = () => {
221224
// eslint-disable-next-line react-hooks/exhaustive-deps
222225
}, [appConfig]);
223226

227+
// Auto-connect the configured default instance once on startup.
228+
useEffect(() => {
229+
if (autoConnectAttempted.current || !instances || !appConfig) return;
230+
const defaultInstanceId = appConfig.default_instance;
231+
if (defaultInstanceId === null) return;
232+
const instance = instances.find((i) => i.id === defaultInstanceId);
233+
if (!instance) return;
234+
autoConnectAttempted.current = true;
235+
setClientState({
236+
selectedInstance: { id: instance.id, type: ClientConnectionType.LOCATION },
237+
});
238+
getLocations({ instanceId: instance.id })
239+
.then((locations) => {
240+
for (const loc of locations) {
241+
const mfaEnabled =
242+
loc.location_mfa_mode === LocationMfaType.INTERNAL ||
243+
loc.location_mfa_mode === LocationMfaType.EXTERNAL;
244+
// MFA locations must go through the modal flow which collects
245+
// credentials before calling connect — calling connect directly
246+
// would mark the location active in the DB without a tunnel.
247+
if (mfaEnabled) {
248+
if (appConfig.auto_connect_mfa) openMFAModal(loc);
249+
} else {
250+
connect({ locationId: loc.id, connectionType: ClientConnectionType.LOCATION }).catch(
251+
() => undefined,
252+
);
253+
}
254+
}
255+
})
256+
.catch(() => undefined);
257+
// eslint-disable-next-line react-hooks/exhaustive-deps
258+
}, [instances, appConfig]);
259+
224260
// navigate to carousel on first app Launch
225261
useEffect(() => {
226262
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/ClientSettingsPage/components/GlobalSettingsTab/GlobalSettingsTab.tsx

Lines changed: 81 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,16 @@ 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>
142157
</form>
143158
);
144159
};
@@ -330,3 +345,69 @@ const CheckForUpdatesOption = ({ controller }: FormMemberProps) => {
330345
/>
331346
);
332347
};
348+
349+
const AutoConnectMfaOption = ({
350+
controller,
351+
control,
352+
}: FormMemberProps & { control: Control<FormFields> }) => {
353+
const { LL } = useI18nContext();
354+
const localLL = LL.pages.client.pages.settingsPage.tabs.global;
355+
const defaultInstance = useWatch({ control, name: 'default_instance' });
356+
357+
return (
358+
<FormCheckBox
359+
labelPlacement="right"
360+
label={localLL.autoConnectMfa.title()}
361+
controller={controller}
362+
disabled={defaultInstance === null}
363+
/>
364+
);
365+
};
366+
367+
const DefaultInstanceSelect = ({ controller }: FormMemberProps) => {
368+
const { LL } = useI18nContext();
369+
const localLL = LL.pages.client.pages.settingsPage.tabs.global.defaultInstance;
370+
const instances = useClientStore((state) => state.instances);
371+
372+
const options = useMemo((): SelectOption<number | null>[] => {
373+
const noneOption: SelectOption<number | null> = {
374+
key: -1,
375+
label: localLL.options.none(),
376+
value: null,
377+
};
378+
const instanceOptions: SelectOption<number | null>[] = instances.map(
379+
(instance: DefguardInstance) => ({
380+
key: instance.id,
381+
label: instance.name,
382+
value: instance.id,
383+
}),
384+
);
385+
return [noneOption, ...instanceOptions];
386+
}, [instances, localLL.options]);
387+
388+
const renderSelected = useCallback(
389+
(value: number | null): SelectSelectedValue => {
390+
const option = options.find((o) => o.value === value);
391+
if (option) {
392+
return {
393+
key: option.key,
394+
displayValue: option.label,
395+
};
396+
}
397+
return {
398+
key: -1,
399+
displayValue: localLL.options.none(),
400+
};
401+
},
402+
[options, localLL.options],
403+
);
404+
405+
return (
406+
<FormSelect
407+
sizeVariant={SelectSizeVariant.STANDARD}
408+
options={options}
409+
renderSelected={renderSelected}
410+
controller={controller}
411+
/>
412+
);
413+
};

0 commit comments

Comments
 (0)