From 3caf6cca62ce2ed30a7bff86764f33bc47f49c14 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 19 Oct 2026 17:20:32 -0500 Subject: [PATCH] fix: show the linked account's name and admin flag in the app (#1580) --- src/backend/database/routes/users.ts | 42 ++++--------- src/backend/sync/shown-identity.ts | 40 +++++++++++++ src/backend/tests/sync/shown-identity.test.ts | 59 +++++++++++++++++++ src/main.tsx | 8 ++- src/ui/auth/Auth.tsx | 2 +- 5 files changed, 118 insertions(+), 33 deletions(-) create mode 100644 src/backend/sync/shown-identity.ts create mode 100644 src/backend/tests/sync/shown-identity.test.ts diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 7a9dd7ce3..bc4a915bf 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -27,6 +27,7 @@ import { registerUserSettingsRoutes } from "./user-settings-routes.js"; import { registerTlsRoutes } from "./tls-routes.js"; import { registerUserSessionRoutes } from "./user-session-routes.js"; import { registerUserSocketTicketRoutes } from "./user-socket-ticket-routes.js"; +import { shownIdentity } from "../../sync/shown-identity.js"; import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js"; import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js"; import { registerUserAdminRoutes } from "./user-admin-routes.js"; @@ -568,30 +569,6 @@ router.post("/logout", authenticateJWT, async (req, res) => { } }); -/** - * On a desktop linked to a server, the account it is signed in to there. - * That account is who the user is; the local profile only mirrors it. - */ -async function describeDesktopLink(userId: string) { - if (process.env.ELECTRON_EMBEDDED !== "true") return null; - try { - const { getLink } = await import("../../sync/client/link-store.js"); - const link = await getLink(userId); - if (!link) return null; - return { - serverUrl: link.serverUrl, - serverName: link.serverName, - username: link.account?.username ?? link.remoteUsername, - isAdmin: !!link.account?.isAdmin, - roles: link.account?.roles ?? [], - permissions: link.account?.permissions ?? [], - status: link.status, - }; - } catch { - return null; - } -} - /** * @openapi * /users/me: @@ -624,18 +601,19 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { const hasPassword = user.passwordHash && user.passwordHash.trim() !== ""; const isDualAuth = hasPassword && isExternalAccount(user); + const shown = await shownIdentity(user); res.json({ userId: user.id, - username: user.username, - is_admin: !!user.isAdmin, + username: shown.username, + is_admin: shown.isAdmin, is_external: isExternalAccount(user), is_dual_auth: isDualAuth, // Any second factor; the name is what 2.8 clients read. totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor( user.id, ), - linked: await describeDesktopLink(user.id), + linked: shown.linked, // "second-factor-setup" while the login policy wants a factor set up. restriction: (req as AuthenticatedRequest).sessionRestriction ?? null, }); @@ -758,10 +736,11 @@ router.post("/internal/auto-session", async (req, res) => { if (existingToken) { const existingPayload = await authManager.verifyJWTToken(existingToken); if (existingPayload?.userId === userRecord.id) { + const shownNow = await shownIdentity(userRecord); return res.json({ success: true, - is_admin: !!userRecord.isAdmin, - username: userRecord.username, + is_admin: shownNow.isAdmin, + username: shownNow.username, token: existingToken, }); } @@ -773,10 +752,11 @@ router.post("/internal/auto-session", async (req, res) => { rememberMe: true, }); + const shown = await shownIdentity(userRecord); const response = { success: true, - is_admin: !!userRecord.isAdmin, - username: userRecord.username, + is_admin: shown.isAdmin, + username: shown.username, token, }; diff --git a/src/backend/sync/shown-identity.ts b/src/backend/sync/shown-identity.ts new file mode 100644 index 000000000..a841a9671 --- /dev/null +++ b/src/backend/sync/shown-identity.ts @@ -0,0 +1,40 @@ +/** + * On a desktop linked to a server, the account it is signed in to there. + * That account is who the user is; the local profile only mirrors it. + */ +export async function describeDesktopLink(userId: string) { + if (process.env.ELECTRON_EMBEDDED !== "true") return null; + try { + const { getLink } = await import("./client/link-store.js"); + const link = await getLink(userId); + if (!link) return null; + return { + serverUrl: link.serverUrl, + serverName: link.serverName, + username: link.account?.username ?? link.remoteUsername, + isAdmin: !!link.account?.isAdmin, + roles: link.account?.roles ?? [], + permissions: link.account?.permissions ?? [], + status: link.status, + }; + } catch { + return null; + } +} + +/** + * Who the app shows. On a desktop linked to a server that is the account it + * is signed in to there, not the local user that holds the copy of its data. + */ +export async function shownIdentity(user: { + id: string; + username: string; + isAdmin: boolean | null; +}) { + const linked = await describeDesktopLink(user.id); + return { + linked, + username: linked?.username || user.username, + isAdmin: linked ? linked.isAdmin : !!user.isAdmin, + }; +} diff --git a/src/backend/tests/sync/shown-identity.test.ts b/src/backend/tests/sync/shown-identity.test.ts new file mode 100644 index 000000000..fd2c293e7 --- /dev/null +++ b/src/backend/tests/sync/shown-identity.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const getLink = vi.hoisted(() => vi.fn()); +vi.mock("../../sync/client/link-store.js", () => ({ getLink })); + +const { shownIdentity } = await import("../../sync/shown-identity.js"); + +const local = { id: "u1", username: "server-ab12", isAdmin: true }; + +beforeEach(() => { + process.env.ELECTRON_EMBEDDED = "true"; + getLink.mockReset(); +}); + +afterEach(() => { + delete process.env.ELECTRON_EMBEDDED; +}); + +describe("who the app shows", () => { + it("shows the local user when nothing is linked", async () => { + getLink.mockResolvedValue(null); + expect(await shownIdentity(local)).toMatchObject({ + username: "server-ab12", + isAdmin: true, + linked: null, + }); + }); + + it("shows the account on the server once linked, with its own admin flag", async () => { + getLink.mockResolvedValue({ + serverUrl: "https://home.example", + serverName: "Home", + remoteUsername: "luke", + account: { username: "luke", isAdmin: false }, + status: "idle", + }); + expect(await shownIdentity(local)).toMatchObject({ + username: "luke", + isAdmin: false, + }); + expect(getLink).toHaveBeenCalledWith("u1"); + }); + + it("shows the sign in name until the account has synced", async () => { + getLink.mockResolvedValue({ + serverUrl: "https://home.example", + remoteUsername: "luke", + account: null, + status: "syncing", + }); + expect((await shownIdentity(local)).username).toBe("luke"); + }); + + it("is the local user on a server", async () => { + delete process.env.ELECTRON_EMBEDDED; + expect((await shownIdentity(local)).username).toBe("server-ab12"); + expect(getLink).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main.tsx b/src/main.tsx index a0d5df84e..2c4e3517e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -14,6 +14,7 @@ import { Auth, getStoredAuth, clearStoredAuth, + storeAuth, markDesktopManualLogout, clearDesktopManualLogout, } from "@/auth/Auth"; @@ -195,7 +196,12 @@ function App() { if (phase !== "verifying") return; appReadyPromise .then(() => getUserInfo()) - .then(async () => { + .then(async (info) => { + // The stored name can be an earlier account, after a switch or rename. + if (info.username) { + setAuthUsername(info.username); + storeAuth(info.username); + } if (isElectron()) { try { const token = await getCurrentToken(); diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index 441005754..f5ef9a746 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -138,7 +138,7 @@ function hasDesktopManualLogout() { return localStorage.getItem(DESKTOP_MANUAL_LOGOUT_KEY) === "true"; } -function storeAuth(username: string) { +export function storeAuth(username: string) { localStorage.setItem( STORAGE_KEY, JSON.stringify({ loggedIn: true, username }),