Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src-tauri/src/app_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64>,
/// 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
Expand All @@ -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,
}
}
}
Expand Down
11 changes: 11 additions & 0 deletions src/i18n/en/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
},
},
Expand Down
44 changes: 44 additions & 0 deletions src/i18n/i18n-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
Expand Down Expand Up @@ -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
}
}
}
}
Expand Down
40 changes: 38 additions & 2 deletions src/pages/client/ClientPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/pages/client/clientAPI/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 2 additions & 0 deletions src/pages/client/hooks/useClientStore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -39,6 +41,7 @@ import {
type TrayIconTheme,
} from '../../../../clientAPI/types';
import { useClientStore } from '../../../../hooks/useClientStore';
import type { DefguardInstance } from '../../../../types';

type FormFields = AppConfig;

Expand Down Expand Up @@ -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],
);
Expand Down Expand Up @@ -139,6 +144,16 @@ export const GlobalSettingsTab = () => {
</header>
<FormInput controller={{ control, name: 'peer_alive_period' }} type="number" />
</section>
<section>
<header>
<h2>{localLL.defaultInstance.title()}</h2>
<Helper initialPlacement="right">
<p>{localLL.defaultInstance.helper()}</p>
</Helper>
</header>
<DefaultInstanceSelect controller={{ control, name: 'default_instance' }} />
<AutoConnectMfaOption control={control} controller={{ control, name: 'auto_connect_mfa' }} />
</section>
</form>
);
};
Expand Down Expand Up @@ -330,3 +345,69 @@ const CheckForUpdatesOption = ({ controller }: FormMemberProps) => {
/>
);
};

const AutoConnectMfaOption = ({
controller,
control,
}: FormMemberProps & { control: Control<FormFields> }) => {
const { LL } = useI18nContext();
const localLL = LL.pages.client.pages.settingsPage.tabs.global;
const defaultInstance = useWatch({ control, name: 'default_instance' });

return (
<FormCheckBox
labelPlacement="right"
label={localLL.autoConnectMfa.title()}
controller={controller}
disabled={defaultInstance === null}
/>
);
};

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<number | null>[] => {
const noneOption: SelectOption<number | null> = {
key: -1,
label: localLL.options.none(),
value: null,
};
const instanceOptions: SelectOption<number | null>[] = 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 (
<FormSelect
sizeVariant={SelectSizeVariant.STANDARD}
options={options}
renderSelected={renderSelected}
controller={controller}
/>
);
};