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
2 changes: 1 addition & 1 deletion new-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"remark-gfm": "^4.0.1",
"rxjs": "^7.8.2",
"sass": "^1.105.1",
"zod": "^4.6.5",
"zod": "^4.6.7",
"zustand": "^5.0.15"
},
"devDependencies": {
Expand Down
14 changes: 7 additions & 7 deletions new-ui/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,43 @@ describe('useMfaMobileConnect', () => {
expect(mocks.mfaConnectMobileApprove).toHaveBeenCalledTimes(1);
});

it('shows an upgrade message when the mobile client is outdated', async () => {
mocks.mfaBeginStep.mockResolvedValue({
challenge: 'challenge-1',
token: 'token-1',
stepAttemptId: 'attempt-1',
});
mocks.mfaConnectMobileApprove.mockResolvedValue('task-1');
const onConnected = vi.fn();
const onStepAdvanced = vi.fn();
const { result } = renderMobileHook({ onConnected, onStepAdvanced, mfaToken: null });

await act(async () => {
await result.current.start();
});
await waitFor(() => expect(mocks.listen).toHaveBeenCalledTimes(3));
expect(result.current.qrValue).not.toBeNull();

const errorListener = mocks.listen.mock.calls.find(
([event]) => event === TauriEvent.MfaMobileError,
)?.[1] as (event: { payload: { error: string } }) => void;

await act(async () => {
errorListener({
payload: { error: JSON.stringify({ type: 'mobile_client_outdated' }) },
});
});

expect(result.current.connectionError).toBe(
'Your Defguard mobile app is out of date. Update it to approve this connection.',
);
expect(result.current.qrValue).toBeNull();
expect(result.current.isConnecting).toBe(false);
expect(mocks.setMfaToken).toHaveBeenLastCalledWith(null);
expect(onConnected).not.toHaveBeenCalled();
expect(onStepAdvanced).not.toHaveBeenCalled();
});

it('retains and reuses the token after a retryable mobile timeout', async () => {
mocks.mfaBeginStep
.mockResolvedValueOnce({ challenge: 'challenge-1', token: 'token-1' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isServiceUnavailable,
isTimeout,
mfaErrorMessage,
parseMfaError,
} from '../../../rust-api/mfaError';
import { getInstancesQueryOptions } from '../../../rust-api/query';
import type {
Expand Down Expand Up @@ -159,6 +160,10 @@ export const useMfaMobileConnect = (
isTimeout(event.payload.error) || isServiceUnavailable(event.payload.error);
setTokenData(null);
if (!retryable) setMfaTokenRef.current(null);
if (parseMfaError(event.payload.error)?.type === 'mobile_client_outdated') {
setConnectionError(message);
return;
}
setConnectionError(
isConnectFailure(message)
? 'Failed to establish VPN connection'
Expand Down
17 changes: 16 additions & 1 deletion new-ui/src/shared/rust-api/mfaError.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { isInvalidCode } from './mfaError';
import { isInvalidCode, mfaErrorMessage } from './mfaError';

describe('isInvalidCode', () => {
it('matches exact rejected-code messages without case sensitivity', () => {
Expand All @@ -14,3 +14,18 @@ describe('isInvalidCode', () => {
expect(isInvalidCode('invalid token')).toBe(false);
});
});

describe('mfaErrorMessage', () => {
it('maps the outdated mobile client type to an upgrade message', () => {
expect(mfaErrorMessage(JSON.stringify({ type: 'mobile_client_outdated' }))).toBe(
'Your Defguard mobile app is out of date. Update it to approve this connection.',
);
});

it('preserves other error messages and plain text', () => {
expect(
mfaErrorMessage(JSON.stringify({ type: 'other', message: 'Existing error' })),
).toBe('Existing error');
expect(mfaErrorMessage('Plain error')).toBe('Plain error');
});
});
10 changes: 7 additions & 3 deletions new-ui/src/shared/rust-api/mfaError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ export const parseMfaError = (err: unknown): ParsedMfaError | null => {
}
};

/** Returns the error message, or the original error text. */
export const mfaErrorMessage = (err: unknown): string =>
parseMfaError(err)?.message ?? String(err);
export const mfaErrorMessage = (err: unknown): string => {
const parsed = parseMfaError(err);
if (parsed?.type === 'mobile_client_outdated') {
return 'Your Defguard mobile app is out of date. Update it to approve this connection.';
}
return parsed?.message ?? String(err);
};

/** Returns true when MFA was rejected by the device posture check. */
export const isMfaPostureError = (err: unknown, location: LocationInfo): boolean =>
Expand Down
2 changes: 1 addition & 1 deletion nix/package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
inherit version pnpm;
src = ../new-ui;
fetcherVersion = 4;
hash = "sha256-J0TiPTNLr1+KW1j/rYWeQL0Bn+msgHemGUD9OkxVdjA=";
hash = "sha256-x7iOltZ3uWB432vIOC5HkWjzJHEFbzmBgaNY91XJVjQ=";
};

# Pre-build the new UI frontend so Tauri can serve it as WebviewUrl::App("compact/") and "full/".
Expand Down
42 changes: 21 additions & 21 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions src-tauri/client-cli/src/mfa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ fn into_cli(err: mfa::MfaError) -> CliError {
mfa::MfaError::MfaRejected { .. }
| mfa::MfaError::PostureRejected { .. }
| mfa::MfaError::AttemptLimit { .. }
| mfa::MfaError::MobileClientOutdated
| mfa::MfaError::Timeout => CliError::MfaFailed(msg),
mfa::MfaError::Cancelled => CliError::Cancelled(msg),
}
Expand Down Expand Up @@ -910,6 +911,13 @@ mod tests {

use super::*;

#[test]
fn test_outdated_mobile_client_is_mfa_failure() {
let error = mfa::MfaError::MobileClientOutdated;
let message = error.to_string();
assert!(matches!(into_cli(error), CliError::MfaFailed(msg) if msg == message));
}

#[test]
fn test_finish_psk_uses_contract_for_completion() {
let legacy = mfa::MfaFinishResponse::Legacy {
Expand Down
Loading
Loading