fix: show the linked account's name and admin flag in the app (#1580)

This commit is contained in:
LukeGus committed 2026-10-19 17:20:32 -05:00
1 parent e284c95891
commit 3caf6cca62
5 files changed
+118 -33

No files matched your search

+11 -31
View File
@@ -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,
};
+40
View File
@@ -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,
};
}
@@ -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();
});
});
+7 -1
View File
@@ -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();
+1 -1
View File
@@ -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 }),