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
Original file line number Diff line number Diff line change
@@ -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 = '<div id="root"></div><div id="modals-root"></div>';
});
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(
<QueryClientProvider client={new QueryClient()}>
<ConfirmModal />
</QueryClientProvider>,
);
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,
});
},
);
});
Original file line number Diff line number Diff line change
@@ -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 });
}
};
};
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -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)) {
Expand Down Expand Up @@ -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],
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,12 @@ export const LocationCardConnectionErrorView = () => {
</>
)}
<p className="description">{connectionError ?? DEFAULT_CONNECTION_ERROR}</p>
<SizedBox height={ThemeSpacing.Xl} />
<SizedBox height={ThemeSpacing.Xl4} />
<Button
text="Got it"
variant={ButtonVariant.Secondary}
onClick={() => setView(LocationCardViews.Default)}
/>
<SizedBox height={ThemeSpacing.Xl} />
</div>
);
};
3 changes: 3 additions & 0 deletions new-ui/src/shared/rust-api/mfaError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ export const isMfaConfigNetworkError = (err: unknown): boolean =>
export const isMfaConfigProxyError = (err: unknown): boolean =>
parseMfaError(err)?.type === 'proxy_error';

export const isMfaConfigSmtpNotConfigured = (err: unknown): boolean =>
parseMfaError(err)?.type === 'smtp_not_configured';

/** Describes an OIDC polling error and its user-facing message. */
export type OidcPollFailure = {
kind:
Expand Down
21 changes: 19 additions & 2 deletions src-tauri/core/src/mfa_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use tokio_util::sync::CancellationToken;
use crate::{
database::models::{instance::MfaCapabilities, Id},
mfa::{OIDC_POLL_INTERVAL, OIDC_POLL_TIMEOUT},
proxy::{post_with_headers, read_error_message},
proxy::post_with_headers,
};

// No leading slash: it would make `Url::join` discard a proxy base path (`https://host/defguard/`).
Expand Down Expand Up @@ -175,6 +175,9 @@ pub enum MfaConfigError {
#[error("{message}")]
NetworkError { message: String },

#[error("SMTP is not configured on this Defguard instance")]
SmtpNotConfigured,

#[error("Proxy error (HTTP {status}): {message}")]
ProxyError { status: u16, message: String },

Expand Down Expand Up @@ -300,7 +303,21 @@ async fn check_response(response: Response, endpoint: &str) -> Result<Response,
return Err(MfaConfigError::Unsupported);
}

let message = read_error_message(response).await;
let body = response.json::<serde_json::Value>().await.ok();
if body
.as_ref()
.and_then(|body| body.get("code"))
.and_then(serde_json::Value::as_str)
== Some("smtp_not_configured")
{
return Err(MfaConfigError::SmtpNotConfigured);
}
let message = body
.as_ref()
.and_then(|body| body.get("error"))
.and_then(serde_json::Value::as_str)
.map(String::from)
.unwrap_or_else(|| format!("HTTP {status}"));
match status {
// a wrong code or FIDO2 assertion, any other 401 is a session Core no longer knows
StatusCode::UNAUTHORIZED if message == INVALID_CODE_MESSAGE => {
Expand Down
Loading
Loading