Skip to content
Merged
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
8 changes: 8 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ dev:
"cd new-ui && pnpm dev" \
"cargo tauri dev"

# Run only the Tauri side with the local dev database and debug logging (Windows/PowerShell).
dev-tauri:
cd src-tauri; $env:DATABASE_URL="sqlite:dev.db"; $env:DEFGUARD_CLIENT_DEV="1"; $env:DEFGUARD_CLIENT_LOG_LEVEL="debug"; cargo tauri dev

# Run only the web frontend dev server.
dev-web:
cd new-ui; pnpm dev

build:
cd new-ui; pnpm build
cargo tauri build --config .\src-tauri\tauri.local.conf.json
Expand Down
2 changes: 1 addition & 1 deletion new-ui/src/pages/full/AddInstancePage/AddInstancePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export const AddInstancePage = () => {
});

return (
<FullPage id="add-instance-view" withControls>
<FullPage id="add-instance-view" hideScrollContainer withControls>
<FullPageTitle title="Add instance" />
<p className="page-description">{`To add an instance, provide the instance URL along with a valid provisioning token. These credentials are issued by your administrator and are required to initiate the setup.`}</p>
<form
Expand Down
2 changes: 1 addition & 1 deletion new-ui/src/pages/full/AddPage/AddPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const AddPage = () => {
useStartMfaConfiguration();

return (
<FullPage id="add-page-view">
<FullPage id="add-page-view" hideScrollContainer>
<FullPageTitle title="Add Defguard items" spacing={ThemeSpacing.Xl} />
<div className="cards">
<AddCard
Expand Down
30 changes: 7 additions & 23 deletions new-ui/src/pages/full/AddPage/hooks/useStartMfaConfiguration.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { useMutation } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { error as logError } from '@tauri-apps/plugin-log';
import { Snackbar } from '../../../../shared/providers/snackbar/snackbar';
import {
isMfaConfigMissingToken,
isMfaConfigUnsupported,
} from '../../../../shared/rust-api/mfaError';
import type { InstanceInfo } from '../../../../shared/rust-api/types';
ConfigureFactorsSource,
type InstanceInfo,
} from '../../../../shared/rust-api/types';
import { reportMfaConfigStartError } from '../../ConfigureMfaPage/hooks/reportMfaConfigStartError';
import { startMfaConfiguration } from '../../ConfigureMfaPage/hooks/useConfigureMfaStore';

/** Opens a session and enters the wizard. On failure it stays put, so another instance
Expand All @@ -15,27 +13,13 @@ export const useStartMfaConfiguration = () => {
const navigate = useNavigate();

return useMutation({
mutationFn: (instance: InstanceInfo) => startMfaConfiguration(instance),
mutationFn: (instance: InstanceInfo) =>
startMfaConfiguration(instance, { source: ConfigureFactorsSource.AddPage }),
onSuccess: () => {
navigate({
to: '/full/configure-mfa',
});
},
onError: (err) => {
void logError(`MFA configuration start failed: ${err}`);
if (isMfaConfigUnsupported(err)) {
Snackbar.error(
'This Defguard instance does not support configuring MFA from the client.',
);
return;
}
if (isMfaConfigMissingToken(err)) {
Snackbar.error(
'This device has no polling token; update the instance and try again.',
);
return;
}
Snackbar.error('Could not start MFA configuration.');
},
onError: reportMfaConfigStartError,
});
};
2 changes: 1 addition & 1 deletion new-ui/src/pages/full/AddTunnelPage/AddTunnelPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { useTunnelWizardStore } from '../TunnelWizardPage/hooks/useTunnelWizardS
export const AddTunnelPage = () => {
const navigate = useNavigate();
return (
<FullPage id="add-tunnel-page" withControls>
<FullPage id="add-tunnel-page" hideScrollContainer withControls>
<FullPageTitle title="Add WireGuard Tunnel" />
<div className="contents">
<p className="page-description">{`A WireGuard tunnel is a secure, encrypted connection that allows your device or network to communicate safely over the internet.It ensures that your data is protected and transmitted through a private, trusted channel.`}</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { error as logError } from '@tauri-apps/plugin-log';
import { Snackbar } from '../../../../shared/providers/snackbar/snackbar';
import {
isMfaConfigMissingToken,
isMfaConfigUnsupported,
} from '../../../../shared/rust-api/mfaError';

/** How a failed `startMfaConfiguration` is reported, shared by every entry point. */
export const reportMfaConfigStartError = (err: unknown): void => {
void logError(`MFA configuration start failed: ${err}`);
if (isMfaConfigUnsupported(err)) {
Snackbar.error(
'This Defguard instance does not support configuring MFA from the client.',
);
return;
}
if (isMfaConfigMissingToken(err)) {
Snackbar.error(
'This device has no polling token; update the instance and try again.',
);
return;
}
Snackbar.error('Could not start MFA configuration.');
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { UseNavigateResult } from '@tanstack/react-router';
import type { ConfigureFactorsPayload } from '../../../../shared/rust-api/types';
import { reportMfaConfigStartError } from './reportMfaConfigStartError';
import { discardMfaConfiguration, startMfaConfiguration } from './useConfigureMfaStore';

type Deps = {
navigate: UseNavigateResult<string>;
};

/** What the full view does with a `configure-factors-trigger`. The route is only entered once
* the session exists, so a failure leaves the user where they were with a snackbar. */
export const runConfigureFactorsRequest = async (
payload: ConfigureFactorsPayload,
{ navigate }: Deps,
): Promise<void> => {
// A session an earlier run walked away from would otherwise outlive this one on the proxy.
await discardMfaConfiguration();

try {
await startMfaConfiguration(payload.instance, {
preselectedMethods: payload.methods,
source: payload.source,
location: payload.location,
});
} catch (err) {
reportMfaConfigStartError(err);
return;
}

await navigate({ to: '/full/configure-mfa' });
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { api } from '../../../../shared/rust-api/api';
import {
type ConfigureFactorsSourceValue,
type InstanceInfo,
type LocationInfo,
type MfaConfigAuthorizeResult,
type MfaConfigStartResult,
MfaMethod,
Expand Down Expand Up @@ -40,6 +42,11 @@ type StoreValues = {
authorized: boolean;
/** Issued for the account's first factor only, so an empty list is an ordinary success. */
recoveryCodes: string[];
/** Which entry point opened this flow. Recorded for later, nothing branches on it yet. */
source: ConfigureFactorsSourceValue | null;
/** The location that sent the user here, so the screens can speak to what that location
* needs. Null when the flow was not started from a location. */
location: LocationInfo | null;
};

type FlowState = Pick<
Expand Down Expand Up @@ -91,10 +98,19 @@ const defaults: StoreValues = {
deadline: null,
authorized: false,
recoveryCodes: [],
source: null,
location: null,
};

/** What the entry point knew about the flow it is opening. */
type ConfigureMfaOrigin = Pick<StoreValues, 'source' | 'location'>;

interface Store extends StoreValues {
start: (instance: InstanceInfo, response: MfaConfigStartResult) => void;
start: (
instance: InstanceInfo,
response: MfaConfigStartResult,
origin: ConfigureMfaOrigin,
) => void;
selectMethods: (methods: MfaMethodValue[]) => void;
/** The fresh deadline bounds every setup still to come, not just the next one. */
authorize: (response: MfaConfigAuthorizeResult) => void;
Expand All @@ -108,7 +124,7 @@ export const useConfigureMfaStore = create<Store>()(
persist(
(set, get) => ({
...defaults,
start: (instance, response) => {
start: (instance, response, origin) => {
// The fallback mails a code to the address on file, registering email along the way.
const codeFactors = response.email_fallback
? [MfaMethod.Email]
Expand All @@ -126,6 +142,7 @@ export const useConfigureMfaStore = create<Store>()(
configuredMethods,
emailFallback: response.email_fallback,
deadline: dayjs.unix(response.deadline_timestamp).toISOString(),
...origin,
});
},
selectMethods: (methods) => {
Expand Down Expand Up @@ -192,9 +209,8 @@ export const useConfigureMfaStore = create<Store>()(
{
name: 'configure-mfa-store',
storage: createJSONStorage(() => sessionStorage),
// Bumped when setup progress moved to its own list, so older sessions start over rather
// than resume believing a configured factor is still pending.
version: 7,
// Bumped on every shape change: a stored session is never resumable across one.
version: 9,
},
),
);
Expand All @@ -205,9 +221,25 @@ export const selectPendingMethod =
(state: Store): MfaMethodValue | undefined =>
pendingMethods(state).find((method) => mfaFactorStep(method) === step);

export const startMfaConfiguration = async (instance: InstanceInfo): Promise<void> => {
type StartOptions = Partial<ConfigureMfaOrigin> & {
/** Factors the caller already picked, so the selection step can be skipped. */
preselectedMethods?: MfaMethodValue[];
};

export const startMfaConfiguration = async (
instance: InstanceInfo,
{ preselectedMethods = [], source = null, location = null }: StartOptions = {},
): Promise<void> => {
const response = await api.mfaConfigStart(instance.id);
useConfigureMfaStore.getState().start(instance, response);
useConfigureMfaStore.getState().start(instance, response, { source, location });
// A pick the selection step would have refused is dropped rather than carried into the wizard;
// with nothing left the step runs as usual.
const preselected = preselectedMethods.filter((method) =>
isMfaFactorOfferable(method, useConfigureMfaStore.getState().configuredMethods),
);
if (preselected.length > 0) {
useConfigureMfaStore.getState().selectMethods(preselected);
}
};

/** A copy the proxy still holds expires on its own, so a failed cancel is not worth raising. */
Expand Down
20 changes: 15 additions & 5 deletions new-ui/src/pages/full/ConfigureMfaPage/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { MfaMethod, type MfaMethodValue } from '../../../shared/rust-api/types';
import {
CLIENT_CONFIGURABLE_METHODS,
type ClientConfigurableMethod,
} from '../../../shared/utils/mfa';

/** Wizard steps, in the order they run. Setup steps are the ones a factor can claim. */
export const ConfigureMfaStep = {
Expand Down Expand Up @@ -26,12 +30,18 @@ type MfaFactor = {
repeatable: boolean;
};

/** Where the wizard sets each configurable factor up. Keyed on the shared list, so adding a
* factor there is a type error until the wizard says what to do with it. */
const WIZARD_FACTORS: Record<ClientConfigurableMethod, Omit<MfaFactor, 'method'>> = {
[MfaMethod.Totp]: { step: ConfigureMfaStep.Configuration, repeatable: false },
[MfaMethod.Email]: { step: ConfigureMfaStep.Configuration, repeatable: false },
[MfaMethod.Fido2]: { step: ConfigureMfaStep.Fido2, repeatable: true },
};

/** Every factor this client can set up, in selection and wizard order. */
export const MFA_CONFIGURABLE_FACTORS: MfaFactor[] = [
{ method: MfaMethod.Totp, step: ConfigureMfaStep.Configuration, repeatable: false },
{ method: MfaMethod.Email, step: ConfigureMfaStep.Configuration, repeatable: false },
{ method: MfaMethod.Fido2, step: ConfigureMfaStep.Fido2, repeatable: true },
];
export const MFA_CONFIGURABLE_FACTORS: MfaFactor[] = CLIENT_CONFIGURABLE_METHODS.map(
(method) => ({ method, ...WIZARD_FACTORS[method] }),
);

export const mfaFactor = (method: MfaMethodValue): MfaFactor | undefined =>
MFA_CONFIGURABLE_FACTORS.find((factor) => factor.method === method);
Expand Down
Loading
Loading