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}

- +