mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-24 06:58:15 +00:00
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:
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user