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
4 changes: 2 additions & 2 deletions new-ui/src/shared/components/LocationCard/LocationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { LocationCardProvider, useLocationCardContext } from './context/context'
import { LocationCardViews, type LocationCardViewsValue } from './context/types';
import { ConnectedView } from './views/ConnectedView/ConnectedView';
import { DefaultView } from './views/DefaultView/DefaultView';
import { LocationCardConnectionErrorView } from './views/LocationCardConnectionErrorView/LocationCardConnectionErrorView';
import { LocationCardErrorView } from './views/LocationCardErrorView/LocationCardErrorView';
import { LocationCardMfaEmailView } from './views/LocationCardMfaEmailView/LocationCardMfaEmailView';
import { LocationCardMfaFido2View } from './views/LocationCardMfaFido2View/LocationCardMfaFido2View';
import { LocationCardMfaMobileView } from './views/LocationCardMfaMobileView/LocationCardMfaMobileView';
Expand Down Expand Up @@ -41,7 +41,7 @@ const views: Record<LocationCardViewsValue, ReactNode> = {
[LocationCardViews.Connecting]: null,
[LocationCardViews.Connected]: <ConnectedView />,
[LocationCardViews.PostureCheckFail]: <LocationCardPostureCheckFailView />,
[LocationCardViews.ConnectionError]: <LocationCardConnectionErrorView />,
[LocationCardViews.Error]: <LocationCardErrorView />,
};

interface InnerProps {
Expand Down
122 changes: 122 additions & 0 deletions new-ui/src/shared/components/LocationCard/api/cardError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest';
import { MfaFailureText } from '../../../rust-api/mfaError';
import type { LocationInfo } from '../../../rust-api/types';
import { IconKind } from '../../Icon';
import { connectErrorToCard, MfaRetry, mfaErrorToCard } from './cardError';
import { DEFAULT_CONNECTION_ERROR } from './connectError';

vi.mock('./connectError', () => ({
DEFAULT_CONNECTION_ERROR: 'Service unavailable',
}));

const mfaError = (type: string, message?: string) => JSON.stringify({ type, message });

describe('mfaErrorToCard', () => {
it.each([
[
'service unavailable',
mfaError('network_error', 'unreachable'),
DEFAULT_CONNECTION_ERROR,
IconKind.ServiceUnavailable,
MfaRetry.ResumeStep,
],
[
'timeout',
mfaError('timeout'),
MfaFailureText.Timeout,
IconKind.WarningOutlined,
MfaRetry.ResumeStep,
],
[
'outdated mobile app',
mfaError('mobile_client_outdated'),
'Your Defguard mobile app is out of date.\nUpgrade to approve this connection.',
IconKind.DisableMfa,
null,
],
[
'attempt limit',
mfaError('attempt_limit', 'Too many failed MFA attempts'),
'Too many failed MFA attempts',
IconKind.WarningOutlined,
null,
],
[
'mobile rejection',
mfaError('mfa_rejected', 'Mobile approval rejected'),
'Mobile approval rejected',
IconKind.WarningOutlined,
null,
],
[
'blocked security key',
mfaError(
'mfa_rejected',
'Security key rejected the PIN: the security key is locked after too many PIN attempts',
),
'Security key rejected the PIN: the security key is locked after too many PIN attempts',
IconKind.WarningOutlined,
null,
],
[
'stale attempt',
mfaError('mfa_rejected', 'stale MFA attempt'),
MfaFailureText.StaleAttempt,
IconKind.WarningOutlined,
MfaRetry.RestartMfa,
],
[
'session expired',
mfaError('other', 'login session not found'),
MfaFailureText.SessionExpired,
IconKind.WarningOutlined,
MfaRetry.RestartMfa,
],
[
'VPN failure after MFA',
'VPN connection failed: interface error',
MfaFailureText.ConnectFailure,
IconKind.WarningOutlined,
MfaRetry.RestartMfa,
],
[
'worded server error',
mfaError('mfa_rejected', 'No security key detected'),
'No security key detected',
IconKind.WarningOutlined,
MfaRetry.RestartMfa,
],
[
'unstructured error',
'Instance not found',
MfaFailureText.Unknown,
IconKind.WarningOutlined,
MfaRetry.RestartMfa,
],
])('maps %s', (_, err, message, icon, retry) => {
expect(mfaErrorToCard(err)).toEqual({ message, icon, retry });
});
});

describe('connectErrorToCard', () => {
const location = { name: 'Office' } as LocationInfo;

it('uses the service error for an unavailable service', () => {
expect(
connectErrorToCard(
{ kind: 'serviceUnavailable', message: 'down', conflicts: [] },
location,
),
).toEqual({ message: DEFAULT_CONNECTION_ERROR, icon: IconKind.ServiceUnavailable });
});

it.each([
['other', { kind: 'other' as const, message: 'daemon error', conflicts: [] }],
['unparseable', null],
])('names the location for an %s error', (_, connectError) => {
expect(connectErrorToCard(connectError, location)).toEqual({
message: 'Failed to connect to Office. Please try again.',
icon: IconKind.WarningOutlined,
});
});
});
81 changes: 81 additions & 0 deletions new-ui/src/shared/components/LocationCard/api/cardError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
isAttemptLimit,
isConnectFailure,
isMobileApprovalRejected,
isMobileClientOutdated,
isSecurityKeyPinBlocked,
isServiceUnavailable,
isSessionExpired,
isStaleAttempt,
isTimeout,
MfaFailureText,
mfaErrorMessage,
parseMfaError,
} from '../../../rust-api/mfaError';
import type { LocationInfo } from '../../../rust-api/types';
import { IconKind } from '../../Icon';
import type { LocationCardError } from '../context/types';
import { type ConnectError, DEFAULT_CONNECTION_ERROR } from './connectError';

export const MfaRetry = {
// the hooks keep the session token for these errors, so the step can resume
ResumeStep: 'resume-step',
RestartMfa: 'restart-mfa',
} as const;

export type MfaRetryValue = (typeof MfaRetry)[keyof typeof MfaRetry];

export type MfaFailure = Omit<LocationCardError, 'onRetry'> & {
retry: MfaRetryValue | null;
};

export const mfaErrorToCard = (err: unknown): MfaFailure => {
if (isServiceUnavailable(err)) {
return {
message: DEFAULT_CONNECTION_ERROR,
icon: IconKind.ServiceUnavailable,
retry: MfaRetry.ResumeStep,
};
}
if (isTimeout(err)) {
return {
message: MfaFailureText.Timeout,
icon: IconKind.WarningOutlined,
retry: MfaRetry.ResumeStep,
};
}
const message = mfaErrorMessage(err);
if (isMobileClientOutdated(err)) {
return { message, icon: IconKind.DisableMfa, retry: null };
}
if (
isAttemptLimit(err) ||
isMobileApprovalRejected(message) ||
isSecurityKeyPinBlocked(message)
) {
return { message, icon: IconKind.WarningOutlined, retry: null };
}
const restart = (text: string): MfaFailure => ({
message: text,
icon: IconKind.WarningOutlined,
retry: MfaRetry.RestartMfa,
});
if (isStaleAttempt(message)) return restart(MfaFailureText.StaleAttempt);
if (isSessionExpired(message)) return restart(MfaFailureText.SessionExpired);
if (isConnectFailure(message)) return restart(MfaFailureText.ConnectFailure);
// only structured errors carry a message worded for the user
return restart(parseMfaError(err)?.message ?? MfaFailureText.Unknown);
};

export const connectErrorToCard = (
connectError: ConnectError | null,
location: LocationInfo,
): LocationCardError => {
if (connectError?.kind === 'serviceUnavailable') {
return { message: DEFAULT_CONNECTION_ERROR, icon: IconKind.ServiceUnavailable };
}
return {
message: `Failed to connect to ${location.name}. Please try again.`,
icon: IconKind.WarningOutlined,
};
};
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { useMutation } from '@tanstack/react-query';
import { error as logError } from '@tauri-apps/plugin-log';
import { useConfigureFactorsScreen } from '../../../hooks/useConfigureFactorsScreen';
import { api } from '../../../rust-api/api';
import { connectConfigureFactorsSource } from '../../../utils/configureFactorsSource';
import { ConnectionAbility, shouldStartMfa } from '../../../utils/mfa';
import { IconKind } from '../../Icon';
import { connectErrorToCard } from '../api/cardError';
import { openRouteConflictInFullView, parseConnectError } from '../api/connectError';
import { useLocationCardContext } from '../context/context';
import { LocationCardViews } from '../context/types';
import { ConnectButton } from './ConnectButton/ConnectButton';
import { connectBlockedTooltip } from './ConnectButton/consts';

export const LocationCardConnectButton = () => {
const { location, connectionAbility, setPostureError, setView, startMfa } =
const { location, connectionAbility, setPostureError, setView, showError, startMfa } =
useLocationCardContext();

const canConfigureMfa = connectionAbility === ConnectionAbility.Configurable;
Expand All @@ -23,7 +25,7 @@ export const LocationCardConnectButton = () => {
onSuccess: () => {
setView(LocationCardViews.Connected);
},
onError: (err) => {
onError: (err, variables) => {
const connectError = parseConnectError(err);

if (
Expand All @@ -34,8 +36,12 @@ export const LocationCardConnectButton = () => {
setView(LocationCardViews.PostureCheckFail);
} else if (connectError?.kind === 'routeConflict') {
void openRouteConflictInFullView({ location, conflicts: connectError.conflicts });
} else if (connectError?.kind === 'serviceUnavailable') {
setView(LocationCardViews.ConnectionError);
} else {
void logError(`Failed to connect to location ${location.id}: ${err}`);
showError({
...connectErrorToCard(connectError, location),
onRetry: () => connect(variables),
});
}
},
meta: {
Expand All @@ -51,6 +57,14 @@ export const LocationCardConnectButton = () => {
onSuccess: () => {
setView(LocationCardViews.Default);
},
onError: (err, variables) => {
void logError(`Failed to disconnect from location ${location.id}: ${err}`);
showError({
message: `Failed to disconnect from ${location.name}. Please try again.`,
icon: IconKind.WarningOutlined,
onRetry: () => disconnect(variables),
});
},
meta: {
invalidate: ['locations'],
},
Expand Down
Loading
Loading