diff --git a/new-ui/src/pages/full/ConfigureMfaPage/hooks/showMfaConfigErrorModal.test.tsx b/new-ui/src/pages/full/ConfigureMfaPage/hooks/showMfaConfigErrorModal.test.tsx
new file mode 100644
index 00000000..d0de2a62
--- /dev/null
+++ b/new-ui/src/pages/full/ConfigureMfaPage/hooks/showMfaConfigErrorModal.test.tsx
@@ -0,0 +1,58 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { ConfirmModal } from '../../../../shared/components/ConfirmModal/ConfirmModal';
+import { useConfirmModal } from '../../../../shared/hooks/confirmModal/useConfirmModal';
+import { MfaMethod } from '../../../../shared/rust-api/types';
+import { showMfaConfigErrorModal } from './showMfaConfigErrorModal';
+import { useConfigureMfaStore } from './useConfigureMfaStore';
+
+vi.hoisted(() => {
+ document.body.innerHTML = '
';
+});
+vi.mock('@tauri-apps/plugin-log', () => ({ error: vi.fn() }));
+vi.mock('../../../../shared/rust-api/api', () => ({ api: {} }));
+
+const message = 'Email MFA is unavailable because SMTP is not configured.';
+
+describe('MFA configuration error modal', () => {
+ beforeEach(() => {
+ useConfirmModal.getState().reset();
+ useConfigureMfaStore.getState().reset();
+ useConfigureMfaStore.setState({
+ sessionId: 'session-1',
+ selectedMethods: [MfaMethod.Email],
+ authorized: true,
+ });
+ });
+
+ it.each(['Got it', 'close button'])(
+ 'dismisses via %s without resetting the flow',
+ (action) => {
+ render(
+
+
+ ,
+ );
+ act(() => {
+ showMfaConfigErrorModal(message);
+ });
+
+ expect(screen.getByText('MFA configuration error')).toBeTruthy();
+ expect(screen.getByText(message)).toBeTruthy();
+ expect(screen.queryByText('Cancel')).toBeNull();
+ const button =
+ action === 'Got it'
+ ? screen.getByRole('button', { name: 'Got it' })
+ : screen.getByRole('button', { name: '' });
+ fireEvent.click(button);
+
+ expect(useConfirmModal.getState().visible).toBe(false);
+ expect(useConfigureMfaStore.getState()).toMatchObject({
+ sessionId: 'session-1',
+ selectedMethods: [MfaMethod.Email],
+ authorized: true,
+ });
+ },
+ );
+});
diff --git a/new-ui/src/pages/full/ConfigureMfaPage/hooks/showMfaConfigErrorModal.ts b/new-ui/src/pages/full/ConfigureMfaPage/hooks/showMfaConfigErrorModal.ts
new file mode 100644
index 00000000..27d5aabc
--- /dev/null
+++ b/new-ui/src/pages/full/ConfigureMfaPage/hooks/showMfaConfigErrorModal.ts
@@ -0,0 +1,18 @@
+import { ButtonVariant } from '../../../../shared/components/Button/types';
+import { useConfirmModal } from '../../../../shared/hooks/confirmModal/useConfirmModal';
+
+export const showMfaConfigErrorModal = (message: string): (() => void) => {
+ const cancelProps = { text: 'Got it', variant: ButtonVariant.Primary };
+ useConfirmModal.getState().open({
+ title: 'MFA configuration error',
+ content: message,
+ cancelProps,
+ });
+
+ return () => {
+ // a later caller may have replaced this modal before the step unmounts
+ if (useConfirmModal.getState().cancelProps === cancelProps) {
+ useConfirmModal.setState({ visible: false });
+ }
+ };
+};
diff --git a/new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.test.ts b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.test.ts
new file mode 100644
index 00000000..ab59e1c2
--- /dev/null
+++ b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.test.ts
@@ -0,0 +1,146 @@
+import { act, renderHook } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { ButtonVariant } from '../../../../shared/components/Button/types';
+import { useConfirmModal } from '../../../../shared/hooks/confirmModal/useConfirmModal';
+import { showEdgeComsError } from '../components/EdgeComsError/useEdgeComsErrorStore';
+import { useMfaConfigErrorHandler } from './useMfaConfigErrorHandler';
+
+vi.mock('@tauri-apps/plugin-log', () => ({ error: vi.fn() }));
+vi.mock('../components/EdgeComsError/useEdgeComsErrorStore', () => ({
+ showEdgeComsError: vi.fn(),
+}));
+
+const renderHandler = (hasCodeInput = true) => {
+ const setError = vi.fn();
+ const onSessionExpired = vi.fn();
+ const hook = renderHook(() =>
+ useMfaConfigErrorHandler({
+ context: 'Email MFA configuration failed',
+ setError,
+ onSessionExpired,
+ fallback: 'Configuration failed',
+ hasCodeInput,
+ }),
+ );
+ return { ...hook, setError, onSessionExpired };
+};
+
+const error = (type: string, message?: string) => JSON.stringify({ type, message });
+
+describe('useMfaConfigErrorHandler', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useConfirmModal.getState().reset();
+ });
+
+ it('shows missing SMTP in an informational modal instead of the code input', () => {
+ const { result, setError, onSessionExpired } = renderHandler();
+ const retry = vi.fn();
+ act(() => result.current(error('smtp_not_configured'), retry));
+
+ expect(useConfirmModal.getState()).toMatchObject({
+ visible: true,
+ title: 'MFA configuration error',
+ content:
+ 'Email MFA is unavailable because SMTP is not configured on this Defguard instance. Contact your administrator.',
+ cancelProps: { text: 'Got it', variant: ButtonVariant.Primary },
+ submitProps: null,
+ });
+ expect(setError).not.toHaveBeenCalled();
+ expect(onSessionExpired).not.toHaveBeenCalled();
+ expect(retry).not.toHaveBeenCalled();
+ });
+
+ it('shows a safe modal for a server error without exposing backend details', () => {
+ const { result, setError } = renderHandler();
+ act(() =>
+ result.current(
+ JSON.stringify({ type: 'proxy_error', status: 500, message: 'private details' }),
+ ),
+ );
+
+ expect(useConfirmModal.getState()).toMatchObject({
+ visible: true,
+ content:
+ 'Could not complete MFA configuration. Please try again or contact your administrator.',
+ });
+ expect(setError).not.toHaveBeenCalled();
+ });
+
+ it.each([new Error('private details'), error('future_error', 'private details')])(
+ 'uses the caller fallback in a modal for an unknown error',
+ (err) => {
+ const { result, setError } = renderHandler();
+ act(() => result.current(err));
+
+ expect(useConfirmModal.getState()).toMatchObject({
+ visible: true,
+ content: 'Configuration failed',
+ });
+ expect(setError).not.toHaveBeenCalled();
+ },
+ );
+
+ it('keeps invalid codes inline', () => {
+ const { result, setError } = renderHandler();
+ act(() => result.current(error('invalid_code', 'invalid code')));
+
+ expect(setError).toHaveBeenCalledWith('Invalid code');
+ expect(useConfirmModal.getState().visible).toBe(false);
+ });
+
+ it('preserves rejections without a code input', () => {
+ const { result, setError } = renderHandler(false);
+ act(() => result.current(error('invalid_code', 'Verification rejected')));
+
+ expect(setError).toHaveBeenCalledWith('Verification rejected');
+ expect(useConfirmModal.getState().visible).toBe(false);
+ });
+
+ it('preserves session expiration handling', () => {
+ const { result, onSessionExpired } = renderHandler();
+ act(() => result.current(error('session_expired')));
+
+ expect(onSessionExpired).toHaveBeenCalledOnce();
+ expect(useConfirmModal.getState().visible).toBe(false);
+ });
+
+ it('preserves the network error screen and retry callback', () => {
+ const { result, setError } = renderHandler();
+ const retry = vi.fn();
+ act(() => result.current(error('network_error'), retry));
+
+ expect(showEdgeComsError).toHaveBeenCalledWith(retry);
+ expect(setError).not.toHaveBeenCalled();
+ expect(useConfirmModal.getState().visible).toBe(false);
+ });
+
+ it('ignores cancellation', () => {
+ const { result, setError, onSessionExpired } = renderHandler();
+ act(() => result.current(error('cancelled')));
+
+ expect(setError).not.toHaveBeenCalled();
+ expect(onSessionExpired).not.toHaveBeenCalled();
+ expect(useConfirmModal.getState().visible).toBe(false);
+ });
+
+ it('dismisses its modal when the step unmounts', () => {
+ const { result, unmount } = renderHandler();
+ act(() => result.current(error('smtp_not_configured')));
+ unmount();
+
+ expect(useConfirmModal.getState().visible).toBe(false);
+ });
+
+ it('does not dismiss a modal opened by another caller', () => {
+ const { result, unmount } = renderHandler();
+ act(() => result.current(error('smtp_not_configured')));
+ act(() => useConfirmModal.getState().open({ title: 'Another modal' }));
+ unmount();
+
+ expect(useConfirmModal.getState()).toMatchObject({
+ visible: true,
+ title: 'Another modal',
+ });
+ });
+});
diff --git a/new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.ts b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.ts
index ad95430c..1ef5a204 100644
--- a/new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.ts
+++ b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.ts
@@ -1,5 +1,5 @@
import { error as logError } from '@tauri-apps/plugin-log';
-import { useCallback } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
import {
isMfaConfigAlreadyAuthorized,
isMfaConfigCancelled,
@@ -11,10 +11,12 @@ import {
isMfaConfigProxyError,
isMfaConfigSecurityKeyError,
isMfaConfigSessionExpired,
+ isMfaConfigSmtpNotConfigured,
isMfaConfigTimeout,
mfaErrorMessage,
} from '../../../../shared/rust-api/mfaError';
import { showEdgeComsError } from '../components/EdgeComsError/useEdgeComsErrorStore';
+import { showMfaConfigErrorModal } from './showMfaConfigErrorModal';
type Options = {
context: string;
@@ -34,8 +36,16 @@ export const useMfaConfigErrorHandler = ({
onSessionExpired,
fallback,
hasCodeInput = true,
-}: Options) =>
- useCallback(
+}: Options) => {
+ const dismissModalRef = useRef<(() => void) | null>(null);
+
+ useEffect(() => {
+ return () => {
+ dismissModalRef.current?.();
+ };
+ }, []);
+
+ return useCallback(
(err: unknown, retry?: () => void) => {
// A cancel is the user's own doing, so it is neither logged nor shown.
if (isMfaConfigCancelled(err)) {
@@ -83,11 +93,16 @@ export const useMfaConfigErrorHandler = ({
showEdgeComsError(retry);
return;
}
- if (isMfaConfigProxyError(err)) {
- setError('Service temporarily unavailable, try again.');
- return;
+ let message = fallback;
+ if (isMfaConfigSmtpNotConfigured(err)) {
+ message =
+ 'Email MFA is unavailable because SMTP is not configured on this Defguard instance. Contact your administrator.';
+ } else if (isMfaConfigProxyError(err)) {
+ message =
+ 'Could not complete MFA configuration. Please try again or contact your administrator.';
}
- setError(fallback);
+ dismissModalRef.current = showMfaConfigErrorModal(message);
},
[context, setError, onSessionExpired, fallback, hasCodeInput],
);
+};
diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardConnectionErrorView/LocationCardConnectionErrorView.tsx b/new-ui/src/shared/components/LocationCard/views/LocationCardConnectionErrorView/LocationCardConnectionErrorView.tsx
index ed3379b4..9f34bea9 100644
--- a/new-ui/src/shared/components/LocationCard/views/LocationCardConnectionErrorView/LocationCardConnectionErrorView.tsx
+++ b/new-ui/src/shared/components/LocationCard/views/LocationCardConnectionErrorView/LocationCardConnectionErrorView.tsx
@@ -23,13 +23,12 @@ export const LocationCardConnectionErrorView = () => {
>
)}
{connectionError ?? DEFAULT_CONNECTION_ERROR}
-
+