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
1 change: 1 addition & 0 deletions new-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"dependencies": {
"@biomejs/biome": "^2.4.15",
"@floating-ui/react": "^0.27.19",
"@stablelib/base64": "^2.0.1",
"@tanstack/react-form": "^1.32.0",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-router": "^1.170.4",
Expand Down
8 changes: 8 additions & 0 deletions new-ui/pnpm-lock.yaml

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

3 changes: 2 additions & 1 deletion new-ui/src/shared/components/LocationCard/LocationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { LocationCardViews, type LocationCardViewsValue } from './context/types'
import { ConnectedView } from './views/ConnectedView/ConnectedView';
import { DefaultView } from './views/DefaultView/DefaultView';
import { LocationCardMfaEmailView } from './views/LocationCardMfaEmailView/LocationCardMfaEmailView';
import { LocationCardMfaMobileView } from './views/LocationCardMfaMobileView/LocationCardMfaMobileView';
import { LocationCardMfaOidcView } from './views/LocationCardMfaOidcView/LocationCardMfaOidcView';
import { LocationCardMfaSettings } from './views/LocationCardMfaSettings/LocationCardMfaSettings';
import { LocationCardMfaTotpView } from './views/LocationCardMfaTotpView/LocationCardMfaTotpView';
Expand All @@ -34,7 +35,7 @@ const views: Record<LocationCardViewsValue, ReactNode> = {
[LocationCardViews.MfaTotp]: <LocationCardMfaTotpView />,
[LocationCardViews.MfaEmail]: <LocationCardMfaEmailView />,
[LocationCardViews.MfaOidc]: <LocationCardMfaOidcView />,
[LocationCardViews.MfaMobile]: null,
[LocationCardViews.MfaMobile]: <LocationCardMfaMobileView />,
[LocationCardViews.MfaSettings]: <LocationCardMfaSettings />,
[LocationCardViews.Connecting]: null,
[LocationCardViews.Connected]: <ConnectedView />,
Expand Down
197 changes: 197 additions & 0 deletions new-ui/src/shared/components/LocationCard/hooks/useMfaMobileConnect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { encode } from '@stablelib/base64';
import { useMutation } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/plugin-http';
import { error } from '@tauri-apps/plugin-log';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../../../rust-api/api';
import { useLocationCardContext } from '../context/context';
import { LocationCardViews } from '../context/types';

const MFA_ENDPOINT = 'api/v1/client-mfa';

type MfaStartResponse = {
token: string;
challenge: string;
};

type MfaErrorResponse = {
error: string;
};

type TokenData = {
token: string;
challenge: string;
};

export const useMfaMobileConnect = () => {
const { location, instance, setView } = useLocationCardContext();

const [isStarting, setIsStarting] = useState(false);
const [startError, setStartError] = useState<string | null>(null);
const [tokenData, setTokenData] = useState<TokenData | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);

const wsRef = useRef<WebSocket | null>(null);
const expectedCloseRef = useRef(false);

const { mutate: connectMutate } = useMutation({
mutationFn: api.connect,
onSuccess: () => {
setView(LocationCardViews.Connected);
},
onError: (err) => {
error(`Connect command failed after successful mobile MFA\n${err}`);
setConnectionError('Failed to establish VPN connection');
},
});

// Open WebSocket when tokenData is available
useEffect(() => {
if (!tokenData) return;

const wsUrl = `${instance.proxy_url
.replace(/^http:/, 'ws:')
.replace(
/^https:/,
'wss:',
)}${MFA_ENDPOINT}/remote?token=${encodeURIComponent(tokenData.token)}`;

expectedCloseRef.current = false;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;

ws.onopen = () => {
setIsConnecting(true);
setConnectionError(null);
};

ws.onmessage = (event: MessageEvent) => {
try {
const parsed = JSON.parse(event.data as string) as unknown;
if (
typeof parsed === 'object' &&
parsed !== null &&
'preshared_key' in parsed &&
typeof (parsed as Record<string, unknown>).preshared_key === 'string'
) {
const presharedKey = (parsed as { preshared_key: string }).preshared_key;
expectedCloseRef.current = true;
connectMutate({
locationId: location.id,
connectionType: location.connection_type,
presharedKey,
});
} else {
error(
`Unexpected mobile MFA WS message for location ${location.id}: ${event.data}`,
);
}
} catch (e) {
error(`Failed to parse mobile MFA WS message for location ${location.id}: ${e}`);
}
};

ws.onerror = () => {
if (!expectedCloseRef.current) {
setIsConnecting(false);
setConnectionError('Connection error. Please try again.');
error(`Mobile MFA WebSocket error for location ${location.id}`);
}
};

ws.onclose = () => {
if (!expectedCloseRef.current) {
setIsConnecting(false);
setConnectionError('Connection closed unexpectedly. Please try again.');
error(`Mobile MFA WebSocket closed unexpectedly for location ${location.id}`);
}
};

return () => {
expectedCloseRef.current = true;
ws.close();
wsRef.current = null;
setIsConnecting(false);
};
}, [tokenData, instance, connectMutate, location]);

// Clean up WebSocket on unmount
useEffect(() => {
return () => {
if (wsRef.current) {
expectedCloseRef.current = true;
wsRef.current.close();
wsRef.current = null;
}
};
}, []);

const qrValue = useMemo(() => {
if (!tokenData) return null;
const json = JSON.stringify({
token: tokenData.token,
challenge: tokenData.challenge,
instance_id: instance.uuid,
});
return encode(new TextEncoder().encode(json));
}, [tokenData, instance.uuid]);

const start = useCallback(async () => {
setIsStarting(true);
setStartError(null);
setConnectionError(null);
// Clear previous token → triggers WS cleanup via effect
setTokenData(null);

let headers: Record<string, string>;
try {
headers = await api.getEdgeRequestHeaders();
} catch {
setStartError('Failed to load request headers');
setIsStarting(false);
return;
}

try {
const res = await fetch(`${instance.proxy_url}${MFA_ENDPOINT}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({
method: 4,
pubkey: instance.pubkey,
location_id: location.network_id,
}),
});

if (res.ok) {
const data = (await res.json()) as MfaStartResponse;
setTokenData({ token: data.token, challenge: data.challenge });
} else {
const data = (await res.json()) as MfaErrorResponse;
setStartError(data.error ?? 'Failed to start mobile authentication');
error(`Mobile MFA start failed for location ${location.id}: ${data.error}`);
}
} catch (e) {
setStartError('Failed to reach server');
error(`Mobile MFA start network error for location ${location.id}: ${e}`);
} finally {
setIsStarting(false);
}
}, [instance, location]);

const reset = useCallback(() => {
if (wsRef.current) {
expectedCloseRef.current = true;
wsRef.current.close();
wsRef.current = null;
}
setTokenData(null);
setIsStarting(false);
setStartError(null);
setIsConnecting(false);
setConnectionError(null);
}, []);

return { start, isStarting, startError, qrValue, isConnecting, connectionError, reset };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import './style.scss';
import { useEffect, useRef, useState } from 'react';
import { ThemeSpacing } from '../../../../types';
import { Button } from '../../../Button/Button';
import { ButtonVariant } from '../../../Button/types';
import { Controls } from '../../../Controls/Controls';
import { Divider } from '../../../Divider/Divider';
import { IconKind } from '../../../Icon';
import { IconButton } from '../../../IconButton/IconButton';
import { IconButtonVariant } from '../../../IconButton/types';
import { QrCard } from '../../../QrCard/QrCard';
import { LocationViewHeader } from '../../components/LocationViewHeader/LocationViewHeader';
import { useLocationCardContext } from '../../context/context';
import { LocationCardViews } from '../../context/types';
import { useMfaMobileConnect } from '../../hooks/useMfaMobileConnect';

type Screen = 'loading' | 'qr' | 'error';

export const LocationCardMfaMobileView = () => {
const { setView } = useLocationCardContext();
const { start, startError, qrValue, connectionError, reset } = useMfaMobileConnect();
const [screen, setScreen] = useState<Screen>('loading');
const startedRef = useRef(false);

// Auto-start on mount
useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;
void start();
}, [start]);

useEffect(() => {
if (startError ?? connectionError) {
setScreen('error');
} else if (qrValue) {
setScreen('qr');
}
}, [startError, connectionError, qrValue]);

const retry = () => {
reset();
setScreen('loading');
void start();
};

const errorMessage = startError ?? connectionError;

return (
<div className="location-card-mfa-mobile">
<Divider spacing={ThemeSpacing.Md} />
<LocationViewHeader title="Two-factor authentication">
{screen === 'loading' && <p>Preparing authentication...</p>}
{screen === 'qr' && (
<p>Open your Defguard mobile app and scan the QR code you see bellow.</p>
)}
{screen === 'error' && <p className="error">{errorMessage}</p>}
</LocationViewHeader>
{screen === 'qr' && qrValue && (
<div className="qr-wrapper">
<QrCard value={qrValue} size={184} />
</div>
)}
<Controls>
<IconButton
variant={IconButtonVariant.BigSelected}
icon={IconKind.ArrowBig}
iconRotation="left"
onClick={() => setView(LocationCardViews.Default)}
/>
<div className="right">
{screen === 'error' && (
<Button text="Try again" variant={ButtonVariant.Primary} onClick={retry} />
)}
</div>
</Controls>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.location-card-mfa-mobile {
display: flex;
flex-direction: column;

.qr-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-md);
padding-top: var(--spacing-lg);

p {
text-align: center;
}
}

.location-card-view-header {
p.error {
color: var(--fg-critical);
}
}
}
2 changes: 0 additions & 2 deletions new-ui/src/shared/rust-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,6 @@ export type SaveDeviceConfigResponse = {
locations: LocationInfo[];
};

// ── Request argument types ───────────────────────────────────────────────────

export type ConnectionArgs = {
locationId: number;
connectionType: ConnectionType;
Expand Down
Loading