diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyOidcStep/ConfigureVerifyOidcStep.tsx b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyOidcStep/ConfigureVerifyOidcStep.tsx
index f718755c2..3f4bcc0a8 100644
--- a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyOidcStep/ConfigureVerifyOidcStep.tsx
+++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyOidcStep/ConfigureVerifyOidcStep.tsx
@@ -1,68 +1,61 @@
+import './style.scss';
+import { useEffect } from 'react';
import { Button } from '../../../../../shared/components/Button/Button';
import { ButtonVariant } from '../../../../../shared/components/Button/types';
-import { Controls } from '../../../../../shared/components/Controls/Controls';
-import { ExternalProviderButton } from '../../../../../shared/components/ExternalProviderButton/ExternalProviderButton';
-import { FullPageTitle } from '../../../../../shared/components/FullPageTitle/FullPageTitle';
+import { LoaderSpinner } from '../../../../../shared/components/LoaderSpinner/LoaderSpinner';
+import { SizedBox } from '../../../../../shared/components/SizedBox/SizedBox';
import { FullPage } from '../../../../../shared/layouts/FullPage/FullPage';
+import { ThemeSpacing } from '../../../../../shared/types';
import { isPresent } from '../../../../../shared/utils/isPresent';
-import { findOpenIdProvider, openIdProviderName } from '../../../../../shared/utils/mfa';
import { useConfigureMfaStore } from '../../hooks/useConfigureMfaStore';
+import { useLeaveVerification } from '../../hooks/useLeaveVerification';
import { useConfigureVerifyOidc } from './useConfigureVerifyOidc';
-import '../style.scss';
interface Props {
+ onCancel: () => void;
onSessionExpired: () => void;
}
-export const ConfigureVerifyOidcStep = ({ onSessionExpired }: Props) => {
- const instance = useConfigureMfaStore((s) => s.instance) ?? undefined;
- const providerName = openIdProviderName(instance);
- const { start, abort, isOpening, isPolling, error } = useConfigureVerifyOidc({
+/** reached from the provider button on the picker, so the browser opens on mount */
+export const ConfigureVerifyOidcStep = ({ onCancel, onSessionExpired }: Props) => {
+ const { leave, isLeaving } = useLeaveVerification(onCancel);
+ const { abort, error } = useConfigureVerifyOidc({
onSessionExpired,
- autoStart: false,
+ autoStart: true,
});
- const handleBack = async () => {
+ useEffect(() => {
+ if (isPresent(error)) {
+ useConfigureMfaStore.getState().backFromVerification(error);
+ }
+ }, [error]);
+
+ const handleLeave = async () => {
// Core holds one pending attempt per session, so abort it before another method starts
await abort();
- useConfigureMfaStore.getState().backFromVerification();
+ leave();
};
return (
-
-
-
-
- {isPolling
- ? 'Complete the sign-in in your browser. This page will update automatically.'
- : `A browser window will open for you to sign in with ${providerName}.`}
-
-
- {isPresent(error) && {error}
}
-
- {
- void start();
- }}
- />
-
-
+
+
+
+
+
Continue in your browser
+
+
+ Once verified, the process will continue automatically here.
+
+
{
- void handleBack();
+ void handleLeave();
}}
/>
-
+
);
};
diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyOidcStep/style.scss b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyOidcStep/style.scss
new file mode 100644
index 000000000..44f187617
--- /dev/null
+++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyOidcStep/style.scss
@@ -0,0 +1,28 @@
+#configure-verify-oidc-step {
+ display: flex;
+ flex-flow: column;
+ align-items: center;
+ justify-content: center;
+ min-height: calc(100dvh - var(--window-decorations-height));
+ padding: var(--spacing-md) var(--spacing-lg);
+ box-sizing: border-box;
+
+ & > .content {
+ display: flex;
+ flex-flow: column;
+ align-items: center;
+ justify-content: flex-start;
+
+ & > .title {
+ text-align: center;
+ font: var(--t-h4);
+ color: var(--fg-white-100);
+ }
+
+ & > .subtitle {
+ text-align: center;
+ font: var(--t-body-sm-400);
+ color: var(--fg-white-70);
+ }
+ }
+}
diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyTotpStep/ConfigureVerifyTotpStep.tsx b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyTotpStep/ConfigureVerifyTotpStep.tsx
index eb3b2d3d6..5ed94c40c 100644
--- a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyTotpStep/ConfigureVerifyTotpStep.tsx
+++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyTotpStep/ConfigureVerifyTotpStep.tsx
@@ -1,28 +1,26 @@
import { useMutation } from '@tanstack/react-query';
import { useCallback, useState } from 'react';
-import { Button } from '../../../../../shared/components/Button/Button';
-import { ButtonVariant } from '../../../../../shared/components/Button/types';
-import { CodeInput } from '../../../../../shared/components/CodeInput/CodeInput';
-import { Controls } from '../../../../../shared/components/Controls/Controls';
-import { FullPageTitle } from '../../../../../shared/components/FullPageTitle/FullPageTitle';
-import { FullPage } from '../../../../../shared/layouts/FullPage/FullPage';
+import { IconKind } from '../../../../../shared/components/Icon';
import { api } from '../../../../../shared/rust-api/api';
import { MfaMethod } from '../../../../../shared/rust-api/types';
import { isPresent } from '../../../../../shared/utils/isPresent';
+import { MfaCodeVerification } from '../../components/MfaCodeVerification/MfaCodeVerification';
import { useConfigureMfaStore } from '../../hooks/useConfigureMfaStore';
+import { useLeaveVerification } from '../../hooks/useLeaveVerification';
import { useMfaConfigErrorHandler } from '../../hooks/useMfaConfigErrorHandler';
-import '../style.scss';
const CODE_LENGTH = 6;
interface Props {
+ onCancel: () => void;
/** The session outlived its deadline, so the whole flow has to start over. */
onSessionExpired: () => void;
}
/** The code comes from an app the user already has, so there is nothing to request first. */
-export const ConfigureVerifyTotpStep = ({ onSessionExpired }: Props) => {
+export const ConfigureVerifyTotpStep = ({ onCancel, onSessionExpired }: Props) => {
const sessionId = useConfigureMfaStore((s) => s.sessionId);
+ const { leaveText, leave, isLeaving } = useLeaveVerification(onCancel);
const [code, setCode] = useState(null);
const [error, setError] = useState(null);
@@ -65,47 +63,22 @@ export const ConfigureVerifyTotpStep = ({ onSessionExpired }: Props) => {
}, []);
return (
-
-
-
- {`Open the authentication app you use with Defguard.`}
- {`Enter the 6-digit code it currently displays to continue.`}
-
-
-
-
-
- {
- useConfigureMfaStore.getState().backFromVerification();
- }}
- />
-
- {
- handleSubmit();
- }}
- />
-
-
-
+ icon={IconKind.MobileLock}
+ title="Security code from your authenticator app."
+ description={[`Enter the 6-digit code from your Authenticator app to continue.`]}
+ code={code}
+ error={error}
+ onCodeChange={handleCodeChange}
+ onSubmit={handleSubmit}
+ codeLoading={isSubmitting}
+ leaveText={leaveText}
+ onLeave={leave}
+ isLeaving={isLeaving}
+ leaveDisabled={isSubmitting}
+ submitLoading={isSubmitting}
+ submitDisabled={isLeaving}
+ />
);
};
diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/style.scss b/new-ui/src/pages/full/ConfigureMfaPage/verify/style.scss
index 2edba5094..dfc017fe5 100644
--- a/new-ui/src/pages/full/ConfigureMfaPage/verify/style.scss
+++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/style.scss
@@ -32,22 +32,4 @@
padding-top: var(--spacing-xl);
box-sizing: border-box;
}
-
- & > .oidc-action {
- display: flex;
- flex-flow: row nowrap;
- align-items: center;
- justify-content: center;
- padding: var(--spacing-2xl) 0 var(--spacing-3xl);
- box-sizing: border-box;
- }
-
- .code-track {
- padding-bottom: var(--spacing-3xl);
-
- .code-input > .inputs-grid {
- width: fit-content;
- justify-content: flex-start;
- }
- }
}
diff --git a/new-ui/src/pages/full/ConfigureMfaPage/wizard/ConfigureFactorStep/ConfigureFactorStep.tsx b/new-ui/src/pages/full/ConfigureMfaPage/wizard/ConfigureFactorStep/ConfigureFactorStep.tsx
index c4260fa9b..d6eed38f2 100644
--- a/new-ui/src/pages/full/ConfigureMfaPage/wizard/ConfigureFactorStep/ConfigureFactorStep.tsx
+++ b/new-ui/src/pages/full/ConfigureMfaPage/wizard/ConfigureFactorStep/ConfigureFactorStep.tsx
@@ -8,7 +8,9 @@ import { Controls } from '../../../../../shared/components/Controls/Controls';
import { CopyField } from '../../../../../shared/components/CopyField/CopyField';
import { Divider } from '../../../../../shared/components/Divider/Divider';
import { QrCard } from '../../../../../shared/components/QrCard/QrCard';
+import { ResendEmailButton } from '../../../../../shared/components/ResendEmailButton/ResendEmailButton';
import { SizedBox } from '../../../../../shared/components/SizedBox/SizedBox';
+import { useResendCooldown } from '../../../../../shared/hooks/useResendCooldown';
import { api } from '../../../../../shared/rust-api/api';
import { MfaMethod } from '../../../../../shared/rust-api/types';
import { ThemeSpacing } from '../../../../../shared/types';
@@ -40,6 +42,11 @@ export const ConfigureFactorStep = ({ onCancel, onSessionExpired }: Props) => {
const [totpSecret, setTotpSecret] = useState(null);
const [code, setCode] = useState(null);
const [error, setError] = useState(null);
+ const {
+ sent,
+ restart: restartCooldown,
+ reset: resetCooldown,
+ } = useResendCooldown(false);
const handleApiError = useMfaConfigErrorHandler({
context: `MFA configuration of ${method} failed`,
@@ -55,9 +62,13 @@ export const ConfigureFactorStep = ({ onCancel, onSessionExpired }: Props) => {
}
return api.mfaConfigSetupStart(sessionId, method);
},
- onError: (err) => handleApiError(err, () => startSetup()),
+ onError: (err) => {
+ resetCooldown();
+ handleApiError(err, () => startSetup());
+ },
onSuccess: (result) => {
setTotpSecret(result.totp_secret);
+ if (method === MfaMethod.Email) restartCooldown();
},
});
@@ -72,8 +83,9 @@ export const ConfigureFactorStep = ({ onCancel, onSessionExpired }: Props) => {
setTotpSecret(null);
setCode(null);
setError(null);
+ if (method === MfaMethod.Email) restartCooldown();
startSetup();
- }, [sessionId, method, startSetup]);
+ }, [sessionId, method, startSetup, restartCooldown]);
const { mutate: finishSetup, isPending: isFinishingSetup } = useMutation({
mutationFn: async (value: string) => {
@@ -111,6 +123,13 @@ export const ConfigureFactorStep = ({ onCancel, onSessionExpired }: Props) => {
[code, finishSetup, isBusy],
);
+ const handleResend = useCallback(() => {
+ if (isBusy) return;
+ setCode(null);
+ setError(null);
+ startSetup();
+ }, [isBusy, startSetup]);
+
// Only real input clears the error, CodeInput's own reset passes ''.
const handleCodeChange = useCallback((value: string) => {
setCode(value);
@@ -159,6 +178,14 @@ export const ConfigureFactorStep = ({ onCancel, onSessionExpired }: Props) => {
}}
/>
+ {!isTotp && (
+
+ )}
({
return IconDisconnectAll;
case 'report':
return IconReport;
+ case 'sent':
+ return IconSent;
}
}, [iconKind]);
diff --git a/new-ui/src/shared/components/Icon/icon-types.ts b/new-ui/src/shared/components/Icon/icon-types.ts
index e735a4c39..d9b22cd2e 100644
--- a/new-ui/src/shared/components/Icon/icon-types.ts
+++ b/new-ui/src/shared/components/Icon/icon-types.ts
@@ -142,6 +142,7 @@ export const IconKind = {
PlayFilled: 'play-filled',
Question: 'question',
Report: 'report',
+ Sent: 'sent',
} as const;
export type IconKindValue = (typeof IconKind)[keyof typeof IconKind];
diff --git a/new-ui/src/shared/components/Icon/icons/IconSent.tsx b/new-ui/src/shared/components/Icon/icons/IconSent.tsx
new file mode 100644
index 000000000..df332ea07
--- /dev/null
+++ b/new-ui/src/shared/components/Icon/icons/IconSent.tsx
@@ -0,0 +1,25 @@
+import type { SVGProps } from 'react';
+
+export const IconSent = (props: SVGProps) => {
+ return (
+
+
+
+
+ );
+};
diff --git a/new-ui/src/shared/components/ResendEmailButton/ResendEmailButton.tsx b/new-ui/src/shared/components/ResendEmailButton/ResendEmailButton.tsx
new file mode 100644
index 000000000..980c3a1b7
--- /dev/null
+++ b/new-ui/src/shared/components/ResendEmailButton/ResendEmailButton.tsx
@@ -0,0 +1,32 @@
+import { Button } from '../Button/Button';
+import { ButtonVariant } from '../Button/types';
+
+interface Props {
+ sent: boolean;
+ onClick: () => void;
+ testId?: string;
+ loading?: boolean;
+ disabled?: boolean;
+}
+
+// copy is fixed here so every resend in the app reads the same
+export const ResendEmailButton = ({
+ sent,
+ onClick,
+ testId,
+ loading = false,
+ disabled = false,
+}: Props) => {
+ return (
+ {
+ onClick();
+ }}
+ />
+ );
+};
diff --git a/new-ui/src/shared/hooks/useResendCooldown.test.ts b/new-ui/src/shared/hooks/useResendCooldown.test.ts
new file mode 100644
index 000000000..b5e95ca24
--- /dev/null
+++ b/new-ui/src/shared/hooks/useResendCooldown.test.ts
@@ -0,0 +1,70 @@
+import { act, renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useResendCooldown } from './useResendCooldown';
+
+describe('useResendCooldown', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('starts sent and allows a resend after the cooldown', () => {
+ const { result } = renderHook(() => useResendCooldown());
+ expect(result.current.sent).toBe(true);
+
+ act(() => {
+ vi.advanceTimersByTime(4_999);
+ });
+ expect(result.current.sent).toBe(true);
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(result.current.sent).toBe(false);
+ });
+
+ it('starts unsent when asked to', () => {
+ const { result } = renderHook(() => useResendCooldown(false));
+ expect(result.current.sent).toBe(false);
+ });
+
+ it('restarts the full cooldown on every send', () => {
+ const { result } = renderHook(() => useResendCooldown(false));
+
+ act(() => {
+ result.current.restart();
+ });
+ act(() => {
+ vi.advanceTimersByTime(3_000);
+ });
+ act(() => {
+ result.current.restart();
+ });
+ act(() => {
+ vi.advanceTimersByTime(3_000);
+ });
+ expect(result.current.sent).toBe(true);
+
+ act(() => {
+ vi.advanceTimersByTime(2_000);
+ });
+ expect(result.current.sent).toBe(false);
+ });
+
+ it('reset allows a resend right away', () => {
+ const { result } = renderHook(() => useResendCooldown());
+
+ act(() => {
+ result.current.reset();
+ });
+ expect(result.current.sent).toBe(false);
+
+ act(() => {
+ vi.advanceTimersByTime(5_000);
+ });
+ expect(result.current.sent).toBe(false);
+ });
+});
diff --git a/new-ui/src/shared/hooks/useResendCooldown.ts b/new-ui/src/shared/hooks/useResendCooldown.ts
new file mode 100644
index 000000000..16cb9f58f
--- /dev/null
+++ b/new-ui/src/shared/hooks/useResendCooldown.ts
@@ -0,0 +1,38 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+const resendCooldownMs = 5_000;
+
+/** The first email goes out automatically, so by default the cooldown runs from mount. */
+export const useResendCooldown = (initiallySent = true) => {
+ const [sent, setSent] = useState(initiallySent);
+ const timeoutRef = useRef | null>(null);
+
+ const clear = useCallback(() => {
+ if (timeoutRef.current !== null) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ }, []);
+
+ const restart = useCallback(() => {
+ clear();
+ setSent(true);
+ timeoutRef.current = setTimeout(() => {
+ timeoutRef.current = null;
+ setSent(false);
+ }, resendCooldownMs);
+ }, [clear]);
+
+ const reset = useCallback(() => {
+ clear();
+ setSent(false);
+ }, [clear]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: runs on mount only
+ useEffect(() => {
+ if (initiallySent) restart();
+ return clear;
+ }, []);
+
+ return { sent, restart, reset };
+};
diff --git a/src-tauri/deny.toml b/src-tauri/deny.toml
index 4805d9ff0..80d8ceb8f 100644
--- a/src-tauri/deny.toml
+++ b/src-tauri/deny.toml
@@ -77,9 +77,9 @@ ignore = [
{ id = "RUSTSEC-2026-0347", reason = "rustls <0.23.46 pulled by reqwest/hyper-rustls; ECH not used" },
{ id = "RUSTSEC-2026-0348", reason = "rustls <0.23.46 pulled by reqwest/hyper-rustls; brotli cert compression feature not enabled" },
{ id = "RUSTSEC-2026-0349", reason = "rustls <0.23.46 pulled by reqwest/hyper-rustls; PQ key exchange downgrade, no key compromise" },
- { id = "RUSTSEC-2026-0381", reason = "webpki-roots <1.0.10 pulled by reqwest/hyper-rustls; distrusted emSign root, fixed by lockfile update" },
- { id = "RUSTSEC-2026-0382", reason = "rustls-webpki <0.103.16 pulled by rustls; distrusted emSign intermediates, fixed by lockfile update" },
- { id = "RUSTSEC-2026-0383", reason = "webpki-root-certs <1.0.10 pulled by rustls-platform-verifier; distrusted emSign root, fixed by lockfile update" },
+ { id = "RUSTSEC-2026-0381", reason = "webpki-roots <1.0.10 pulled by hyper-rustls; bundles distrusted emSign root" },
+ { id = "RUSTSEC-2026-0382", reason = "rustls-webpki <0.103.16 pulled by rustls; may accept distrusted emSign intermediates" },
+ { id = "RUSTSEC-2026-0383", reason = "webpki-root-certs <1.0.10 pulled by rustls-platform-verifier; bundles distrusted emSign root" },
# The "unmaintained" advisories below stem from our use of Tauri v2, which
# depends on GTK3 bindings that are no longer maintained.
{ id = "RUSTSEC-2024-0370", reason = "Tauri v2 GTK3 dependency (unmaintained)" },