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;
|
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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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": "需要远程服务器。请在“设置”中连接远程服务器以使用此连接类型。"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user