Skip to content

Commit b9c2a51

Browse files
authored
fix(account): show relay unreachable instead of empty device list (#1552)
When a persisted session cannot reach the relay, Online Devices no longer treats a failed list as "no other devices online".
1 parent 1801ac0 commit b9c2a51

6 files changed

Lines changed: 148 additions & 17 deletions

File tree

src/web-ui/src/app/components/AccountLoginDialog/AccountLoginDialog.tsx

Lines changed: 64 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { api } from '@/infrastructure/api/service-api/ApiClient';
2323
import { usePeerDeviceMode } from '@/infrastructure/peer-device/PeerDeviceContext';
2424
import { useAccountSyncStore, ensureAccountSyncProgressListener } from '@/infrastructure/account/accountSyncStore';
2525
import type { AccountSyncPhase } from '@/infrastructure/account/accountSyncStore';
26+
import { isAccountAuthFailure } from '@/infrastructure/account/accountErrorUtils';
2627
import { useNotification } from '@/shared/notification-system';
2728
import { createLogger } from '@/shared/utils/logger';
2829
import './AccountLoginDialog.scss';
@@ -63,16 +64,6 @@ function syncPhaseLabel(
6364
}
6465
}
6566

66-
function isAccountAuthFailure(error: unknown): boolean {
67-
const msg = (error instanceof Error ? error.message : String(error)).toLowerCase();
68-
return (
69-
msg.includes('401')
70-
|| msg.includes('unauthorized')
71-
|| msg.includes('invalid or expired token')
72-
|| msg.includes('relay auth error')
73-
);
74-
}
75-
7667
interface AccountLoginDialogProps {
7768
isOpen: boolean;
7869
onClose: () => void;
@@ -104,13 +95,18 @@ export const AccountLoginDialog: React.FC<AccountLoginDialogProps> = ({
10495

10596
const [devices, setDevices] = useState<AccountDeviceInfo[]>([]);
10697
const [localDeviceId, setLocalDeviceId] = useState<string | null>(null);
98+
/** Only true after a successful list_devices response; gates the empty-state copy. */
99+
const [devicesReady, setDevicesReady] = useState(false);
100+
const [relayError, setRelayError] = useState<string | null>(null);
107101
const refreshTimer = useRef<ReturnType<typeof setInterval> | null>(null);
108102
/** Prevent overlapping background syncs from rapid clicks. */
109103
const syncInFlightRef = useRef(false);
110104

111105
const resetState = useCallback(() => {
112106
setDevices([]);
113107
setLocalDeviceId(null);
108+
setDevicesReady(false);
109+
setRelayError(null);
114110
if (refreshTimer.current) { clearInterval(refreshTimer.current); refreshTimer.current = null; }
115111
}, []);
116112

@@ -125,6 +121,11 @@ export const AccountLoginDialog: React.FC<AccountLoginDialogProps> = ({
125121
setError(t('accountLogin.sessionExpired'));
126122
}, [resetState, t]);
127123

124+
const markRelayUnreachable = useCallback(() => {
125+
setDevicesReady(false);
126+
setRelayError(t('accountLogin.relayUnreachable'));
127+
}, [t]);
128+
128129
const refreshDevices = useCallback(async () => {
129130
try {
130131
let list = await remoteConnectAPI.accountListDevices();
@@ -134,13 +135,35 @@ export const AccountLoginDialog: React.FC<AccountLoginDialogProps> = ({
134135
list = await remoteConnectAPI.accountListDevices();
135136
}
136137
setDevices(list);
138+
setDevicesReady(true);
139+
setRelayError(null);
137140
} catch (e) {
138141
log.warn('refreshDevices failed', e);
139142
if (isAccountAuthFailure(e)) {
140143
await handleSessionExpired(e);
144+
} else {
145+
markRelayUnreachable();
146+
}
147+
}
148+
}, [localDeviceId, handleSessionExpired, markRelayUnreachable]);
149+
150+
const handleRetryConnect = useCallback(async () => {
151+
setLoading(true);
152+
setRelayError(null);
153+
try {
154+
await remoteConnectAPI.accountConnectDevices();
155+
await refreshDevices();
156+
} catch (err) {
157+
log.warn('retry connect failed', err);
158+
if (isAccountAuthFailure(err)) {
159+
await handleSessionExpired(err);
160+
return;
141161
}
162+
markRelayUnreachable();
163+
} finally {
164+
setLoading(false);
142165
}
143-
}, [localDeviceId, handleSessionExpired]);
166+
}, [handleSessionExpired, markRelayUnreachable, refreshDevices]);
144167

145168
const applyPresenceOnline = useCallback((onlineDevices: Array<{ device_id: string; device_name: string }>) => {
146169
const onlineIds = new Set(onlineDevices.map(d => d.device_id));
@@ -195,6 +218,7 @@ export const AccountLoginDialog: React.FC<AccountLoginDialogProps> = ({
195218
});
196219
remoteConnectAPI.accountStatus().then(async (status) => {
197220
if (status.logged_in && status.user_id) {
221+
setView('devices');
198222
try {
199223
await remoteConnectAPI.accountConnectDevices();
200224
} catch (err) {
@@ -203,9 +227,9 @@ export const AccountLoginDialog: React.FC<AccountLoginDialogProps> = ({
203227
await handleSessionExpired(err);
204228
return;
205229
}
230+
markRelayUnreachable();
206231
}
207-
setView('devices');
208-
refreshDevices();
232+
void refreshDevices();
209233
startDevicePolling();
210234
}
211235
});
@@ -232,7 +256,15 @@ export const AccountLoginDialog: React.FC<AccountLoginDialogProps> = ({
232256
unlistenPresence();
233257
unlistenSettings();
234258
};
235-
}, [isOpen, refreshDevices, resetState, startDevicePolling, applyPresenceOnline, handleSessionExpired]);
259+
}, [
260+
isOpen,
261+
refreshDevices,
262+
resetState,
263+
startDevicePolling,
264+
applyPresenceOnline,
265+
handleSessionExpired,
266+
markRelayUnreachable,
267+
]);
236268

237269
const validate = useCallback(() => {
238270
if (!username.trim() || !password.trim() || !authServer.trim()) {
@@ -516,7 +548,7 @@ export const AccountLoginDialog: React.FC<AccountLoginDialogProps> = ({
516548

517549
{view === 'devices' && (
518550
<div className="account-login-dialog__scroll">
519-
{syncStatus !== 'idle' && (
551+
{syncStatus !== 'idle' && !relayError && (
520552
<div className={`account-login-dialog__sync-indicator ${syncStatus}`}>
521553
<div className="account-login-dialog__sync-indicator-row">
522554
{syncStatus === 'syncing' && <RefreshCw size={14} className="spinning" />}
@@ -554,11 +586,20 @@ export const AccountLoginDialog: React.FC<AccountLoginDialogProps> = ({
554586
)}
555587
</div>
556588
)}
589+
{relayError && (
590+
<div className="account-login-dialog__error-banner">
591+
<Alert
592+
type="error"
593+
message={relayError}
594+
className="account-login-dialog__error-alert"
595+
/>
596+
</div>
597+
)}
557598
<div className="account-login-dialog__device-list">
558-
{devices.length === 0 && (
599+
{!relayError && devicesReady && devices.length === 0 && (
559600
<div className="account-login-dialog__empty">{t('accountLogin.noDevices')}</div>
560601
)}
561-
{devices.map((d) => {
602+
{!relayError && devices.map((d) => {
562603
const isLocal = localDeviceId === d.device_id;
563604
return (
564605
<div key={d.device_id}
@@ -599,6 +640,12 @@ export const AccountLoginDialog: React.FC<AccountLoginDialogProps> = ({
599640
})}
600641
</div>
601642
<div className="account-login-dialog__actions">
643+
{relayError && (
644+
<Button variant="primary" size="small" onClick={handleRetryConnect} disabled={loading}>
645+
<RefreshCw size={14} />
646+
{t('accountLogin.retryConnect')}
647+
</Button>
648+
)}
602649
<Button variant="secondary" size="small" onClick={handleLogout} disabled={loading}>
603650
{t('accountLogin.logout')}
604651
</Button>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { isAccountAuthFailure, isRelayUnreachable } from './accountErrorUtils';
3+
4+
describe('isAccountAuthFailure', () => {
5+
it('detects HTTP 401 and token errors', () => {
6+
expect(isAccountAuthFailure(new Error('HTTP 401 Unauthorized'))).toBe(true);
7+
expect(isAccountAuthFailure(new Error('invalid or expired token'))).toBe(true);
8+
expect(isAccountAuthFailure(new Error('relay auth error: bad token'))).toBe(true);
9+
});
10+
11+
it('rejects network failures', () => {
12+
expect(isAccountAuthFailure(new Error('error sending request: connection refused'))).toBe(false);
13+
});
14+
});
15+
16+
describe('isRelayUnreachable', () => {
17+
it('detects common transport failures', () => {
18+
expect(isRelayUnreachable(new Error('error sending request: connection refused'))).toBe(true);
19+
expect(isRelayUnreachable(new Error('request timed out'))).toBe(true);
20+
expect(isRelayUnreachable(new Error('Failed to fetch'))).toBe(true);
21+
expect(isRelayUnreachable(new Error('dns resolution failed'))).toBe(true);
22+
expect(isRelayUnreachable(new Error('failed to connect to relay'))).toBe(true);
23+
});
24+
25+
it('does not treat auth failures as unreachable', () => {
26+
expect(isRelayUnreachable(new Error('HTTP 401 Unauthorized'))).toBe(false);
27+
expect(isRelayUnreachable(new Error('invalid or expired token'))).toBe(false);
28+
});
29+
30+
it('does not treat unrelated application errors as unreachable', () => {
31+
expect(isRelayUnreachable(new Error('not logged in'))).toBe(false);
32+
expect(isRelayUnreachable(new Error('remote connect service not initialized'))).toBe(false);
33+
});
34+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Classify account / relay errors for Online Devices UX.
3+
* Auth failures clear the local session; unreachable keeps it and shows retry.
4+
*/
5+
6+
export function errorMessage(error: unknown): string {
7+
return (error instanceof Error ? error.message : String(error)).toLowerCase();
8+
}
9+
10+
export function isAccountAuthFailure(error: unknown): boolean {
11+
const msg = errorMessage(error);
12+
return (
13+
msg.includes('401')
14+
|| msg.includes('unauthorized')
15+
|| msg.includes('invalid or expired token')
16+
|| msg.includes('relay auth error')
17+
);
18+
}
19+
20+
/** Network / transport failures talking to the relay (not auth rejection). */
21+
export function isRelayUnreachable(error: unknown): boolean {
22+
if (isAccountAuthFailure(error)) {
23+
return false;
24+
}
25+
const msg = errorMessage(error);
26+
return (
27+
msg.includes('connection refused')
28+
|| msg.includes('connect error')
29+
|| msg.includes('connection reset')
30+
|| msg.includes('timed out')
31+
|| msg.includes('timeout')
32+
|| msg.includes('failed to fetch')
33+
|| msg.includes('error sending request')
34+
|| msg.includes('network')
35+
|| msg.includes('dns')
36+
|| msg.includes('name or service not known')
37+
|| msg.includes('no such host')
38+
|| msg.includes('unreachable')
39+
|| msg.includes('could not connect')
40+
|| msg.includes('failed to connect')
41+
|| msg.includes('empty reply')
42+
|| msg.includes('connection closed')
43+
);
44+
}

src/web-ui/src/locales/en-US/common.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,8 @@
525525
"noCurrentWorkspace": "This device has no open workspace. Select a recent one or create a new workspace.",
526526
"noSessions": "No sessions in this workspace",
527527
"sessionExpired": "Session expired. Please log in again.",
528+
"relayUnreachable": "Cannot reach the relay server. Check the server URL and network, or log out and try again.",
529+
"retryConnect": "Retry",
528530
"enteredPeerMode": "Connected to remote device: {{name}}",
529531
"peerRemoteLabel": "{{name}}",
530532
"peerRemoteBadgeTitle": "Remote device: {{name}}",

src/web-ui/src/locales/zh-CN/common.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,8 @@
525525
"noCurrentWorkspace": "该设备当前没有打开的工作区,请选择最近工作区或新建",
526526
"noSessions": "当前工作区暂无会话",
527527
"sessionExpired": "登录已失效,请重新登录",
528+
"relayUnreachable": "无法连接中继服务。请检查服务地址与网络,或退出登录后重试。",
529+
"retryConnect": "重试连接",
528530
"enteredPeerMode": "已连接到远程设备:{{name}}",
529531
"peerRemoteLabel": "{{name}}",
530532
"peerRemoteBadgeTitle": "远程设备:{{name}}",

src/web-ui/src/locales/zh-TW/common.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,8 @@
525525
"noCurrentWorkspace": "此裝置目前沒有開啟的工作區,請選擇最近工作區或新建",
526526
"noSessions": "目前工作區暫無工作階段",
527527
"sessionExpired": "登入已失效,請重新登入",
528+
"relayUnreachable": "無法連接中繼服務。請檢查服務位址與網路,或退出登入後重試。",
529+
"retryConnect": "重試連線",
528530
"enteredPeerMode": "已連接到遠端裝置:{{name}}",
529531
"peerRemoteLabel": "{{name}}",
530532
"peerRemoteBadgeTitle": "遠端裝置:{{name}}",

0 commit comments

Comments
 (0)