fix: show remote sync account identity (#1110)

* 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.
This commit is contained in:
ZacharyZcR
2026-07-28 02:08:37 +08:00
committed by GitHub
parent 5f3e840892
commit 44a4534baa
5 changed files with 146 additions and 10 deletions
+4
View File
@@ -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;
});
+36
View File
@@ -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,
};
+17
View File
@@ -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<UserInfo> {
}
}
export async function getRemoteSyncUserInfo(): Promise<RemoteSyncUserInfo | null> {
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<void> {
try {
await authApi.post("/users/me/dismiss-donation-modal");
+23 -10
View File
@@ -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")}
</span>
<span className="text-sm font-semibold mt-0.5">
{username ?? "—"}
{accountUsername || "—"}
</span>
</div>
<div className="flex flex-col py-2">
@@ -1427,7 +1440,7 @@ export function UserProfilePanel({
{t("newUi.sidebar.userProfile.twoFaLabel")}
</span>
<span className="flex items-center gap-1 mt-0.5">
{totpEnabled ? (
{accountTotpEnabled ? (
<>
<ShieldCheck className="size-3.5 text-accent-brand" />
<span className="text-sm font-semibold text-accent-brand">
@@ -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();
});
});