mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
fix: surface remote sync reauthentication failures (#1351)
This commit is contained in:
+3
-2
@@ -1654,7 +1654,7 @@ ipcMain.handle("clear-remote-sync-config", async () => {
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("save-remote-sync-jwt", (_event, token) => {
|
||||
ipcMain.handle("save-remote-sync-jwt", async (_event, token) => {
|
||||
const result = remoteSync.saveRemoteSyncJwt(token);
|
||||
if (result.success) {
|
||||
remoteSync.getRemoteSyncEngine()?.updateStatus({
|
||||
@@ -1662,7 +1662,8 @@ ipcMain.handle("save-remote-sync-jwt", (_event, token) => {
|
||||
needsReauth: false,
|
||||
lastError: null,
|
||||
});
|
||||
remoteSync.getRemoteSyncEngine()?.syncNow();
|
||||
const status = await remoteSync.getRemoteSyncEngine()?.syncNow();
|
||||
return { ...result, status: status || null };
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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": "需要远程服务器。请在“设置”中连接远程服务器以使用此连接类型。"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user