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(); + }); +});