fix: surface remote sync reauthentication failures (#1351)

This commit is contained in:
ZacharyZcR
2026-08-27 17:06:16 +08:00
committed by GitHub
parent 6277d15c2a
commit 50f1882fa9
8 changed files with 179 additions and 26 deletions
+3 -2
View File
@@ -1654,7 +1654,7 @@ ipcMain.handle("clear-remote-sync-config", async () => {
return result; return result;
}); });
ipcMain.handle("save-remote-sync-jwt", (_event, token) => { ipcMain.handle("save-remote-sync-jwt", async (_event, token) => {
const result = remoteSync.saveRemoteSyncJwt(token); const result = remoteSync.saveRemoteSyncJwt(token);
if (result.success) { if (result.success) {
remoteSync.getRemoteSyncEngine()?.updateStatus({ remoteSync.getRemoteSyncEngine()?.updateStatus({
@@ -1662,7 +1662,8 @@ ipcMain.handle("save-remote-sync-jwt", (_event, token) => {
needsReauth: false, needsReauth: false,
lastError: null, lastError: null,
}); });
remoteSync.getRemoteSyncEngine()?.syncNow(); const status = await remoteSync.getRemoteSyncEngine()?.syncNow();
return { ...result, status: status || null };
} }
return result; return result;
}); });
+7
View File
@@ -333,6 +333,8 @@ export function AppShell({
const [remoteSyncInitialServerUrl, setRemoteSyncInitialServerUrl] = useState< const [remoteSyncInitialServerUrl, setRemoteSyncInitialServerUrl] = useState<
string | undefined string | undefined
>(undefined); >(undefined);
const [remoteSyncReconnectRequested, setRemoteSyncReconnectRequested] =
useState(false);
const [sidebarWidth, setSidebarWidth] = useState(() => { const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem("termix_sidebarWidth"); const saved = localStorage.getItem("termix_sidebarWidth");
return saved ? parseInt(saved, 10) : 291; return saved ? parseInt(saved, 10) : 291;
@@ -2714,6 +2716,10 @@ export function AppShell({
setUserPrefs((current) => ({ ...current, ...updates })) setUserPrefs((current) => ({ ...current, ...updates }))
} }
remoteSyncInitialServerUrl={remoteSyncInitialServerUrl} remoteSyncInitialServerUrl={remoteSyncInitialServerUrl}
remoteSyncReconnectRequested={remoteSyncReconnectRequested}
onRemoteSyncReconnectHandled={() =>
setRemoteSyncReconnectRequested(false)
}
/> />
</div> </div>
)} )}
@@ -2854,6 +2860,7 @@ export function AppShell({
onReconnect={() => { onReconnect={() => {
setRailView("user-profile"); setRailView("user-profile");
if (!sidebarOpen) setSidebarOpen(true); if (!sidebarOpen) setSidebarOpen(true);
setRemoteSyncReconnectRequested(true);
}} }}
/> />
<MigrationNoticeDialog <MigrationNoticeDialog
+13 -3
View File
@@ -20,6 +20,10 @@ interface SaveRemoteSyncJwtResult {
success: boolean; success: boolean;
reason?: string; reason?: string;
error?: string; error?: string;
status?: {
needsReauth?: boolean;
lastError?: string | null;
} | null;
} }
const AUTH_MESSAGE_SOURCES = new Set([ const AUTH_MESSAGE_SOURCES = new Set([
@@ -56,8 +60,10 @@ export function ElectronLoginForm({
setIsAuthenticating(true); setIsAuthenticating(true);
try { try {
if (token) {
if (targetPurpose === "remoteSync") { if (targetPurpose === "remoteSync") {
if (!token) {
throw new Error(t("errors.authTokenMissing"));
}
// The main process refuses to persist the JWT when it has no OS // The main process refuses to persist the JWT when it has no OS
// keyring to encrypt it with, and reports that by resolving with // keyring to encrypt it with, and reports that by resolving with
// success: false. Dropping the result signs the user in against a // success: false. Dropping the result signs the user in against a
@@ -74,9 +80,13 @@ export function ElectronLoginForm({
: result?.error || t("errors.authTokenSaveFailed"), : result?.error || t("errors.authTokenSaveFailed"),
); );
} }
} else { if (result.status?.needsReauth || result.status?.lastError) {
localStorage.setItem("jwt", token); throw new Error(
result.status.lastError || t("errors.authTokenRejected"),
);
} }
} else if (token) {
localStorage.setItem("jwt", token);
} }
await onAuthSuccessRef.current(token); await onAuthSuccessRef.current(token);
} catch (err) { } catch (err) {
+4
View File
@@ -500,6 +500,8 @@
"disconnectButton": "Disconnect", "disconnectButton": "Disconnect",
"syncNowButton": "Sync Now", "syncNowButton": "Sync Now",
"syncing": "Syncing...", "syncing": "Syncing...",
"syncSuccess": "Remote sync completed",
"syncUnavailable": "Remote sync could not be started",
"serverUrl": "Server URL", "serverUrl": "Server URL",
"enterServerUrl": "Please enter a server URL", "enterServerUrl": "Please enter a server URL",
"mustIncludeProtocol": "Server URL must start with http:// or https://", "mustIncludeProtocol": "Server URL must start with http:// or https://",
@@ -2966,6 +2968,8 @@
"resetCodeRateLimited": "Rate limited: Too many verification attempts. Please try again later.", "resetCodeRateLimited": "Rate limited: Too many verification attempts. Please try again later.",
"resetCodeRateLimitedWithTime": "Rate limited: Too many verification attempts. Please wait {{time}} seconds before trying again.", "resetCodeRateLimitedWithTime": "Rate limited: Too many verification attempts. Please wait {{time}} seconds before trying again.",
"authTokenSaveFailed": "Failed to save authentication token", "authTokenSaveFailed": "Failed to save authentication token",
"authTokenMissing": "The server did not return a new authentication token. Please sign in again.",
"authTokenRejected": "The remote server rejected the new authentication token",
"keyringUnavailable": "Signed in, but the session could not be stored: this system has no keyring available to encrypt it with. Start a Secret Service (such as gnome-keyring or KWallet) and sign in again.", "keyringUnavailable": "Signed in, but the session could not be stored: this system has no keyring available to encrypt it with. Start a Secret Service (such as gnome-keyring or KWallet) and sign in again.",
"failedToLoadServer": "Failed to load server", "failedToLoadServer": "Failed to load server",
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type." "remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
+4
View File
@@ -474,6 +474,8 @@
"disconnectButton": "断开", "disconnectButton": "断开",
"syncNowButton": "立即同步", "syncNowButton": "立即同步",
"syncing": "正在同步...", "syncing": "正在同步...",
"syncSuccess": "远程同步已完成",
"syncUnavailable": "无法启动远程同步",
"serverUrl": "服务器 URL", "serverUrl": "服务器 URL",
"enterServerUrl": "请输入服务器网址", "enterServerUrl": "请输入服务器网址",
"mustIncludeProtocol": "服务器 URL 必须以 http:// 或 https:// 开头。", "mustIncludeProtocol": "服务器 URL 必须以 http:// 或 https:// 开头。",
@@ -2820,6 +2822,8 @@
"resetCodeRateLimited": "频率受限:验证尝试次数过多,请稍后再试。", "resetCodeRateLimited": "频率受限:验证尝试次数过多,请稍后再试。",
"resetCodeRateLimitedWithTime": "频率受限:验证尝试次数过多,请等待 {{time}} 秒后再试。", "resetCodeRateLimitedWithTime": "频率受限:验证尝试次数过多,请等待 {{time}} 秒后再试。",
"authTokenSaveFailed": "保存认证令牌失败", "authTokenSaveFailed": "保存认证令牌失败",
"authTokenMissing": "服务器没有返回新的认证令牌,请重新登录。",
"authTokenRejected": "远程服务器拒绝了新的认证令牌",
"keyringUnavailable": "已登录,但会话无法保存:此系统没有可用于加密的密钥环。请启动密钥服务(例如 gnome-keyring 或 KWallet)并重新登录。", "keyringUnavailable": "已登录,但会话无法保存:此系统没有可用于加密的密钥环。请启动密钥服务(例如 gnome-keyring 或 KWallet)并重新登录。",
"failedToLoadServer": "加载服务器失败", "failedToLoadServer": "加载服务器失败",
"remoteServerRequired": "需要远程服务器。请在“设置”中连接远程服务器以使用此连接类型。" "remoteServerRequired": "需要远程服务器。请在“设置”中连接远程服务器以使用此连接类型。"
+35 -3
View File
@@ -11,6 +11,7 @@ import {
import { RemoteSyncServerPicker } from "./RemoteSyncServerPicker.tsx"; import { RemoteSyncServerPicker } from "./RemoteSyncServerPicker.tsx";
import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx"; import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx";
import { invalidateServerStatusCache } from "@/lib/hosts-request-cache.ts"; import { invalidateServerStatusCache } from "@/lib/hosts-request-cache.ts";
import { toast } from "sonner";
interface RemoteSyncConfig { interface RemoteSyncConfig {
serverUrl: string; serverUrl: string;
@@ -32,9 +33,15 @@ type DesktopSettings = { defaultConnectionOrigin: "local" | "remote" };
interface RemoteSyncPanelProps { interface RemoteSyncPanelProps {
initialServerUrl?: string; initialServerUrl?: string;
reconnectRequested?: boolean;
onReconnectRequestHandled?: () => void;
} }
export function RemoteSyncPanel({ initialServerUrl }: RemoteSyncPanelProps) { export function RemoteSyncPanel({
initialServerUrl,
reconnectRequested = false,
onReconnectRequestHandled,
}: RemoteSyncPanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [config, setConfig] = useState<RemoteSyncConfig | null>(null); const [config, setConfig] = useState<RemoteSyncConfig | null>(null);
const [status, setStatus] = useState<RemoteSyncStatus | null>(null); const [status, setStatus] = useState<RemoteSyncStatus | null>(null);
@@ -79,8 +86,21 @@ export function RemoteSyncPanel({ initialServerUrl }: RemoteSyncPanelProps) {
setStep("picker"); setStep("picker");
}, [initialServerUrl, config]); }, [initialServerUrl, config]);
useEffect(() => {
if (!reconnectRequested || !config?.serverUrl) return;
setPendingServerUrl(config.serverUrl);
setStep("login");
onReconnectRequestHandled?.();
}, [config?.serverUrl, onReconnectRequestHandled, reconnectRequested]);
const handleConnectClick = () => setStep("picker"); const handleConnectClick = () => setStep("picker");
const handleReconnectClick = () => {
if (!config?.serverUrl) return;
setPendingServerUrl(config.serverUrl);
setStep("login");
};
const handleServerConfigured = (serverUrl: string) => { const handleServerConfigured = (serverUrl: string) => {
setPendingServerUrl(serverUrl); setPendingServerUrl(serverUrl);
setStep("login"); setStep("login");
@@ -99,7 +119,19 @@ export function RemoteSyncPanel({ initialServerUrl }: RemoteSyncPanelProps) {
const handleSyncNow = async () => { const handleSyncNow = async () => {
setSyncingNow(true); setSyncingNow(true);
try { try {
await window.electronAPI?.invoke?.("remote-sync-now"); const result = (await window.electronAPI?.invoke?.("remote-sync-now")) as
RemoteSyncStatus | null | undefined;
if (!result || result.needsReauth || result.lastError) {
toast.error(result?.lastError || t("remoteSync.syncUnavailable"));
} else {
toast.success(t("remoteSync.syncSuccess"));
}
} catch (error) {
toast.error(
error instanceof Error && error.message
? error.message
: t("remoteSync.syncUnavailable"),
);
} finally { } finally {
setSyncingNow(false); setSyncingNow(false);
await refresh(); await refresh();
@@ -171,7 +203,7 @@ export function RemoteSyncPanel({ initialServerUrl }: RemoteSyncPanelProps) {
type="button" type="button"
size="sm" size="sm"
className="text-[10px] h-7" className="text-[10px] h-7"
onClick={handleConnectClick} onClick={handleReconnectClick}
> >
{t("remoteSync.bannerReconnect")} {t("remoteSync.bannerReconnect")}
</Button> </Button>
+9 -1
View File
@@ -495,10 +495,14 @@ export function UserProfilePanel({
userPrefs, userPrefs,
onPrefsChange, onPrefsChange,
remoteSyncInitialServerUrl, remoteSyncInitialServerUrl,
remoteSyncReconnectRequested,
onRemoteSyncReconnectHandled,
}: { }: {
username?: string; username?: string;
onLogout?: () => void; onLogout?: () => void;
remoteSyncInitialServerUrl?: string; remoteSyncInitialServerUrl?: string;
remoteSyncReconnectRequested?: boolean;
onRemoteSyncReconnectHandled?: () => void;
userPrefs?: { userPrefs?: {
reopenTabsOnLogin: boolean; reopenTabsOnLogin: boolean;
storageMode?: string | null; storageMode?: string | null;
@@ -1667,7 +1671,11 @@ export function UserProfilePanel({
{isElectron() && ( {isElectron() && (
<div className="border-t border-border pt-3 mt-3"> <div className="border-t border-border pt-3 mt-3">
<RemoteSyncPanel initialServerUrl={remoteSyncInitialServerUrl} /> <RemoteSyncPanel
initialServerUrl={remoteSyncInitialServerUrl}
reconnectRequested={remoteSyncReconnectRequested}
onReconnectRequestHandled={onRemoteSyncReconnectHandled}
/>
</div> </div>
)} )}
@@ -0,0 +1,87 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { toast, invoke } = vi.hoisted(() => ({
toast: { error: vi.fn(), success: vi.fn() },
invoke: vi.fn(),
}));
vi.mock("sonner", () => ({ toast }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/auth/ElectronLoginForm.tsx", () => ({
ElectronLoginForm: ({ serverUrl }: { serverUrl: string }) => (
<div data-testid="remote-login">{serverUrl}</div>
),
}));
import { RemoteSyncPanel } from "../../settings/RemoteSyncPanel";
const config = {
serverUrl: "http://192.168.3.175:6060",
connectedAt: "2026-08-27T00:00:00.000Z",
};
const status = {
connected: true,
syncing: false,
lastSyncedAt: null,
lastError: "Remote session expired",
needsReauth: true,
};
beforeEach(() => {
invoke.mockImplementation((channel: string) => {
if (channel === "get-remote-sync-config") return Promise.resolve(config);
if (channel === "get-remote-sync-status") return Promise.resolve(status);
if (channel === "get-desktop-settings") {
return Promise.resolve({ defaultConnectionOrigin: "local" });
}
if (channel === "remote-sync-now") return Promise.resolve(status);
return Promise.resolve(null);
});
Object.defineProperty(window, "electronAPI", {
configurable: true,
value: { invoke, onRemoteSyncStatusChanged: vi.fn() },
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("RemoteSyncPanel", () => {
it("opens the configured server login when the banner requests reconnect", async () => {
const onHandled = vi.fn();
render(
<RemoteSyncPanel
reconnectRequested
onReconnectRequestHandled={onHandled}
/>,
);
expect((await screen.findByTestId("remote-login")).textContent).toBe(
config.serverUrl,
);
expect(onHandled).toHaveBeenCalledOnce();
});
it("shows the sync failure returned by the main process", async () => {
render(<RemoteSyncPanel />);
const syncButton = await screen.findByRole("button", {
name: /remoteSync.syncNowButton/,
});
fireEvent.click(syncButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Remote session expired");
});
});
});