From 44a4534baaa6f4609aa6f5b683b7886895e04cc2 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Tue, 28 Jul 2026 02:08:37 +0800 Subject: [PATCH] fix: show remote sync account identity (#1110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: show remote sync account identity * cover getRemoteSyncUserInfo and make its null contract hold Nothing asserted the renderer-side gate: browser builds must not reach for the IPC bridge, and a missing bridge, an unconfigured server, an expired JWT or a failed channel all have to degrade to no identity rather than throw. Writing that turned up a mismatch — with no preload bridge the optional chain resolved to undefined while the signature promises null. The only caller uses ??, so nothing is broken today, but the type was not telling the truth. The main-process half (token expiry, /users/me, the roles fallback) stays uncovered: remote-sync.cjs requires electron at load, so exercising it means stubbing safeStorage and the filesystem, which is a bigger change than this PR warrants. --- electron/main.cjs | 4 ++ electron/remote-sync.cjs | 36 ++++++++++ src/ui/main-axios.ts | 17 +++++ src/ui/sidebar/UserProfilePanel.tsx | 33 +++++++--- .../tests/api/remote-sync-user-info.test.ts | 66 +++++++++++++++++++ 5 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 src/ui/tests/api/remote-sync-user-info.test.ts diff --git a/electron/main.cjs b/electron/main.cjs index 64a3255b..6e9c4082 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1579,6 +1579,10 @@ ipcMain.handle("get-remote-sync-status", () => { return remoteSync.getRemoteSyncEngine()?.status || null; }); +ipcMain.handle("get-remote-sync-user-info", () => { + return remoteSync.getRemoteSyncUserInfo(); +}); + ipcMain.handle("remote-sync-now", async () => { return (await remoteSync.getRemoteSyncEngine()?.syncNow()) || null; }); diff --git a/electron/remote-sync.cjs b/electron/remote-sync.cjs index b059731d..7c9f9198 100644 --- a/electron/remote-sync.cjs +++ b/electron/remote-sync.cjs @@ -135,6 +135,41 @@ function clearRemoteSyncJwt() { return { success: true }; } +async function getRemoteSyncUserInfo() { + const config = getRemoteSyncConfig(); + const token = getRemoteSyncJwt(); + if (!config?.serverUrl || !token || isJwtExpiredOrExpiringSoon(token)) { + return null; + } + + const baseUrl = config.serverUrl.replace(/\/$/, ""); + const userResponse = await fetch(`${baseUrl}/users/me`, { + headers: { Authorization: `Bearer ${token}`, "X-Electron-App": "true" }, + }); + if (!userResponse.ok) return null; + + const user = await userResponse.json(); + const rolesResponse = await fetch( + `${baseUrl}/rbac/users/${encodeURIComponent(user.userId)}/roles`, + { + headers: { Authorization: `Bearer ${token}`, "X-Electron-App": "true" }, + }, + ); + const roles = rolesResponse.ok + ? (await rolesResponse.json()).roles || [] + : []; + + return { + userId: user.userId, + username: user.username, + is_admin: !!user.is_admin, + is_oidc: !!user.is_oidc, + is_dual_auth: !!user.is_dual_auth, + totp_enabled: !!user.totp_enabled, + roles, + }; +} + function decodeJwtExpiry(token) { try { const payloadB64 = token.split(".")[1]; @@ -511,6 +546,7 @@ module.exports = { saveRemoteSyncJwt, getRemoteSyncJwt, clearRemoteSyncJwt, + getRemoteSyncUserInfo, isJwtExpiredOrExpiringSoon, decodeJwtExpiry, }; diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index e7da418a..ef272086 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -199,11 +199,16 @@ export interface UserInfo { username: string; is_admin: boolean; is_oidc: boolean; + is_dual_auth?: boolean; password_hash?: string; data_unlocked?: boolean; show_donation_modal?: boolean; } +export interface RemoteSyncUserInfo extends UserInfo { + roles: UserRole[]; +} + interface UserCount { count: number; } @@ -1689,6 +1694,18 @@ export async function getUserInfo(): Promise { } } +export async function getRemoteSyncUserInfo(): Promise { + if (!isElectron()) return null; + try { + // ?? null so a missing preload bridge matches the declared return type + // rather than resolving to undefined. + return ((await window.electronAPI?.invoke?.("get-remote-sync-user-info")) ?? + null) as RemoteSyncUserInfo | null; + } catch { + return null; + } +} + export async function dismissDonationModal(): Promise { try { await authApi.post("/users/me/dismiss-donation-modal"); diff --git a/src/ui/sidebar/UserProfilePanel.tsx b/src/ui/sidebar/UserProfilePanel.tsx index feffde02..2033ef2d 100644 --- a/src/ui/sidebar/UserProfilePanel.tsx +++ b/src/ui/sidebar/UserProfilePanel.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { copyToClipboard } from "@/lib/clipboard"; import { getUserInfo, + getRemoteSyncUserInfo, getApiKeys, createApiKey, deleteApiKey, @@ -479,6 +480,8 @@ export function UserProfilePanel({ // User info const [userId, setUserId] = useState(""); + const [accountUsername, setAccountUsername] = useState(username ?? ""); + const [accountTotpEnabled, setAccountTotpEnabled] = useState(false); const [userRole, setUserRole] = useState(""); const [authMethod, setAuthMethod] = useState(""); const [version, setVersion] = useState(""); @@ -646,11 +649,15 @@ export function UserProfilePanel({ useEffect(() => { getUserInfo() - .then((info) => { - setUserId(info.userId); - setTotpEnabled(info.totp_enabled ?? false); - setIsOidc(info.is_oidc ?? false); - setIsDualAuth(info.is_dual_auth ?? false); + .then(async (localInfo) => { + setUserId(localInfo.userId); + setTotpEnabled(localInfo.totp_enabled ?? false); + setIsOidc(localInfo.is_oidc ?? false); + setIsDualAuth(localInfo.is_dual_auth ?? false); + const remoteInfo = await getRemoteSyncUserInfo(); + const info = remoteInfo ?? localInfo; + setAccountUsername(info.username); + setAccountTotpEnabled(info.totp_enabled ?? false); setUserRole( info.is_admin ? t("newUi.sidebar.userProfile.roleAdministrator") @@ -663,9 +670,13 @@ export function UserProfilePanel({ } else { setAuthMethod(t("newUi.sidebar.userProfile.authMethodLocal")); } - getUserRoles(info.userId) - .then(({ roles }) => setUserRoles(roles ?? [])) - .catch(() => {}); + if (remoteInfo) { + setUserRoles(remoteInfo.roles ?? []); + } else { + getUserRoles(localInfo.userId) + .then(({ roles }) => setUserRoles(roles ?? [])) + .catch(() => {}); + } }) .catch(() => {}); getApiKeys() @@ -1089,6 +1100,7 @@ export function UserProfilePanel({ const result = await enableTOTP(totpCode); setTotpBackupCodes(result.backup_codes ?? []); setTotpEnabled(true); + if (!isRemoteSyncConnected) setAccountTotpEnabled(true); setTotpStep("backup"); toast.success(t("newUi.sidebar.userProfile.totpEnabledSuccess")); } catch (e: unknown) { @@ -1109,6 +1121,7 @@ export function UserProfilePanel({ try { await disableTOTP(disableTotpInput); setTotpEnabled(false); + if (!isRemoteSyncConnected) setAccountTotpEnabled(false); setShowDisableTotp(false); setDisableTotpInput(""); toast.success(t("newUi.sidebar.userProfile.totpDisabledSuccess")); @@ -1393,7 +1406,7 @@ export function UserProfilePanel({ {t("newUi.sidebar.userProfile.usernameLabel")} - {username ?? "—"} + {accountUsername || "—"}
@@ -1427,7 +1440,7 @@ export function UserProfilePanel({ {t("newUi.sidebar.userProfile.twoFaLabel")} - {totpEnabled ? ( + {accountTotpEnabled ? ( <> diff --git a/src/ui/tests/api/remote-sync-user-info.test.ts b/src/ui/tests/api/remote-sync-user-info.test.ts new file mode 100644 index 00000000..1a833ec4 --- /dev/null +++ b/src/ui/tests/api/remote-sync-user-info.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const isElectronMock = vi.hoisted(() => vi.fn(() => true)); + +vi.mock("@/lib/electron", () => ({ isElectron: isElectronMock })); +vi.mock("@/lib/base-path", () => ({ getBasePath: () => "" })); +vi.mock("@/shell/TabContext", () => ({ clearTermixSessionStorage: vi.fn() })); +vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +const { getRemoteSyncUserInfo } = await import("../../main-axios"); + +const invoke = vi.fn(); + +beforeEach(() => { + isElectronMock.mockReturnValue(true); + invoke.mockReset(); + (window as unknown as { electronAPI?: unknown }).electronAPI = { invoke }; +}); + +afterEach(() => { + delete (window as unknown as { electronAPI?: unknown }).electronAPI; +}); + +describe("getRemoteSyncUserInfo", () => { + it("returns the identity reported by the main process", async () => { + const identity = { + userId: "u-1", + username: "alice", + is_admin: true, + is_oidc: false, + totp_enabled: false, + roles: [{ roleId: 1, roleDisplayName: "Admin" }], + }; + invoke.mockResolvedValueOnce(identity); + + await expect(getRemoteSyncUserInfo()).resolves.toEqual(identity); + expect(invoke).toHaveBeenCalledWith("get-remote-sync-user-info"); + }); + + it("returns null in the browser without calling the bridge", async () => { + isElectronMock.mockReturnValue(false); + + await expect(getRemoteSyncUserInfo()).resolves.toBeNull(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("returns null when no remote sync identity is available", async () => { + // The main process answers null when the server is unconfigured, the JWT is + // missing, or it has expired. + invoke.mockResolvedValueOnce(null); + + await expect(getRemoteSyncUserInfo()).resolves.toBeNull(); + }); + + it("returns null instead of propagating a bridge failure", async () => { + invoke.mockRejectedValueOnce(new Error("IPC channel closed")); + + await expect(getRemoteSyncUserInfo()).resolves.toBeNull(); + }); + + it("returns null when the preload bridge is absent", async () => { + delete (window as unknown as { electronAPI?: unknown }).electronAPI; + + await expect(getRemoteSyncUserInfo()).resolves.toBeNull(); + }); +});