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
+7
View File
@@ -333,6 +333,8 @@ export function AppShell({
const [remoteSyncInitialServerUrl, setRemoteSyncInitialServerUrl] = useState<
string | undefined
>(undefined);
const [remoteSyncReconnectRequested, setRemoteSyncReconnectRequested] =
useState(false);
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem("termix_sidebarWidth");
return saved ? parseInt(saved, 10) : 291;
@@ -2714,6 +2716,10 @@ export function AppShell({
setUserPrefs((current) => ({ ...current, ...updates }))
}
remoteSyncInitialServerUrl={remoteSyncInitialServerUrl}
remoteSyncReconnectRequested={remoteSyncReconnectRequested}
onRemoteSyncReconnectHandled={() =>
setRemoteSyncReconnectRequested(false)
}
/>
</div>
)}
@@ -2854,6 +2860,7 @@ export function AppShell({
onReconnect={() => {
setRailView("user-profile");
if (!sidebarOpen) setSidebarOpen(true);
setRemoteSyncReconnectRequested(true);
}}
/>
<MigrationNoticeDialog
+30 -20
View File
@@ -20,6 +20,10 @@ interface SaveRemoteSyncJwtResult {
success: boolean;
reason?: string;
error?: string;
status?: {
needsReauth?: boolean;
lastError?: string | null;
} | null;
}
const AUTH_MESSAGE_SOURCES = new Set([
@@ -56,27 +60,33 @@ export function ElectronLoginForm({
setIsAuthenticating(true);
try {
if (token) {
if (targetPurpose === "remoteSync") {
// The main process refuses to persist the JWT when it has no OS
// keyring to encrypt it with, and reports that by resolving with
// success: false. Dropping the result signs the user in against a
// store that kept nothing, so the next sync tick calls a session
// that was never saved expired.
const result = (await window.electronAPI?.invoke?.(
"save-remote-sync-jwt",
token,
)) as SaveRemoteSyncJwtResult | undefined;
if (!result?.success) {
throw new Error(
result?.reason === "encryption_unavailable"
? t("errors.keyringUnavailable")
: result?.error || t("errors.authTokenSaveFailed"),
);
}
} else {
localStorage.setItem("jwt", token);
if (targetPurpose === "remoteSync") {
if (!token) {
throw new Error(t("errors.authTokenMissing"));
}
// The main process refuses to persist the JWT when it has no OS
// keyring to encrypt it with, and reports that by resolving with
// success: false. Dropping the result signs the user in against a
// store that kept nothing, so the next sync tick calls a session
// that was never saved expired.
const result = (await window.electronAPI?.invoke?.(
"save-remote-sync-jwt",
token,
)) as SaveRemoteSyncJwtResult | undefined;
if (!result?.success) {
throw new Error(
result?.reason === "encryption_unavailable"
? t("errors.keyringUnavailable")
: result?.error || t("errors.authTokenSaveFailed"),
);
}
if (result.status?.needsReauth || result.status?.lastError) {
throw new Error(
result.status.lastError || t("errors.authTokenRejected"),
);
}
} else if (token) {
localStorage.setItem("jwt", token);
}
await onAuthSuccessRef.current(token);
} catch (err) {
+4
View File
@@ -500,6 +500,8 @@
"disconnectButton": "Disconnect",
"syncNowButton": "Sync Now",
"syncing": "Syncing...",
"syncSuccess": "Remote sync completed",
"syncUnavailable": "Remote sync could not be started",
"serverUrl": "Server URL",
"enterServerUrl": "Please enter a server URL",
"mustIncludeProtocol": "Server URL must start with http:// or https://",
@@ -2966,6 +2968,8 @@
"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.",
"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.",
"failedToLoadServer": "Failed to load server",
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
+4
View File
@@ -474,6 +474,8 @@
"disconnectButton": "断开",
"syncNowButton": "立即同步",
"syncing": "正在同步...",
"syncSuccess": "远程同步已完成",
"syncUnavailable": "无法启动远程同步",
"serverUrl": "服务器 URL",
"enterServerUrl": "请输入服务器网址",
"mustIncludeProtocol": "服务器 URL 必须以 http:// 或 https:// 开头。",
@@ -2820,6 +2822,8 @@
"resetCodeRateLimited": "频率受限:验证尝试次数过多,请稍后再试。",
"resetCodeRateLimitedWithTime": "频率受限:验证尝试次数过多,请等待 {{time}} 秒后再试。",
"authTokenSaveFailed": "保存认证令牌失败",
"authTokenMissing": "服务器没有返回新的认证令牌,请重新登录。",
"authTokenRejected": "远程服务器拒绝了新的认证令牌",
"keyringUnavailable": "已登录,但会话无法保存:此系统没有可用于加密的密钥环。请启动密钥服务(例如 gnome-keyring 或 KWallet)并重新登录。",
"failedToLoadServer": "加载服务器失败",
"remoteServerRequired": "需要远程服务器。请在“设置”中连接远程服务器以使用此连接类型。"
+35 -3
View File
@@ -11,6 +11,7 @@ import {
import { RemoteSyncServerPicker } from "./RemoteSyncServerPicker.tsx";
import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx";
import { invalidateServerStatusCache } from "@/lib/hosts-request-cache.ts";
import { toast } from "sonner";
interface RemoteSyncConfig {
serverUrl: string;
@@ -32,9 +33,15 @@ type DesktopSettings = { defaultConnectionOrigin: "local" | "remote" };
interface RemoteSyncPanelProps {
initialServerUrl?: string;
reconnectRequested?: boolean;
onReconnectRequestHandled?: () => void;
}
export function RemoteSyncPanel({ initialServerUrl }: RemoteSyncPanelProps) {
export function RemoteSyncPanel({
initialServerUrl,
reconnectRequested = false,
onReconnectRequestHandled,
}: RemoteSyncPanelProps) {
const { t } = useTranslation();
const [config, setConfig] = useState<RemoteSyncConfig | null>(null);
const [status, setStatus] = useState<RemoteSyncStatus | null>(null);
@@ -79,8 +86,21 @@ export function RemoteSyncPanel({ initialServerUrl }: RemoteSyncPanelProps) {
setStep("picker");
}, [initialServerUrl, config]);
useEffect(() => {
if (!reconnectRequested || !config?.serverUrl) return;
setPendingServerUrl(config.serverUrl);
setStep("login");
onReconnectRequestHandled?.();
}, [config?.serverUrl, onReconnectRequestHandled, reconnectRequested]);
const handleConnectClick = () => setStep("picker");
const handleReconnectClick = () => {
if (!config?.serverUrl) return;
setPendingServerUrl(config.serverUrl);
setStep("login");
};
const handleServerConfigured = (serverUrl: string) => {
setPendingServerUrl(serverUrl);
setStep("login");
@@ -99,7 +119,19 @@ export function RemoteSyncPanel({ initialServerUrl }: RemoteSyncPanelProps) {
const handleSyncNow = async () => {
setSyncingNow(true);
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 {
setSyncingNow(false);
await refresh();
@@ -171,7 +203,7 @@ export function RemoteSyncPanel({ initialServerUrl }: RemoteSyncPanelProps) {
type="button"
size="sm"
className="text-[10px] h-7"
onClick={handleConnectClick}
onClick={handleReconnectClick}
>
{t("remoteSync.bannerReconnect")}
</Button>
+9 -1
View File
@@ -495,10 +495,14 @@ export function UserProfilePanel({
userPrefs,
onPrefsChange,
remoteSyncInitialServerUrl,
remoteSyncReconnectRequested,
onRemoteSyncReconnectHandled,
}: {
username?: string;
onLogout?: () => void;
remoteSyncInitialServerUrl?: string;
remoteSyncReconnectRequested?: boolean;
onRemoteSyncReconnectHandled?: () => void;
userPrefs?: {
reopenTabsOnLogin: boolean;
storageMode?: string | null;
@@ -1667,7 +1671,11 @@ export function UserProfilePanel({
{isElectron() && (
<div className="border-t border-border pt-3 mt-3">
<RemoteSyncPanel initialServerUrl={remoteSyncInitialServerUrl} />
<RemoteSyncPanel
initialServerUrl={remoteSyncInitialServerUrl}
reconnectRequested={remoteSyncReconnectRequested}
onReconnectRequestHandled={onRemoteSyncReconnectHandled}
/>
</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");
});
});
});