From 0c5d70dd5f08c919c6ac4a9732738895d9721c05 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 17:55:26 +0000 Subject: [PATCH 01/11] fix(security): stop sending the user's email over the session WebSocket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five call sites passed getCurrentUserEmail() as the display_name query parameter, and the server echoed it to every participant — so a stranger admitted by knock learned everyone's real email address. The server resolves the name from users.handle now, so the client sends nothing. The Participant type's display_name field is renamed to handle to match, and every in-repo consumer of it (MultiplayerBar, ShareMenu, TeamSessions' avatar stack, teamInbox's participant-name lookups, the sharing plugin domain) is updated to read the server-resolved handle instead. --- src/components/hosts/TeamSessions.test.tsx | 19 +++++------ src/components/hosts/TeamSessions.tsx | 5 +-- src/components/omni/OmniSearch.tsx | 5 --- .../terminal/MultiplayerBar.test.tsx | 2 +- src/components/terminal/MultiplayerBar.tsx | 4 +-- .../terminal/ShareMenu.invitePeople.test.tsx | 6 ++-- src/components/terminal/ShareMenu.test.tsx | 4 +-- src/components/terminal/ShareMenu.tsx | 2 +- src/plugins/domains/sharing.test.ts | 4 +-- src/plugins/domains/sharing.ts | 2 +- src/services/multiplayerService.ts | 13 +++---- src/services/multiplayerService.ws.test.ts | 34 ++++++++++++------- src/services/teamInbox.test.ts | 15 ++++---- src/services/teamInbox.ts | 7 ++-- src/services/teamSessionJoin.ts | 2 -- src/stores/teamSessionStore.test.ts | 5 ++- src/stores/teamSessionStore.ts | 8 ++--- 17 files changed, 60 insertions(+), 77 deletions(-) diff --git a/src/components/hosts/TeamSessions.test.tsx b/src/components/hosts/TeamSessions.test.tsx index 7514bcb81..1b6f0ff3e 100644 --- a/src/components/hosts/TeamSessions.test.tsx +++ b/src/components/hosts/TeamSessions.test.tsx @@ -23,7 +23,7 @@ interface TeamState { activeSessions: unknown[]; fetchActiveSessions: ReturnType; joinSession: ReturnType; - connections: Record; + connections: Record; } interface SessionState { sessions: unknown[]; @@ -65,25 +65,23 @@ const h = vi.hoisted(() => { uiState, useUIStore, getMyUserId: vi.fn(async () => "me" as string | null), - getCurrentUserEmail: vi.fn(async () => "me@x" as string | null), accessibleVaultIds: vi.fn(() => ["team-1"] as string[]), }; }); vi.mock("@/services/teamService", () => ({ getMyUserId: () => h.getMyUserId() })); -vi.mock("@/services/account", () => ({ getCurrentUserEmail: () => h.getCurrentUserEmail() })); vi.mock("@/hooks/useAccessibleVaultIds", () => ({ useAccessibleVaultIds: () => h.accessibleVaultIds() })); vi.mock("@/stores/teamSessionStore", () => ({ useTeamSessionStore: h.useTeamSessionStore })); vi.mock("@/stores/sessionStore", () => ({ useSessionStore: h.useSessionStore })); vi.mock("@/stores/uiStore", () => ({ useUIStore: h.useUIStore })); -const { teamState, sessionState, uiState, getMyUserId, getCurrentUserEmail, accessibleVaultIds } = h; +const { teamState, sessionState, uiState, getMyUserId, accessibleVaultIds } = h; import { TeamSessions } from "./TeamSessions"; const active = (o: Partial<{ id: string; connection_name: string; host_user_id: string; - participant_count: number; participants: { user_id: string; display_name: string }[]; vault_ids: string[]; + participant_count: number; participants: { user_id: string; handle: string }[]; vault_ids: string[]; }> = {}) => ({ id: o.id ?? "sess-1", connection_name: o.connection_name ?? "Prod DB", @@ -107,7 +105,6 @@ beforeEach(() => { uiState.homeView = true; accessibleVaultIds.mockReturnValue(["team-1"]); getMyUserId.mockReset().mockResolvedValue("me"); - getCurrentUserEmail.mockReset().mockResolvedValue("me@x"); }); afterEach(() => cleanup()); @@ -184,7 +181,7 @@ test("valid code calls joinSession with sessionId + token", async () => { fireEvent.change(input, { target: { value: "sess-9:tok-9" } }); fireEvent.click(screen.getByText("hosts.teamSessions.join")); await waitFor(() => - expect(teamState.joinSession).toHaveBeenCalledWith("sess-9", expect.any(String), expect.any(Function), "tok-9"), + expect(teamState.joinSession).toHaveBeenCalledWith("sess-9", expect.any(Function), "tok-9"), ); }); @@ -234,12 +231,12 @@ test("renders exactly one join affordance and an empty-state hint when no sessio test("participant list prefers live WS connection participants over server participants", () => { uiState.homeView = true; teamState.activeSessions = [ - active({ id: "s1", participants: [{ user_id: "u1", display_name: "ServerName" }] }), + active({ id: "s1", participants: [{ user_id: "u1", handle: "ServerName" }] }), ]; teamState.connections = { "local-1": { multiplayerSessionId: "s1", - participants: [{ display_name: "LiveA" }, { display_name: "LiveB" }], + participants: [{ handle: "LiveA" }, { handle: "LiveB" }], }, }; render(); @@ -252,8 +249,8 @@ test("falls back to server participants when not in the session", () => { active({ id: "s1", participants: [ - { user_id: "u1", display_name: "A" }, - { user_id: "u2", display_name: "B" }, + { user_id: "u1", handle: "A" }, + { user_id: "u2", handle: "B" }, ], }), ]; diff --git a/src/components/hosts/TeamSessions.tsx b/src/components/hosts/TeamSessions.tsx index d88efc5a1..1a09c2508 100644 --- a/src/components/hosts/TeamSessions.tsx +++ b/src/components/hosts/TeamSessions.tsx @@ -4,7 +4,6 @@ import { Icon } from "@iconify/react"; import { useTeamSessionStore } from "@/stores/teamSessionStore"; import { useSessionStore } from "@/stores/sessionStore"; import { useTeamSessionStore as useMpStore } from "@/stores/teamSessionStore"; -import { getCurrentUserEmail } from "@/services/account"; import { getMyUserId } from "@/services/teamService"; import { useUIStore } from "@/stores/uiStore"; import { useAccessibleVaultIds } from "@/hooks/useAccessibleVaultIds"; @@ -82,10 +81,8 @@ export function TeamSessions() { ); const doJoinSession = async (sessionId: string, inviteToken?: string) => { - const displayName = (await getCurrentUserEmail()) ?? t("hosts.teamSessions.meFallback"); await joinTeamSessionAndOpenTab({ sessionId, - displayName, // Session not found (not yet loaded) collapses to the same redacted state as a null name. connectionName: sessionDisplayName({ connection_name: activeSessions.find((a) => a.id === sessionId)?.connection_name ?? null, @@ -239,7 +236,7 @@ export function TeamSessions() { ? useMpStore.getState().connections[liveLocalId]?.participants : undefined; const participants = (liveParticipants ?? session.participants)?.map((p) => ({ - name: p.display_name, + name: p.handle, })); return ( diff --git a/src/components/omni/OmniSearch.tsx b/src/components/omni/OmniSearch.tsx index 81fa71b79..bb2e8ac4f 100644 --- a/src/components/omni/OmniSearch.tsx +++ b/src/components/omni/OmniSearch.tsx @@ -29,7 +29,6 @@ import { useVaultStore } from "@/stores/vaultStore"; import { useTeamStore } from "@/stores/teamStore"; import { useTeamSessionStore } from "@/stores/teamSessionStore"; import type { ActiveSession } from "@/stores/teamSessionStore"; -import { getCurrentUserEmail } from "@/services/account"; import { joinTeamSessionAndOpenTab } from "@/services/teamSessionJoin"; import { useToggleSettings } from "@/hooks/useToggleSettings"; import { parseQuickConnect, type QuickConnectIntent } from "@/services/quickConnect"; @@ -479,10 +478,8 @@ export default function OmniSearch({ onClose }: OmniSearchProps) { } } else { (async () => { - const displayName = (await getCurrentUserEmail()) ?? "Me"; await joinTeamSessionAndOpenTab({ sessionId: session.id, - displayName, connectionName: sessionDisplayName(session), }); setSidebarOpen(false); @@ -501,10 +498,8 @@ export default function OmniSearch({ onClose }: OmniSearchProps) { if (parsed) { const { sessionId, token } = parsed; (async () => { - const displayName = (await getCurrentUserEmail()) ?? "Me"; await joinTeamSessionAndOpenTab({ sessionId, - displayName, connectionName: "Shared Terminal", inviteToken: token, }); diff --git a/src/components/terminal/MultiplayerBar.test.tsx b/src/components/terminal/MultiplayerBar.test.tsx index 6b69f7aaf..09a3d20b1 100644 --- a/src/components/terminal/MultiplayerBar.test.tsx +++ b/src/components/terminal/MultiplayerBar.test.tsx @@ -52,7 +52,7 @@ interface MpState { controlHolder: string; controlRequester: string | null; ended: boolean; - participants: Array<{ user_id: string; display_name: string }>; + participants: Array<{ user_id: string; handle: string }>; } function mk(overrides: Partial = {}): MpState { diff --git a/src/components/terminal/MultiplayerBar.tsx b/src/components/terminal/MultiplayerBar.tsx index 2b70f5a88..8f11ee981 100644 --- a/src/components/terminal/MultiplayerBar.tsx +++ b/src/components/terminal/MultiplayerBar.tsx @@ -87,9 +87,9 @@ export function MultiplayerBar({ localSessionId }: MultiplayerBarProps) { ? "1.5px solid var(--t-accent)" : "1.5px solid var(--t-border)", }} - title={p.display_name} + title={p.handle} > - {p.display_name.slice(0, 2).toUpperCase()} + {p.handle.slice(0, 2).toUpperCase()} ))} {mpState.participants.length > 5 && ( diff --git a/src/components/terminal/ShareMenu.invitePeople.test.tsx b/src/components/terminal/ShareMenu.invitePeople.test.tsx index 3cc69095d..046f74ec8 100644 --- a/src/components/terminal/ShareMenu.invitePeople.test.tsx +++ b/src/components/terminal/ShareMenu.invitePeople.test.tsx @@ -78,7 +78,7 @@ function hostConnection(extra: Record = {}) { return { "local-1": { multiplayerSessionId: "mp-1", ended: false, - participants: [{ user_id: "me", display_name: "Me" }], myUserId: "me", controlHolder: "me", + participants: [{ user_id: "me", handle: "Me" }], myUserId: "me", controlHolder: "me", sessionKeyBytes: new Uint8Array([1]), ...extra, }, @@ -143,7 +143,7 @@ test("a pending invitee renders as non-tappable Invited, not Has access", async test("a participant already in the session still renders Has access", async () => { mpState.connections = hostConnection({ - participants: [{ user_id: "me", display_name: "Me" }, { user_id: "alice", display_name: "Alice" }], + participants: [{ user_id: "me", handle: "Me" }, { user_id: "alice", handle: "Alice" }], }); render(shareMenuElement()); @@ -222,7 +222,7 @@ test("setup view: a Pro host (cap 1) cannot tap a second teammate after the firs test("active view: a Pro host (cap 1) already at cap shows the remaining rows as non-tappable", async () => { mpState.connections = hostConnection({ - participants: [{ user_id: "me", display_name: "Me" }, { user_id: "guest-1", display_name: "Guest" }], + participants: [{ user_id: "me", handle: "Me" }, { user_id: "guest-1", handle: "Guest" }], }); render(shareMenuElement()); const alice = (await screen.findByRole("button", { name: /alice/i })) as HTMLButtonElement; diff --git a/src/components/terminal/ShareMenu.test.tsx b/src/components/terminal/ShareMenu.test.tsx index a16c7081c..4d57ab4e8 100644 --- a/src/components/terminal/ShareMenu.test.tsx +++ b/src/components/terminal/ShareMenu.test.tsx @@ -96,7 +96,7 @@ test("generating an invite link copies the code to the clipboard and shows the c test("with only the host in participants, the waiting line renders and no lone self-chip appears", () => { mpState.connections = { - "local-1": { multiplayerSessionId: "mp-1", ended: false, participants: [{ user_id: "me", display_name: "Me" }], myUserId: "me", controlHolder: "me" }, + "local-1": { multiplayerSessionId: "mp-1", ended: false, participants: [{ user_id: "me", handle: "Me" }], myUserId: "me", controlHolder: "me" }, }; renderMenu(); @@ -109,7 +109,7 @@ test("with a guest present, the chips render and the waiting line does not", () "local-1": { multiplayerSessionId: "mp-1", ended: false, - participants: [{ user_id: "me", display_name: "Me" }, { user_id: "guest-1", display_name: "Guest" }], + participants: [{ user_id: "me", handle: "Me" }, { user_id: "guest-1", handle: "Guest" }], myUserId: "me", controlHolder: "me", }, diff --git a/src/components/terminal/ShareMenu.tsx b/src/components/terminal/ShareMenu.tsx index 391be9dc3..f52b602df 100644 --- a/src/components/terminal/ShareMenu.tsx +++ b/src/components/terminal/ShareMenu.tsx @@ -484,7 +484,7 @@ function ActiveSharingView({ title={p.user_id === activeMp.controlHolder ? t("terminal.share.hasControl") : undefined} > {p.user_id === activeMp.controlHolder && } - {p.display_name} + {p.handle} ))} diff --git a/src/plugins/domains/sharing.test.ts b/src/plugins/domains/sharing.test.ts index f69353c2f..ab9bc9ef1 100644 --- a/src/plugins/domains/sharing.test.ts +++ b/src/plugins/domains/sharing.test.ts @@ -6,7 +6,7 @@ import { const hostState = (over: Record = {}) => ({ multiplayerSessionId: "m1", role: "host" as const, myUserId: "u0", - participants: [{ user_id: "u2", display_name: "Two" }], + participants: [{ user_id: "u2", handle: "Two" }], controlHolder: "u0", controlRequester: null, connection: {} as MultiplayerConnection, ...over, }); @@ -16,7 +16,7 @@ function ports(over: Partial = {}): SharingPorts { activeSessions: () => [{ id: "m1", connection_name: "web-1", host_user_id: "u0", host_public_key: "", visibility: "team", created_at: "", participant_count: 1, - participants: [{ user_id: "u2", display_name: "Two" }], + participants: [{ user_id: "u2", handle: "Two" }], }], fetchActiveSessions: vi.fn(async () => {}), state: (id: string) => (id === "s1" ? hostState() : undefined), diff --git a/src/plugins/domains/sharing.ts b/src/plugins/domains/sharing.ts index ede9b3ad0..9099b6590 100644 --- a/src/plugins/domains/sharing.ts +++ b/src/plugins/domains/sharing.ts @@ -79,7 +79,7 @@ export async function listSharedSessions(ports: SharingPorts): Promise ({ userId: p.user_id, - displayName: p.display_name, + displayName: p.handle, })), controlHolder: live?.controlHolder ?? s.host_user_id, controlRequester: live?.controlRequester ?? null, diff --git a/src/services/multiplayerService.ts b/src/services/multiplayerService.ts index 75c3eaee8..6a6a35694 100644 --- a/src/services/multiplayerService.ts +++ b/src/services/multiplayerService.ts @@ -23,11 +23,7 @@ export interface ActiveSession { vault_ids?: string[]; /** Set when this session reached me through an individual invite (#66). */ invited_by?: string | null; - /** - * `invited_by`'s handle, resolved by the server from its own `users` table. - * The only inviter identity a stranger knock may render: participant - * `display_name` is supplied by the sender's own WebSocket query string. - */ + /** `invited_by`'s handle, resolved by the server from its own `users` table. */ invited_by_handle?: string | null; /** Everyone the host has individually invited (#66). Only set for the host. */ invitee_ids?: string[]; @@ -35,7 +31,7 @@ export interface ActiveSession { export interface Participant { user_id: string; - display_name: string; + handle: string; } export interface SessionCallbacks { @@ -410,7 +406,6 @@ export function openWebSocket( serverUrl: string, sessionId: string, jwt: string, - displayName: string, sessionKey: SessionKey, callbacks: SessionCallbacks, inviteToken?: string, @@ -419,7 +414,7 @@ export function openWebSocket( let wsUrl = serverUrl .replace(/^https?/, (m) => (m === "https" ? "wss" : "ws")) + `/v1/terminal-sessions/${sessionId}/ws` - + `?token=${encodeURIComponent(jwt)}&display_name=${encodeURIComponent(displayName)}`; + + `?token=${encodeURIComponent(jwt)}`; if (inviteToken) { wsUrl += `&invite_token=${encodeURIComponent(inviteToken)}`; @@ -454,7 +449,7 @@ export function openWebSocket( callbacks.onControlUpdate(msg.holder as string, (msg.requester as string | null) ?? null); break; case "participant_joined": - callbacks.onParticipantJoined({ user_id: msg.user_id as string, display_name: msg.display_name as string }); + callbacks.onParticipantJoined({ user_id: msg.user_id as string, handle: msg.handle as string }); break; case "participant_left": callbacks.onParticipantLeft(msg.user_id as string); diff --git a/src/services/multiplayerService.ws.test.ts b/src/services/multiplayerService.ws.test.ts index 877d1fc7d..0bcf353dc 100644 --- a/src/services/multiplayerService.ws.test.ts +++ b/src/services/multiplayerService.ws.test.ts @@ -43,35 +43,45 @@ beforeEach(() => { MockWS.last = undefined as unknown as MockWS; }); +// This test exists to stop the email leak being reintroduced. The WebSocket +// used to carry a client-supplied display_name that every call site filled +// with the user's own email address, so a stranger admitted by knock learned +// everyone's real address. The name is resolved server-side now; if a +// display_name parameter ever reappears on this URL, that leak is back. +test("openWebSocket sends no display_name query parameter, and no email", async () => { + openWebSocket("https://s", "sid", "jwt", await key(), noopCallbacks()); + expect(MockWS.last.url).not.toContain("display_name"); + expect(MockWS.last.url).not.toContain("%40"); // no encoded @, i.e. no email anywhere in the URL +}); + test("openWebSocket rewrites https->wss and appends invite_token", async () => { - openWebSocket("https://s", "sid", "jwt", "Dana", await key(), noopCallbacks(), "tok"); + openWebSocket("https://s", "sid", "jwt", await key(), noopCallbacks(), "tok"); expect(MockWS.last.url).toMatch(/^wss:\/\/s\/v1\/terminal-sessions\/sid\/ws\?/); expect(MockWS.last.url).toContain("invite_token=tok"); - expect(MockWS.last.url).toContain("display_name=Dana"); }); -test("onmessage dispatches control/participant events to callbacks", async () => { +test("a participant is named by the handle the server resolved", async () => { const cb = noopCallbacks(); - openWebSocket("https://s", "sid", "jwt", "Dana", await key(), cb); + openWebSocket("https://s", "sid", "jwt", await key(), cb); const fire = (msg: unknown) => MockWS.last.onmessage!({ data: JSON.stringify(msg) }); fire({ type: "control_update", holder: "u1", requester: "u2" }); - fire({ type: "participant_joined", user_id: "u3", display_name: "Eve" }); + fire({ type: "participant_joined", user_id: "u3", handle: "merry-quartz-2597" }); fire({ type: "participant_left", user_id: "u3" }); - fire({ type: "participant_list", participants: [{ user_id: "u1", display_name: "A" }] }); + fire({ type: "participant_list", participants: [{ user_id: "u1", handle: "A" }] }); fire({ type: "session_ended" }); expect(cb.onControlUpdate).toHaveBeenCalledWith("u1", "u2"); - expect(cb.onParticipantJoined).toHaveBeenCalledWith({ user_id: "u3", display_name: "Eve" }); + expect(cb.onParticipantJoined).toHaveBeenCalledWith({ user_id: "u3", handle: "merry-quartz-2597" }); expect(cb.onParticipantLeft).toHaveBeenCalledWith("u3"); - expect(cb.onParticipantList).toHaveBeenCalledWith([{ user_id: "u1", display_name: "A" }]); + expect(cb.onParticipantList).toHaveBeenCalledWith([{ user_id: "u1", handle: "A" }]); expect(cb.onSessionEnded).toHaveBeenCalledTimes(1); }); test("output messages are decrypted before reaching onOutput", async () => { const cb = noopCallbacks(); const k = await key(); - openWebSocket("https://s", "sid", "jwt", "Dana", k, cb); + openWebSocket("https://s", "sid", "jwt", k, cb); const payload = new TextEncoder().encode("terminal bytes"); const encrypted = await encryptData(k, payload); await MockWS.last.onmessage!({ data: JSON.stringify({ type: "output", data: encrypted }) }); @@ -82,13 +92,13 @@ test("output messages are decrypted before reaching onOutput", async () => { test("malformed message JSON is swallowed", async () => { const cb = noopCallbacks(); - openWebSocket("https://s", "sid", "jwt", "Dana", await key(), cb); + openWebSocket("https://s", "sid", "jwt", await key(), cb); await MockWS.last.onmessage!({ data: "not-json{" }); expect(cb.onOutput).not.toHaveBeenCalled(); }); test("sendOutput encrypts, send is suppressed when socket not open", async () => { - const conn = openWebSocket("https://s", "sid", "jwt", "Dana", await key(), noopCallbacks()); + const conn = openWebSocket("https://s", "sid", "jwt", await key(), noopCallbacks()); await conn.sendOutput(new Uint8Array([1, 2, 3])); expect(MockWS.last.sent).toHaveLength(1); expect(JSON.parse(MockWS.last.sent[0]).type).toBe("output"); @@ -100,7 +110,7 @@ test("sendOutput encrypts, send is suppressed when socket not open", async () => test("initial snapshot is encrypted and sent on open", async () => { const k = await key(); - openWebSocket("https://s", "sid", "jwt", "Dana", k, noopCallbacks(), undefined, new Uint8Array([5, 5])); + openWebSocket("https://s", "sid", "jwt", k, noopCallbacks(), undefined, new Uint8Array([5, 5])); await MockWS.last.onopen!(); expect(MockWS.last.sent).toHaveLength(1); expect(JSON.parse(MockWS.last.sent[0]).type).toBe("output"); diff --git a/src/services/teamInbox.test.ts b/src/services/teamInbox.test.ts index 3e6434793..ecc81c323 100644 --- a/src/services/teamInbox.test.ts +++ b/src/services/teamInbox.test.ts @@ -23,7 +23,6 @@ const h = vi.hoisted(() => { accept: vi.fn(async () => {}), decline: vi.fn(async () => {}), declineSessionInvite: vi.fn(async () => {}), - getCurrentUserEmail: vi.fn(async () => "me@x" as string | null), isMobileShell: vi.fn(() => false), sessionState, useSessionStore, @@ -44,7 +43,6 @@ vi.mock("@/services/teamService", () => ({ declineSessionInvite: h.declineSessionInvite, getMyUserId: vi.fn(async () => "me"), })); -vi.mock("@/services/account", () => ({ getCurrentUserEmail: () => h.getCurrentUserEmail() })); vi.mock("@/utils/platform", () => ({ getPlatform: async () => "linux", isMobileShell: () => h.isMobileShell(), @@ -86,7 +84,6 @@ beforeEach(() => { useNotificationStore.setState({ toasts: [], banners: [], history: [], inbox: [] }); h.accept.mockClear(); h.decline.mockClear(); - h.getCurrentUserEmail.mockClear().mockResolvedValue("me@x"); h.isMobileShell.mockClear().mockReturnValue(false); h.joinSession.mockClear().mockResolvedValue("local-99"); h.grantControl.mockClear(); @@ -251,7 +248,7 @@ test("falls back to a generic inviter name when the inviter is not in participan test("uses the inviter's display name from participants when available", () => { reconcileSessions( - [session({ id: "s1", invited_by: "alice", participants: [{ user_id: "alice", display_name: "Alice" }] })], + [session({ id: "s1", invited_by: "alice", participants: [{ user_id: "alice", handle: "Alice" }] })], new Set(), "me", ); @@ -275,8 +272,8 @@ test("a redacted invite renders as a knock from the inviter alone", () => { ]); }); -// The handle is server-owned; a participant display_name arrives in the sender's -// own WebSocket query string, so honouring it here is an impersonation vector. +// invited_by_handle is resolved server-side from users.handle; a participant's +// own handle (also server-resolved) must never override it for a knock. test("a knock renders the server handle and never a participant display name", () => { reconcileSessions( [ @@ -284,7 +281,7 @@ test("a knock renders the server handle and never a participant display name", ( connection_name: null, invited_by: "u-stranger", invited_by_handle: "kevin-p", - participants: [{ user_id: "u-stranger", display_name: "Voltius Support" }], + participants: [{ user_id: "u-stranger", handle: "Voltius Support" }], }), ], new Set(), @@ -301,7 +298,7 @@ test("a knock with no handle falls back to Someone, not to the supplied name", ( session({ connection_name: null, invited_by: "u-stranger", - participants: [{ user_id: "u-stranger", display_name: "Voltius Support" }], + participants: [{ user_id: "u-stranger", handle: "Voltius Support" }], }), ], new Set(), @@ -398,7 +395,7 @@ test("running the inbox Join action opens a session tab, not just a websocket", reconcileSessions([session({ id: "s1" })], new Set(), "me"); await get().runInboxAction("session:s1", 0); - expect(h.joinSession).toHaveBeenCalledWith("s1", "me@x", expect.any(Function), undefined); + expect(h.joinSession).toHaveBeenCalledWith("s1", expect.any(Function), undefined); // The bug this regression test guards against: joining without ever adding // a sessionStore tab left MultiplayerBar (keyed off that tab id) with // nothing to render. diff --git a/src/services/teamInbox.ts b/src/services/teamInbox.ts index c300a7c58..d052bf1cd 100644 --- a/src/services/teamInbox.ts +++ b/src/services/teamInbox.ts @@ -8,7 +8,6 @@ import type { MultiplayerSessionState } from "@/stores/teamSessionStore"; import { useTeamVaultStateStore } from "@/stores/teamVaultStateStore"; import type { TeamVaultStatus } from "@/stores/teamVaultStateStore"; import { acceptInvitation, declineInvitation } from "@/services/invitationActions"; -import { getCurrentUserEmail } from "@/services/account"; import { joinTeamSessionAndOpenTab } from "@/services/teamSessionJoin"; import { getPlatform, isMobileShell } from "@/utils/platform"; import { declineSessionInvite, getMyUserId } from "@/services/teamService"; @@ -109,10 +108,8 @@ async function revealJoinedSessionName(sessionId: string, localSessionId: string } async function joinSharedSession(session: ActiveSession): Promise { - const displayName = (await getCurrentUserEmail()) ?? i18n.t("hosts.teamSessions.meFallback"); const localSessionId = await joinTeamSessionAndOpenTab({ sessionId: session.id, - displayName, connectionName: sessionDisplayName(session), }); @@ -163,7 +160,7 @@ export function reconcileSessions( ? `@${s.invited_by_handle}` : i18n.t("notifications.inbox.someone") : invited - ? (s.participants?.find((p) => p.user_id === s.invited_by)?.display_name ?? + ? (s.participants?.find((p) => p.user_id === s.invited_by)?.handle ?? i18n.t("notifications.inbox.someone")) : ""; const kind: InboxKind = knock ? "sessionKnock" : invited ? "sessionInvite" : "sessionShared"; @@ -229,7 +226,7 @@ export function reconcileControlRequests(connections: Record p.user_id === requesterId)?.display_name ?? + c.participants.find((p) => p.user_id === requesterId)?.handle ?? i18n.t("notifications.inbox.someone"); return { id, diff --git a/src/services/teamSessionJoin.ts b/src/services/teamSessionJoin.ts index 2ec2b7030..eb1438311 100644 --- a/src/services/teamSessionJoin.ts +++ b/src/services/teamSessionJoin.ts @@ -4,7 +4,6 @@ import { useUIStore } from "@/stores/uiStore"; export interface JoinTeamSessionParams { sessionId: string; - displayName: string; connectionName: string; inviteToken?: string; } @@ -21,7 +20,6 @@ export async function joinTeamSessionAndOpenTab(params: JoinTeamSessionParams): .getState() .joinSession( params.sessionId, - params.displayName, () => {}, // onControlUpdate — handled by MultiplayerBar params.inviteToken, ); diff --git a/src/stores/teamSessionStore.test.ts b/src/stores/teamSessionStore.test.ts index 00bda0a52..a2cafbfec 100644 --- a/src/stores/teamSessionStore.test.ts +++ b/src/stores/teamSessionStore.test.ts @@ -15,7 +15,6 @@ const svc = vi.hoisted(() => ({ vi.mock("@/services/multiplayerService", () => mp); vi.mock("@/services/ssh", () => ({ sshSendInput: vi.fn(async () => {}) })); vi.mock("@/services/teamService", () => svc); -vi.mock("@/services/account", () => ({ getCurrentUserEmail: vi.fn(async () => "me@x") })); vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); import { useTeamSessionStore } from "./teamSessionStore.ts"; @@ -51,9 +50,9 @@ test("leaveSession closes the connection and removes it from state", () => { test("joinSession wires callbacks that drive the participant/control state machine", async () => { let cb: any; - mp.openWebSocket.mockImplementation((...args: any[]) => { cb = args[5]; return connStub(); }); + mp.openWebSocket.mockImplementation((...args: any[]) => { cb = args[4]; return connStub(); }); - const localId = await get().joinSession("m1", "Guest", () => {}); + const localId = await get().joinSession("m1", () => {}); expect(get().connections[localId]).toMatchObject({ role: "guest", multiplayerSessionId: "m1" }); cb.onParticipantList([{ user_id: "u1" }, { user_id: "u2" }]); diff --git a/src/stores/teamSessionStore.ts b/src/stores/teamSessionStore.ts index 5d3dbdbc7..d6c67713a 100644 --- a/src/stores/teamSessionStore.ts +++ b/src/stores/teamSessionStore.ts @@ -52,7 +52,6 @@ interface TeamSessionStore { joinSession: ( multiplayerSessionId: string, - displayName: string, onControlUpdate: (holderId: string, requesterId: string | null) => void, inviteToken?: string, ) => Promise; // returns localSessionId @@ -143,12 +142,11 @@ async function attachAsHost( const serverUrl = await import("@/services/teamService").then((m) => m.getServerUrlValue()); const jwt = await import("@/services/teamService").then((m) => m.getJwtToken()); if (!serverUrl || !jwt) throw new Error(i18n.t("common.error.notConnectedToServer")); - const displayName = await import("@/services/account").then((m) => m.getCurrentUserEmail()).then((e) => e ?? "Me"); const myUserId = await import("@/services/teamService").then((m) => m.getMyUserId()).then((id) => id ?? ""); const initialSnapshot = mp.drainSshOutputBuffer(localSessionId) ?? undefined; - const conn = mp.openWebSocket(serverUrl, sessionId, jwt, displayName, sessionKey, { + const conn = mp.openWebSocket(serverUrl, sessionId, jwt, sessionKey, { ...makeCallbacks(localSessionId, set, get), onOutput: () => {}, }, extra.inviteToken, initialSnapshot); @@ -200,7 +198,7 @@ export const useTeamSessionStore = create((set, get) => ({ get().fetchActiveSessions().catch(() => {}); }, - joinSession: async (multiplayerSessionId, displayName, onControlUpdate, inviteToken) => { + joinSession: async (multiplayerSessionId, onControlUpdate, inviteToken) => { const { sessionKey } = await mp.getMySessionKey(multiplayerSessionId, inviteToken); const serverUrl = await import("@/services/teamService").then((m) => m.getServerUrlValue()); @@ -210,7 +208,7 @@ export const useTeamSessionStore = create((set, get) => ({ const localSessionId = crypto.randomUUID(); const myUserId = await import("@/services/teamService").then((m) => m.getMyUserId()).then((id) => id ?? ""); - const conn = mp.openWebSocket(serverUrl, multiplayerSessionId, jwt, displayName, sessionKey, { + const conn = mp.openWebSocket(serverUrl, multiplayerSessionId, jwt, sessionKey, { onOutput: (data) => { const conn = get().connections[localSessionId]; conn?._termWrite?.(data); From 20b8c9ab17356d5aaf613f0771664c260816e0e0 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 18:07:53 +0000 Subject: [PATCH 02/11] fix(security): address Task 6 review findings - teamInbox.ts: the stale comment claimed participant display names still arrived over the WebSocket query string; that leak is gone, so rewrite it around invited_by_handle being the sole authoritative source for a knock's identity. - multiplayerService.ws.test.ts: the regression guard's not.toContain checks let a raw unencoded @ through and didn't say what they don't cover; switch to an exact URL match (catches any future parameter) and note that the store call sites are guarded separately by their own call-signature tests. - teamSessionStore.test.ts: stop reaching into openWebSocket's mock args by position (args[4]); find the callbacks object by its onParticipantList shape so a future signature reorder doesn't silently repoint the test. - MembersPage.InviteToSession.test.tsx: update the last old-shape Participant fixtures (display_name) to handle. - teamSessionStore.directInvite.test.ts: drop the now-dead getCurrentUserEmail mock; the store no longer imports it. - Remove the now-unreferenced hosts.teamSessions.meFallback key from all four locales (en/fr/ru/zh). --- .../members/MembersPage.InviteToSession.test.tsx | 4 ++-- src/i18n/locales/en/hosts.json | 3 +-- src/i18n/locales/fr/hosts.json | 3 +-- src/i18n/locales/ru/hosts.json | 3 +-- src/i18n/locales/zh/hosts.json | 3 +-- src/services/multiplayerService.ws.test.ts | 12 +++++++----- src/services/teamInbox.ts | 9 +++++---- src/stores/teamSessionStore.directInvite.test.ts | 1 - src/stores/teamSessionStore.test.ts | 7 ++++++- 9 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/components/members/MembersPage.InviteToSession.test.tsx b/src/components/members/MembersPage.InviteToSession.test.tsx index 82adda270..ab2ff2b77 100644 --- a/src/components/members/MembersPage.InviteToSession.test.tsx +++ b/src/components/members/MembersPage.InviteToSession.test.tsx @@ -285,7 +285,7 @@ test("a session that has already spent its guest cap is not offered", async () = // Pro host, cap 1, one guest already live -> no seat left for anyone. patchConnection("local-1", { myUserId: "me", - participants: [{ user_id: "me", display_name: "Me" }, { user_id: "guest-1", display_name: "Guest" }], + participants: [{ user_id: "me", handle: "Me" }, { user_id: "guest-1", handle: "Guest" }], }); await renderPage(); expect(screen.queryByTestId(`ctx-u1::${INVITE_PROD}`)).toBeNull(); @@ -301,7 +301,7 @@ test("a member who already holds a standing invite is not offered that session", }); test("a member already live in the session is not offered it, while others still are", async () => { - patchConnection("local-1", { myUserId: "me", participants: [{ user_id: "u1", display_name: "Ann" }] }); + patchConnection("local-1", { myUserId: "me", participants: [{ user_id: "u1", handle: "Ann" }] }); patchActiveSession("mp-1", { vault_ids: [] }); // Cap 1 spent by u1 being live; raise the cap via the session's vault-owner tier // so this test isolates the dedupe guard from the cap guard. diff --git a/src/i18n/locales/en/hosts.json b/src/i18n/locales/en/hosts.json index 0358caf26..7b9bd64e9 100644 --- a/src/i18n/locales/en/hosts.json +++ b/src/i18n/locales/en/hosts.json @@ -118,8 +118,7 @@ "resume": "Resume", "invalidCodeFormat": "Invalid code — expected format: sessionId:token", "failedToJoinSession": "Failed to join session", - "sharedTerminalFallback": "Shared Terminal", - "meFallback": "Me" + "sharedTerminalFallback": "Shared Terminal" }, "snippetPicker": { "title": "Execute Snippet", diff --git a/src/i18n/locales/fr/hosts.json b/src/i18n/locales/fr/hosts.json index c6ce8981b..f1fc6e2aa 100644 --- a/src/i18n/locales/fr/hosts.json +++ b/src/i18n/locales/fr/hosts.json @@ -118,8 +118,7 @@ "resume": "Reprendre", "invalidCodeFormat": "Code invalide — format attendu : sessionId:token", "failedToJoinSession": "Échec de la connexion à la session", - "sharedTerminalFallback": "Terminal partagé", - "meFallback": "Moi" + "sharedTerminalFallback": "Terminal partagé" }, "snippetPicker": { "title": "Exécuter un snippet", diff --git a/src/i18n/locales/ru/hosts.json b/src/i18n/locales/ru/hosts.json index 0649ef208..7e069d11e 100644 --- a/src/i18n/locales/ru/hosts.json +++ b/src/i18n/locales/ru/hosts.json @@ -145,8 +145,7 @@ "resume": "Возобновить", "invalidCodeFormat": "Неверный код — ожидаемый формат: sessionId:token", "failedToJoinSession": "Не удалось присоединиться к сессии", - "sharedTerminalFallback": "Общий терминал", - "meFallback": "Я" + "sharedTerminalFallback": "Общий терминал" }, "snippetPicker": { "title": "Выполнить сниппет", diff --git a/src/i18n/locales/zh/hosts.json b/src/i18n/locales/zh/hosts.json index 7e6527c14..fc25d16de 100644 --- a/src/i18n/locales/zh/hosts.json +++ b/src/i18n/locales/zh/hosts.json @@ -118,8 +118,7 @@ "resume": "继续", "invalidCodeFormat": "无效代码——格式应为:sessionId:token", "failedToJoinSession": "加入会话失败", - "sharedTerminalFallback": "共享终端", - "meFallback": "我" + "sharedTerminalFallback": "共享终端" }, "snippetPicker": { "title": "执行代码片段", diff --git a/src/services/multiplayerService.ws.test.ts b/src/services/multiplayerService.ws.test.ts index 0bcf353dc..e40d62d7f 100644 --- a/src/services/multiplayerService.ws.test.ts +++ b/src/services/multiplayerService.ws.test.ts @@ -46,12 +46,14 @@ beforeEach(() => { // This test exists to stop the email leak being reintroduced. The WebSocket // used to carry a client-supplied display_name that every call site filled // with the user's own email address, so a stranger admitted by knock learned -// everyone's real address. The name is resolved server-side now; if a -// display_name parameter ever reappears on this URL, that leak is back. -test("openWebSocket sends no display_name query parameter, and no email", async () => { +// everyone's real address. The name is resolved server-side now; this only +// proves openWebSocket itself sends no identity parameter on this URL — the +// call sites that used to feed it one (teamSessionStore's attachAsHost and +// joinSession) are covered separately by teamSessionStore.test.ts and +// teamInbox.test.ts asserting their call signatures, not by this file. +test("openWebSocket's URL carries only the token, no display_name or any other parameter", async () => { openWebSocket("https://s", "sid", "jwt", await key(), noopCallbacks()); - expect(MockWS.last.url).not.toContain("display_name"); - expect(MockWS.last.url).not.toContain("%40"); // no encoded @, i.e. no email anywhere in the URL + expect(MockWS.last.url).toBe("wss://s/v1/terminal-sessions/sid/ws?token=jwt"); }); test("openWebSocket rewrites https->wss and appends invite_token", async () => { diff --git a/src/services/teamInbox.ts b/src/services/teamInbox.ts index d052bf1cd..460ce1bcd 100644 --- a/src/services/teamInbox.ts +++ b/src/services/teamInbox.ts @@ -149,12 +149,13 @@ export function reconcileSessions( // identity alone and never from sessionDisplayName. const invited = !!s.invited_by && s.invited_by !== myUserId; const knock = invited && s.connection_name === null; - // A knock renders the server-resolved handle and nothing else. Participant - // display names arrive in the sender's own WebSocket query string, so + // A knock renders the server-resolved handle and nothing else. A + // participant's handle names that participant, not the inviter, so // falling back to one here would let a stranger knock as "Voltius Support" // — the exact impersonation the reserved-handle list exists to refuse. - // Absent (an older server, or a race before the inviter is resolvable) it - // degrades to "Someone", never to a name the sender chose. + // Only invited_by_handle is authoritative for who is knocking; absent + // (an older server, or a race before the inviter resolves) it degrades + // to "Someone", never to a name the sender chose. const inviter = knock ? s.invited_by_handle ? `@${s.invited_by_handle}` diff --git a/src/stores/teamSessionStore.directInvite.test.ts b/src/stores/teamSessionStore.directInvite.test.ts index 605419d0b..2f08487b7 100644 --- a/src/stores/teamSessionStore.directInvite.test.ts +++ b/src/stores/teamSessionStore.directInvite.test.ts @@ -16,7 +16,6 @@ const svc = vi.hoisted(() => ({ vi.mock("@/services/multiplayerService", () => mp); vi.mock("@/services/ssh", () => ({ sshSendInput: vi.fn(async () => {}) })); vi.mock("@/services/teamService", () => svc); -vi.mock("@/services/account", () => ({ getCurrentUserEmail: vi.fn(async () => "me@x") })); vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); import { useTeamSessionStore } from "./teamSessionStore.ts"; diff --git a/src/stores/teamSessionStore.test.ts b/src/stores/teamSessionStore.test.ts index a2cafbfec..198dc3681 100644 --- a/src/stores/teamSessionStore.test.ts +++ b/src/stores/teamSessionStore.test.ts @@ -50,7 +50,12 @@ test("leaveSession closes the connection and removes it from state", () => { test("joinSession wires callbacks that drive the participant/control state machine", async () => { let cb: any; - mp.openWebSocket.mockImplementation((...args: any[]) => { cb = args[4]; return connStub(); }); + // Found by shape, not position — a positional index breaks silently if + // openWebSocket's parameter order ever changes again. + mp.openWebSocket.mockImplementation((...args: any[]) => { + cb = args.find((a) => a && typeof a === "object" && "onParticipantList" in a); + return connStub(); + }); const localId = await get().joinSession("m1", () => {}); expect(get().connections[localId]).toMatchObject({ role: "guest", multiplayerSessionId: "m1" }); From c22e540b40ff1ca0039773ccd00171da88d133e9 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 18:19:43 +0000 Subject: [PATCH 03/11] test(security): close the two remaining gaps in the display_name regression guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior fix-up's comment claimed teamSessionStore.test.ts and teamInbox.test.ts already asserted attachAsHost/joinSession never forward an identity string into openWebSocket. They didn't: teamSessionStore.test.ts only extracted callbacks via mockImplementation, with no toHaveBeenCalledWith anywhere, and teamInbox.test.ts asserted one level removed — joinSession's own public signature, not what it forwards. Add real guards for both paths: assertOpenWebSocketArgsCarryNoIdentity finds the callbacks object by its onParticipantList shape (not by index) and checks every string-typed argument against the expected, non-identity set, so a re-introduced email/display_name anywhere in the call fails immediately. Covers startSharing's attachAsHost and joinSession. Also close the sent-frames gap the review flagged as neither fixed nor acknowledged: sendOutput/sendInput/requestControl/grantControl/revokeControl never serialize an identity string, verified directly. --- src/services/multiplayerService.ws.test.ts | 26 +++++++++--- src/stores/teamSessionStore.test.ts | 49 ++++++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/services/multiplayerService.ws.test.ts b/src/services/multiplayerService.ws.test.ts index e40d62d7f..04c3b42d6 100644 --- a/src/services/multiplayerService.ws.test.ts +++ b/src/services/multiplayerService.ws.test.ts @@ -46,11 +46,12 @@ beforeEach(() => { // This test exists to stop the email leak being reintroduced. The WebSocket // used to carry a client-supplied display_name that every call site filled // with the user's own email address, so a stranger admitted by knock learned -// everyone's real address. The name is resolved server-side now; this only -// proves openWebSocket itself sends no identity parameter on this URL — the -// call sites that used to feed it one (teamSessionStore's attachAsHost and -// joinSession) are covered separately by teamSessionStore.test.ts and -// teamInbox.test.ts asserting their call signatures, not by this file. +// everyone's real address. The name is resolved server-side now; this proves +// openWebSocket itself sends no identity parameter on the URL or in any sent +// frame (below). The two call sites that used to feed it an identity string — +// teamSessionStore's attachAsHost and joinSession — are covered by dedicated +// "no identity string reaches openWebSocket" tests in teamSessionStore.test.ts, +// which assert the full argument list, not just the call-site's own signature. test("openWebSocket's URL carries only the token, no display_name or any other parameter", async () => { openWebSocket("https://s", "sid", "jwt", await key(), noopCallbacks()); expect(MockWS.last.url).toBe("wss://s/v1/terminal-sessions/sid/ws?token=jwt"); @@ -62,6 +63,21 @@ test("openWebSocket rewrites https->wss and appends invite_token", async () => { expect(MockWS.last.url).toContain("invite_token=tok"); }); +test("sent frames never carry an identity string", async () => { + const conn = openWebSocket("https://s", "sid", "jwt", await key(), noopCallbacks()); + await conn.sendOutput(new Uint8Array([1])); + await conn.sendInput(new Uint8Array([2])); + conn.requestControl(); + conn.grantControl("u9"); + conn.revokeControl(); + + expect(MockWS.last.sent.length).toBeGreaterThan(0); + for (const frame of MockWS.last.sent) { + expect(frame).not.toContain("display_name"); + expect(frame).not.toContain("@"); // no raw or encoded email in any frame + } +}); + test("a participant is named by the handle the server resolved", async () => { const cb = noopCallbacks(); openWebSocket("https://s", "sid", "jwt", await key(), cb); diff --git a/src/stores/teamSessionStore.test.ts b/src/stores/teamSessionStore.test.ts index 198dc3681..3455d7a7d 100644 --- a/src/stores/teamSessionStore.test.ts +++ b/src/stores/teamSessionStore.test.ts @@ -24,6 +24,21 @@ const connStub = () => ({ }); const get = () => useTeamSessionStore.getState(); +/** + * Guards against the identity-string leak (display_name/email) reaching + * openWebSocket via any argument, at any position. Finds the callbacks + * object by its onParticipantList shape rather than a fixed index, then + * asserts every string-typed argument is exactly the expected non-identity + * set — an unexpected extra string (an email, a handle passed where it + * shouldn't be) fails the match immediately, regardless of position. + */ +function assertOpenWebSocketArgsCarryNoIdentity(args: unknown[], expectedStrings: string[]) { + const callbacks = args.find((a) => a && typeof a === "object" && "onParticipantList" in a); + expect(callbacks).toBeTruthy(); + const strings = args.filter((a): a is string => typeof a === "string"); + expect(strings).toEqual(expectedStrings); +} + beforeEach(() => { Object.values(mp).forEach((f) => f.mockClear()); useTeamSessionStore.setState({ activeSessions: [], connections: {} }); @@ -78,3 +93,37 @@ test("joinSession wires callbacks that drive the participant/control state machi cb.onSessionEnded(); // guest → marked ended, not removed expect(get().connections[localId].ended).toBe(true); }); + +// Regression guard: attachAsHost (the host-side path shared by startSharing, +// startSharingInviteLink and startSharingDirect) used to resolve +// getCurrentUserEmail() into a displayName and forward it into openWebSocket. +// That leak point is gone; this proves it stays gone by inspecting every +// argument openWebSocket actually receives, not just this call site's own +// (now email-free) signature. +test("startSharing's attachAsHost calls openWebSocket with no identity string among its arguments", async () => { + mp.openWebSocket.mockImplementation(() => connStub()); + const sessionKey = new Uint8Array([7]); + mp.createVaultSession.mockResolvedValueOnce({ sessionId: "m9", sessionKey, sessionKeyBytes: new Uint8Array(32) }); + + await get().startSharing("local-1", ["v1"], [], "conn-name", [], "teams"); + + expect(mp.openWebSocket).toHaveBeenCalledTimes(1); + const args = mp.openWebSocket.mock.calls[0]; + expect(args).toContain(sessionKey); + assertOpenWebSocketArgsCarryNoIdentity(args, ["https://s", "m9", "jwt"]); +}); + +// Same regression guard for the guest path: joinSession forwards whatever +// teamSessionJoin.ts passes it straight into openWebSocket. +test("joinSession calls openWebSocket with no identity string among its arguments", async () => { + mp.openWebSocket.mockImplementation(() => connStub()); + const sessionKey = new Uint8Array([3]); + mp.getMySessionKey.mockResolvedValueOnce({ sessionKey }); + + await get().joinSession("m1", () => {}); + + expect(mp.openWebSocket).toHaveBeenCalledTimes(1); + const args = mp.openWebSocket.mock.calls[0]; + expect(args).toContain(sessionKey); + assertOpenWebSocketArgsCarryNoIdentity(args, ["https://s", "m1", "jwt"]); +}); From 12829ebabb6ffea632dd09927421b72e79c79622 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 18:24:36 +0000 Subject: [PATCH 04/11] feat(ui): two-letter avatar initials for handles A single leading letter collapses to 20 possible values over the generated handle wordlist, so every merry-* user rendered the same glyph. MultiplayerBar had its own slice(0, 2) initials that disagreed; both go through handleInitials now. --- src/components/shared/AvatarStack.test.tsx | 29 ++++++++++++++++++++++ src/components/shared/AvatarStack.tsx | 13 ++++++++-- src/components/terminal/MultiplayerBar.tsx | 3 ++- 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 src/components/shared/AvatarStack.test.tsx diff --git a/src/components/shared/AvatarStack.test.tsx b/src/components/shared/AvatarStack.test.tsx new file mode 100644 index 000000000..43838c855 --- /dev/null +++ b/src/components/shared/AvatarStack.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, test } from "vitest"; +import { handleInitials } from "./AvatarStack"; + +describe("handleInitials", () => { + // Generated handles are adjective-noun-NNNN over 20 adjectives, so a single + // leading letter collides constantly: every merry-* user would show "M". + test("takes one letter from each of the first two words", () => { + expect(handleInitials("merry-quartz-2597")).toBe("MQ"); + expect(handleInitials("swift-otter-4821")).toBe("SO"); + }); + + test("degrades to one letter for a single-word custom handle", () => { + expect(handleInitials("kevin")).toBe("K"); + }); + + test("handles underscores, which custom handles allow", () => { + expect(handleInitials("ada_lovelace")).toBe("AL"); + }); + + test("returns a question mark for nothing usable", () => { + expect(handleInitials("")).toBe("?"); + }); + + test("gives a different answer than a naive two-character slice", () => { + // MultiplayerBar used to slice(0, 2), which yields "ME" for this handle — + // the first two letters of one word rather than one from each word. + expect(handleInitials("merry-quartz-2597")).not.toBe("ME"); + }); +}); diff --git a/src/components/shared/AvatarStack.tsx b/src/components/shared/AvatarStack.tsx index cada23091..9b3f1e908 100644 --- a/src/components/shared/AvatarStack.tsx +++ b/src/components/shared/AvatarStack.tsx @@ -11,6 +11,15 @@ export function avatarColor(name: string): string { return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length]; } +/** Initials for a handle. Generated handles are `adjective-noun-NNNN` drawn + * from only 20 adjectives, so a single leading letter collides constantly — + * every `merry-*` user would render an identical "M" across 8 colours. */ +export function handleInitials(handle: string): string { + const words = handle.split(/[-_]/).filter((w) => /^[a-z]/i.test(w)); + const initials = words.slice(0, 2).map((w) => w[0].toUpperCase()).join(""); + return initials || "?"; +} + interface MiniAvatarProps { name: string; size?: number; @@ -25,10 +34,10 @@ export function MiniAvatar({ name, size = 26 }: MiniAvatarProps) { height: size, background: avatarColor(name), color: "#fff", - fontSize: size * 0.38, + fontSize: size * 0.32, }} > - {name[0]?.toUpperCase() ?? "?"} + {handleInitials(name)} ); } diff --git a/src/components/terminal/MultiplayerBar.tsx b/src/components/terminal/MultiplayerBar.tsx index 8f11ee981..6c6650cf2 100644 --- a/src/components/terminal/MultiplayerBar.tsx +++ b/src/components/terminal/MultiplayerBar.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import { Icon } from "@iconify/react"; import { useTeamSessionStore } from "@/stores/teamSessionStore"; import { useSessionStore } from "@/stores/sessionStore"; +import { handleInitials } from "@/components/shared/AvatarStack"; interface MultiplayerBarProps { localSessionId: string; @@ -89,7 +90,7 @@ export function MultiplayerBar({ localSessionId }: MultiplayerBarProps) { }} title={p.handle} > - {p.handle.slice(0, 2).toUpperCase()} + {handleInitials(p.handle)} ))} {mpState.participants.length > 5 && ( From ddf90ca1a567e64808d2f2bb11e1be1959856f6a Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 18:29:11 +0000 Subject: [PATCH 05/11] feat(client)!: remove the display-name editor and its plumbing There is no endpoint left to call. The handle control is the account section's identity field. getCurrentDisplayName and fetchAndCacheDisplayName had no production callers and go with it. --- .../sections/AccountSection.handle.test.tsx | 1 - .../settings/sections/AccountSection.tsx | 58 +------------------ src/i18n/locales/en/common.json | 2 - src/i18n/locales/en/settings.json | 6 -- src/i18n/locales/fr/common.json | 2 - src/i18n/locales/fr/settings.json | 6 -- src/i18n/locales/ru/common.json | 2 - src/i18n/locales/ru/settings.json | 6 -- src/i18n/locales/zh/common.json | 2 - src/i18n/locales/zh/settings.json | 6 -- src/services/account.localSession.test.ts | 5 +- src/services/account.serverAuth.test.ts | 42 ++++---------- src/services/account.ts | 33 +---------- src/services/accountCacheKeys.ts | 2 + 14 files changed, 19 insertions(+), 154 deletions(-) diff --git a/src/components/settings/sections/AccountSection.handle.test.tsx b/src/components/settings/sections/AccountSection.handle.test.tsx index e344af87d..7fc2d4d16 100644 --- a/src/components/settings/sections/AccountSection.handle.test.tsx +++ b/src/components/settings/sections/AccountSection.handle.test.tsx @@ -43,7 +43,6 @@ vi.mock("@/services/account", async () => { getAccountMode: vi.fn(async () => "server"), getCurrentUserEmail: vi.fn(async () => "ada@example.com"), getMe: h.getMe, - updateDisplayName: vi.fn(async () => {}), setMasterPassword: vi.fn(async () => {}), logout: vi.fn(async () => {}), lockVaultSession: vi.fn(async () => {}), diff --git a/src/components/settings/sections/AccountSection.tsx b/src/components/settings/sections/AccountSection.tsx index bff99c96b..fecb834cc 100644 --- a/src/components/settings/sections/AccountSection.tsx +++ b/src/components/settings/sections/AccountSection.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, type FormEvent } from "react"; import { Icon } from "@iconify/react"; import { useTranslation } from "react-i18next"; -import { getAccountMode, getCurrentUserEmail, getMe, updateDisplayName, setMasterPassword, logout, lockVaultSession } from "@/services/account"; +import { getAccountMode, getCurrentUserEmail, getMe, setMasterPassword, logout, lockVaultSession } from "@/services/account"; import { resetVault } from "@/services/vault"; import { useSecurityStore } from "@/stores/securityStore"; import { ActionItem, FormButtons, SettingsInput } from "./shared"; @@ -17,8 +17,8 @@ import ChangeMasterPasswordModal from "./ChangeMasterPasswordModal"; type AccountStep = "idle" | "set-password" | "loading" | "confirm-wipe"; /** - * Shared idle → editing → submitting → error cycle behind both the display-name - * row and the handle-claim row below — same shape, different save/validate fns. + * Shared idle → editing → submitting → error cycle behind the handle-claim + * row below. */ function useEditableField( save: (value: string) => Promise, @@ -99,7 +99,6 @@ export default function AccountSection() { const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const [currentEmail, setCurrentEmail] = useState(null); - const [displayName, setDisplayName] = useState(null); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const [showEditEmail, setShowEditEmail] = useState(false); @@ -114,10 +113,6 @@ export default function AccountSection() { const sessionTimeoutMinutes = useSecurityStore((s) => s.sessionTimeoutMinutes); const setSessionTimeoutMinutes = useSecurityStore((s) => s.setSessionTimeoutMinutes); - const displayNameField = useEditableField( - (value) => updateDisplayName(value), - (value) => setDisplayName(value), - ); const handleField = useEditableField( async (value) => { try { @@ -164,7 +159,6 @@ export default function AccountSection() { getCurrentUserEmail().then(setCurrentEmail).catch(() => {}); getMe().then((me) => { if (!me) return; - if (me.display_name) setDisplayName(me.display_name); if (me.handle) setHandle(me.handle); setHandleIsCustom(!!me.handle_is_custom); setMeTier(me.tier); @@ -375,52 +369,6 @@ export default function AccountSection() { onClick={() => setShowEditEmail(true)} /> )} - {mode === "server" && ( - displayNameField.editing ? ( -
-

{t("settings.account.displayName.title")}

- displayNameField.setInput(e.target.value)} - className="rounded-lg px-3 py-1.5 text-sm outline-hidden" - style={{ background: "var(--t-bg-input)", border: "1px solid var(--t-border)", color: "var(--t-text-primary)" }} - /> - {displayNameField.error &&

{displayNameField.error}

} -
- - -
-
- ) : ( - displayNameField.start(displayName ?? "")} - /> - ) - )} {mode === "server" && ( { +test("getAccountMode / getCurrentUserEmail pass through keychain", async () => { h.store.mode = "server"; h.store.email = "a@b.co"; - h.store.display_name = "Ada"; expect(await getAccountMode()).toBe("server"); expect(await getCurrentUserEmail()).toBe("a@b.co"); - expect(await getCurrentDisplayName()).toBe("Ada"); }); test("isServerMode is true only for server mode", async () => { diff --git a/src/services/account.serverAuth.test.ts b/src/services/account.serverAuth.test.ts index 0d4577a8e..740407278 100644 --- a/src/services/account.serverAuth.test.ts +++ b/src/services/account.serverAuth.test.ts @@ -40,8 +40,7 @@ import { changeMasterPassword, changeEmail, refreshSession, - updateDisplayName, - fetchAndCacheDisplayName, + getMe, resendVerificationEmail, } from "./account"; @@ -285,39 +284,18 @@ test("refreshSession stores the new jwt and reloads subscription", async () => { expect(h.load).toHaveBeenCalled(); }); -// ─── updateDisplayName ─────────────────────────────────────────────────────── +// ─── getMe ─────────────────────────────────────────────────────────────────── -test("updateDisplayName requires a connected server session", async () => { - await expect(updateDisplayName("Ada")).rejects.toThrow("common.error.notConnectedToServer"); -}); - -test("updateDisplayName maps 422 to displayNameLength", async () => { - h.store.jwt = "JWT"; - h.store.server_url = S; - h.http["/auth/display-name"] = err(422); - await expect(updateDisplayName("")).rejects.toThrow("common.error.displayNameLength"); -}); - -test("updateDisplayName caches the new name on success", async () => { - h.store.jwt = "JWT"; - h.store.server_url = S; - h.http["/auth/display-name"] = ok(); - await updateDisplayName("Ada"); - expect(h.store.display_name).toBe("Ada"); -}); - -// ─── fetchAndCacheDisplayName ──────────────────────────────────────────────── - -test("fetchAndCacheDisplayName returns null when not connected", async () => { - expect(await fetchAndCacheDisplayName()).toBeNull(); -}); - -test("fetchAndCacheDisplayName caches and returns the fetched name", async () => { +test("getMe caches the handle and no display name", async () => { h.store.jwt = "JWT"; h.store.server_url = S; - h.http["/auth/me"] = ok({ display_name: "Ada" }); - expect(await fetchAndCacheDisplayName()).toBe("Ada"); - expect(h.store.display_name).toBe("Ada"); + // An old/misbehaving server sending the retired alias must still be ignored. + h.http["/auth/me"] = ok({ handle: "merry-quartz-2597", display_name: "Ada", tier: "free" }); + const me = await getMe(); + expect(me?.handle).toBe("merry-quartz-2597"); + expect(h.store.handle).toBe("merry-quartz-2597"); + // There is no display_name to cache: the field is gone from the client. + expect(h.store.display_name).toBeUndefined(); }); // ─── resendVerificationEmail ───────────────────────────────────────────────── diff --git a/src/services/account.ts b/src/services/account.ts index cf4faed93..79decb364 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -363,22 +363,16 @@ export async function getCurrentUserEmail(): Promise { return keychainGet("email"); } -export async function getCurrentDisplayName(): Promise { - return keychainGet("display_name"); -} - export interface MeResponse { - display_name?: string | null; handle?: string; handle_is_custom?: boolean; allow_stranger_invites?: boolean; tier?: string; } -/** Fetches /v1/auth/me and caches the display name and handle for offline use - * (e.g. getCurrentDisplayName). Returns the full payload so callers that need - * the live tier/preference fields — the settings identity UI — don't need a - * second round trip. */ +/** Fetches /v1/auth/me and caches the handle for offline use. Returns the + * full payload so callers that need the live tier/preference fields — the + * settings identity UI — don't need a second round trip. */ export async function getMe(): Promise { const [jwt, serverUrl] = await Promise.all([keychainGet("jwt"), keychainGet("server_url")]); if (!jwt || !serverUrl) return null; @@ -388,7 +382,6 @@ export async function getMe(): Promise { }); if (!res.ok) return null; const me: MeResponse = await res.json(); - if (me.display_name) await keychainSet("display_name", me.display_name); if (me.handle) await keychainSet("handle", me.handle); return me; } catch { @@ -396,26 +389,6 @@ export async function getMe(): Promise { } } -export async function fetchAndCacheDisplayName(): Promise { - const me = await getMe(); - return me?.display_name ?? null; -} - -export async function updateDisplayName(newName: string): Promise { - const [jwt, serverUrl] = await Promise.all([keychainGet("jwt"), keychainGet("server_url")]); - if (!jwt || !serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); - - const res = await fetchWithTimeout(`${serverUrl}/v1/auth/display-name`, { - method: "PUT", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` }, - body: JSON.stringify({ display_name: newName }), - }); - if (res.status === 422) throw new Error(i18n.t("common.error.displayNameLength")); - if (!res.ok) throw new Error(i18n.t("common.error.updateDisplayNameFailed", { status: res.status })); - - await keychainSet("display_name", newName); -} - export async function refreshSession(): Promise { const [refreshToken, serverUrl] = await Promise.all([ keychainGet("refresh_token"), diff --git a/src/services/accountCacheKeys.ts b/src/services/accountCacheKeys.ts index 59fca62fd..4dbeb9ecb 100644 --- a/src/services/accountCacheKeys.ts +++ b/src/services/accountCacheKeys.ts @@ -12,6 +12,8 @@ export const ACCOUNT_CACHE_KEYS = [ "account_id", "mode", "email", + // Nothing writes this any more — it is here to purge the key from devices + // that cached one before 0.26. Delete in 0.27 with the display_name alias. "display_name", "handle", "jwt", From f2bafebed823865320892f7567684ab6a478a1cb Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 18:38:47 +0000 Subject: [PATCH 06/11] refactor(client): drop display_name from RecentPerson No migration and no handler version: project() already runs on replaceAll, which is the path the E2EE sync blob and the import UI both take, so a stale field from a device that has not updated is discarded on arrival. PeopleTab.tsx and teamSharing.ts read RecentPerson.display_name; both now fall back to handle, since that is all a Recent row carries. --- src/components/terminal/PeopleTab.test.tsx | 6 +++--- src/components/terminal/PeopleTab.tsx | 3 +-- src/services/teamSharing.ts | 2 +- src/stores/recentPeopleStore.test.ts | 24 +++++++++++++++++++--- src/stores/recentPeopleStore.ts | 4 +--- 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/components/terminal/PeopleTab.test.tsx b/src/components/terminal/PeopleTab.test.tsx index b0c2e52f0..55daf1a16 100644 --- a/src/components/terminal/PeopleTab.test.tsx +++ b/src/components/terminal/PeopleTab.test.tsx @@ -60,10 +60,10 @@ test("a stranger row is marked and shows its handle", async () => { }); test("a recent row already in the session renders as having access and cannot be invited", async () => { - useRecentPeopleStore.setState({ recent: [{ user_id: "r1", handle: "kev", display_name: "Kevin", last_invited_at: "" }], recentUpdatedAt: "" }); + useRecentPeopleStore.setState({ recent: [{ user_id: "r1", handle: "kev", last_invited_at: "" }], recentUpdatedAt: "" }); const onInvite = vi.fn(); render(); - const row = await screen.findByRole("button", { name: /kevin/i }); + const row = await screen.findByRole("button", { name: /kev/i }); expect((row as HTMLButtonElement).disabled).toBe(true); await userEvent.click(row); expect(onInvite).not.toHaveBeenCalled(); @@ -142,7 +142,7 @@ test("marks a covered teammate as having access and does not call onInvite", asy test("a teammate who is also in Recent still renders as having access in their vault's session", async () => { h.allTeammates.mockResolvedValue(roster); useRecentPeopleStore.setState({ - recent: [{ user_id: "u-alice", handle: "alice-h", display_name: "Alice", last_invited_at: "" }], + recent: [{ user_id: "u-alice", handle: "alice-h", last_invited_at: "" }], recentUpdatedAt: "", }); const onInvite = vi.fn(); diff --git a/src/components/terminal/PeopleTab.tsx b/src/components/terminal/PeopleTab.tsx index 539eef21c..7d17d8d07 100644 --- a/src/components/terminal/PeopleTab.tsx +++ b/src/components/terminal/PeopleTab.tsx @@ -186,7 +186,6 @@ export function PeopleTab({ session, invitedThisSession, guestCap, tier, onUpgra useRecentPeopleStore.getState().remember({ user_id: target.user_id, handle: target.handle ?? "", - display_name: target.display_name, last_invited_at: new Date().toISOString(), }); } catch { @@ -223,7 +222,7 @@ export function PeopleTab({ session, invitedThisSession, guestCap, tier, onUpgra return { target: { user_id: p.user_id, - display_name: p.display_name, + display_name: p.handle, handle: p.handle, team_id: teammate?.teamIds[0], }, diff --git a/src/services/teamSharing.ts b/src/services/teamSharing.ts index 74ff5cf7f..490324d0e 100644 --- a/src/services/teamSharing.ts +++ b/src/services/teamSharing.ts @@ -176,7 +176,7 @@ export function groupPeople !q || fields.some((f) => (f ?? "").toLowerCase().includes(q)); - const recent = input.recent.filter((p) => matches(p.display_name, p.handle)); + const recent = input.recent.filter((p) => matches(p.handle)); const recentIds = new Set(recent.map((p) => p.user_id)); // Recent is the more specific group: a person already in Recent does not repeat // under Your teams, even if they are also a current teammate. diff --git a/src/stores/recentPeopleStore.test.ts b/src/stores/recentPeopleStore.test.ts index 78606b69f..97e2c930b 100644 --- a/src/stores/recentPeopleStore.test.ts +++ b/src/stores/recentPeopleStore.test.ts @@ -1,9 +1,9 @@ import { test, expect, beforeEach } from "vitest"; -import { useRecentPeopleStore, MAX_RECENT } from "./recentPeopleStore"; +import { useRecentPeopleStore, MAX_RECENT, type RecentPerson } from "./recentPeopleStore"; import { withRemoteApply } from "./remoteApplyGuard"; const person = (id: string, at = "2026-08-15T00:00:00.000Z") => ({ - user_id: id, handle: `h-${id}`, display_name: id, last_invited_at: at, + user_id: id, handle: `h-${id}`, last_invited_at: at, }); beforeEach(() => useRecentPeopleStore.setState({ recent: [], recentUpdatedAt: new Date(0).toISOString() })); @@ -44,7 +44,25 @@ test("replaceAll strips fields remember would have dropped", () => { useRecentPeopleStore.getState().replaceAll([{ ...person("a"), public_key: "leak" }] as never); const { recent } = useRecentPeopleStore.getState(); expect(JSON.stringify(recent)).not.toContain("leak"); - expect(Object.keys(recent[0]).sort()).toEqual(["display_name", "handle", "last_invited_at", "user_id"]); + expect(Object.keys(recent[0]).sort()).toEqual(["handle", "last_invited_at", "user_id"]); +}); + +// A device that has not updated keeps writing display_name into the E2EE sync +// blob. project() runs on replaceAll — the path that takes the blob and the +// import UI — so the stale field is dropped on arrival and no migration is +// needed. See E4 in the design. +test("replaceAll drops a display_name carried by an older device's blob", () => { + useRecentPeopleStore.getState().replaceAll([ + { user_id: "u1", handle: "merry-quartz-2597", last_invited_at: "2026-08-15T00:00:00.000Z", + display_name: "ada" } as unknown as RecentPerson, + ]); + const [row] = useRecentPeopleStore.getState().recent; + expect(row).toEqual({ + user_id: "u1", + handle: "merry-quartz-2597", + last_invited_at: "2026-08-15T00:00:00.000Z", + }); + expect("display_name" in row).toBe(false); }); test("replaceAll caps the list and rejects a non-array", () => { diff --git a/src/stores/recentPeopleStore.ts b/src/stores/recentPeopleStore.ts index d2fafc8b6..1636b5180 100644 --- a/src/stores/recentPeopleStore.ts +++ b/src/stores/recentPeopleStore.ts @@ -11,12 +11,11 @@ export const MAX_RECENT = 20; export interface RecentPerson { user_id: string; handle: string; - display_name: string; last_invited_at: string; } /** - * Keeps exactly the four fields a Recent row is allowed to hold. Every write + * Keeps exactly the three fields a Recent row is allowed to hold. Every write * path goes through this: `replaceAll` takes foreign data (the sync blob, the * import UI), so enforcing the no-`public_key` invariant only on `remember` * would leave it enforced on the path that never sees untrusted input. @@ -25,7 +24,6 @@ function project(person: RecentPerson): RecentPerson { return { user_id: person.user_id, handle: person.handle, - display_name: person.display_name, last_invited_at: person.last_invited_at, }; } From 7c5e1e2a7280c0a7880dee530a57f49796ba8623 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 19:11:05 +0000 Subject: [PATCH 07/11] fix(test): stop RecentPerson bleed and human-name fixtures in ShareMenu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a teammate row in this file's PeopleTab exercises the real, unmocked recentPeopleStore, which persists across tests within the file. The roster fixture had no handle field, so a successful invite cached an empty handle into Recent — and PeopleTab now renders that handle, not a display name, so later queries for "alice" found an empty row instead. Fixes: give the roster/carol fixtures real handle-shaped values instead of human names (the standard the removal plan sets — a name like "Alice" hides truncation/initials problems a real handle exposes), query for those handles, and reset the real store in beforeEach so a write in one test can't leak into the next. --- .../terminal/ShareMenu.invitePeople.test.tsx | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/components/terminal/ShareMenu.invitePeople.test.tsx b/src/components/terminal/ShareMenu.invitePeople.test.tsx index 046f74ec8..c9b091eda 100644 --- a/src/components/terminal/ShareMenu.invitePeople.test.tsx +++ b/src/components/terminal/ShareMenu.invitePeople.test.tsx @@ -22,7 +22,12 @@ vi.mock("react-i18next", () => ({ })); vi.mock("@iconify/react", () => ({ Icon: () => null })); -const roster = [{ user_id: "alice", team_id: "t1", display_name: "Alice", is_online: true, teamIds: ["t1"] }]; +// Handle-shaped, not a human name: PeopleTab renders the handle now, and a +// fixture named "Alice" would hide exactly the truncation/initials problems +// a real handle exposes. +const ALICE_HANDLE = "merry-quartz-2597"; +const CAROL_HANDLE = "swift-otter-4821"; +const roster = [{ user_id: "alice", team_id: "t1", display_name: ALICE_HANDLE, handle: ALICE_HANDLE, is_online: true, teamIds: ["t1"] }]; const h = vi.hoisted(() => ({ allTeammates: vi.fn(), uninviteFromSession: vi.fn() })); vi.mock("@/services/teamSharing", async () => { @@ -48,6 +53,7 @@ import { useTeamStore } from "@/stores/teamStore"; import { useTeamSessionStore } from "@/stores/teamSessionStore"; import { type MpState } from "./ShareMenu.testHarness"; import { ShareMenu } from "./ShareMenu"; +import { useRecentPeopleStore } from "@/stores/recentPeopleStore"; const teamState = useTeamStore.getState(); const mpState = useTeamSessionStore.getState() as unknown as MpState; @@ -66,6 +72,9 @@ beforeEach(() => { h.allTeammates.mockReset().mockResolvedValue(roster); h.uninviteFromSession.mockReset().mockResolvedValue(undefined); mpState.fetchActiveSessions.mockReset().mockResolvedValue(undefined); + // Real, unmocked store: a successful invite in one test genuinely calls + // remember() and would otherwise bleed a stale Recent row into the next. + useRecentPeopleStore.setState({ recent: [], recentUpdatedAt: new Date(0).toISOString() }); }); afterEach(() => cleanup()); @@ -119,13 +128,13 @@ function ratioLines() { test("starts a direct session when a teammate is tapped on an unshared terminal", async () => { renderShareMenu({ sharing: false }); - await userEvent.click(await screen.findByRole("button", { name: /alice/i })); + await userEvent.click(await screen.findByRole("button", { name: /merry/i })); expect(startSharingDirect).toHaveBeenCalledWith("local-1", "web-prod", [expect.objectContaining({ user_id: "alice" })]); }); test("adds a teammate to the live session when already sharing", async () => { renderShareMenu({ sharing: true }); - await userEvent.click(await screen.findByRole("button", { name: /alice/i })); + await userEvent.click(await screen.findByRole("button", { name: /merry/i })); expect(inviteToActiveSession).toHaveBeenCalledWith("local-1", expect.objectContaining({ user_id: "alice" })); }); @@ -135,7 +144,7 @@ test("a pending invitee renders as non-tappable Invited, not Has access", async mpState.activeSessions = [{ id: "mp-1", invitee_ids: ["alice"] }]; renderShareMenu({ sharing: true }); - const row = await screen.findByRole("button", { name: /alice/i }); + const row = await screen.findByRole("button", { name: /merry/i }); expect((row as HTMLButtonElement).disabled).toBe(true); expect(row.textContent).toContain("terminal.share.inviteSent"); expect(row.textContent).not.toContain("terminal.share.inviteHasAccess"); @@ -143,11 +152,11 @@ test("a pending invitee renders as non-tappable Invited, not Has access", async test("a participant already in the session still renders Has access", async () => { mpState.connections = hostConnection({ - participants: [{ user_id: "me", handle: "Me" }, { user_id: "alice", handle: "Alice" }], + participants: [{ user_id: "me", handle: "Me" }, { user_id: "alice", handle: ALICE_HANDLE }], }); render(shareMenuElement()); - const row = await screen.findByRole("button", { name: /alice/i }); + const row = await screen.findByRole("button", { name: /merry/i }); expect(row.textContent).toContain("terminal.share.inviteHasAccess"); }); @@ -164,7 +173,7 @@ test("withdrawing a pending invite calls the server and refreshes the seat count test("a row that is neither invited nor joined offers no withdraw control", async () => { renderShareMenu({ sharing: true }); - await screen.findByRole("button", { name: /alice/i }); + await screen.findByRole("button", { name: /merry/i }); expect(screen.queryByRole("button", { name: "terminal.share.withdrawInvite" })).toBeNull(); }); @@ -179,7 +188,7 @@ test("the active view shows exactly one seats-vs-cap line, with and without a re // With the invite roster: the roster's own line already counts standing invites, // so a second line above it would contradict it (e.g. "0 / 1" over "1 / 1"). renderShareMenu({ sharing: true }); - await screen.findByRole("button", { name: /alice/i }); + await screen.findByRole("button", { name: /merry/i }); expect(ratioLines().length).toBe(1); cleanup(); @@ -196,7 +205,7 @@ test("setup view: a Pro host (cap 1) cannot tap a second teammate after the firs // Needs two teammates so there's a "remaining" row left to prove is now blocked. h.allTeammates.mockResolvedValue([ ...roster, - { user_id: "carol", team_id: "t1", display_name: "Carol", is_online: true, teamIds: ["t1"] }, + { user_id: "carol", team_id: "t1", display_name: CAROL_HANDLE, handle: CAROL_HANDLE, is_online: true, teamIds: ["t1"] }, ]); // The real startSharingDirect creates the session and writes `connections`, which // flips ShareMenu from the setup branch to ActiveSharingView — a *different* @@ -208,14 +217,14 @@ test("setup view: a Pro host (cap 1) cannot tap a second teammate after the firs return "mp-1"; }); const { rerender } = renderShareMenu({ sharing: false }); - const alice = await screen.findByRole("button", { name: /alice/i }); + const alice = await screen.findByRole("button", { name: /merry/i }); await userEvent.click(alice); // Stands in for zustand notifying subscribers of the `connections` write. rerender(shareMenuElement()); expect(startSharingDirect).toHaveBeenCalledTimes(1); await screen.findByText("terminal.share.inviteSent"); - const carol = (await screen.findByRole("button", { name: /carol/i })) as HTMLButtonElement; + const carol = (await screen.findByRole("button", { name: /swift/i })) as HTMLButtonElement; expect(carol.disabled).toBe(true); expect(screen.getByText("terminal.share.inviteCapReached")).toBeTruthy(); }); @@ -225,7 +234,7 @@ test("active view: a Pro host (cap 1) already at cap shows the remaining rows as participants: [{ user_id: "me", handle: "Me" }, { user_id: "guest-1", handle: "Guest" }], }); render(shareMenuElement()); - const alice = (await screen.findByRole("button", { name: /alice/i })) as HTMLButtonElement; + const alice = (await screen.findByRole("button", { name: /merry/i })) as HTMLButtonElement; expect(alice.disabled).toBe(true); expect(screen.getByText("terminal.share.inviteCapReached")).toBeTruthy(); }); From c2c54e2e33028ffbc734e80df178841f46282f6d Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 19:58:51 +0000 Subject: [PATCH 08/11] refactor(client): render handles instead of display names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Members, vaults, the vault header, the people tab and the notification inbox all read handle. invited_by_display_name keeps its key — the server still sends it; only the value is a handle now. --- src/components/hosts/HostCard.tsx | 8 +-- src/components/layout/VaultHeader.tsx | 8 +-- .../members/MembersPage.BulkActions.test.tsx | 12 ++-- .../members/MembersPage.InvitePanel.test.tsx | 23 +++--- .../MembersPage.InviteToSession.test.tsx | 16 ++--- .../MembersPage.MemberDetailPanel.test.tsx | 2 +- .../MembersPage.PendingInviteCard.test.tsx | 6 +- .../MembersPage.PrivateVaultAdd.test.tsx | 16 ++--- src/components/members/MembersPage.tsx | 72 +++++++++---------- .../settings/BuySeatsModal.test.tsx | 4 +- src/components/settings/BuySeatsModal.tsx | 4 +- .../sections/RolesSection.RoleCard.test.tsx | 2 +- .../settings/sections/TeamRolesPanel.test.tsx | 2 +- ...sSection.PrivateVaultMembersPanel.test.tsx | 10 +-- .../VaultsSection.TeamVaultPanel.test.tsx | 12 ++-- .../settings/sections/VaultsSection.tsx | 24 +++---- .../shared/UserSearchField.test.tsx | 12 ++-- src/components/shared/UserSearchField.tsx | 4 +- src/components/terminal/PeopleTab.test.tsx | 58 +++++++-------- src/components/terminal/PeopleTab.tsx | 17 ++--- .../terminal/ShareMenu.invitePeople.test.tsx | 4 +- src/hooks/useConnectionPresence.test.tsx | 26 +++---- src/hooks/useConnectionPresence.ts | 14 ++-- src/hooks/useUserSearch.test.tsx | 4 +- src/hooks/useWritableVaultIds.test.tsx | 2 +- src/plugins/domains/sharing.test.ts | 8 +-- src/plugins/domains/team.test.ts | 24 +++---- src/plugins/domains/team.ts | 4 +- src/plugins/toolSurface/tools/sharing.test.ts | 2 +- src/plugins/toolSurface/tools/team.test.ts | 4 +- .../multiplayerService.directInvite.test.ts | 9 ++- src/services/permissions.test.ts | 2 +- src/services/permissions.ts | 2 +- src/services/teamInbox.test.ts | 4 +- src/services/teamService.ts | 13 ++-- src/services/teamSharing.allTeammates.test.ts | 14 ++-- src/services/teamSharing.grouping.test.ts | 12 ++-- src/services/teamSharing.ts | 12 ++-- .../teamSessionStore.directInvite.test.ts | 2 +- src/stores/teamStore.test.ts | 2 +- 40 files changed, 230 insertions(+), 246 deletions(-) diff --git a/src/components/hosts/HostCard.tsx b/src/components/hosts/HostCard.tsx index 736c62a94..45f3de15e 100644 --- a/src/components/hosts/HostCard.tsx +++ b/src/components/hosts/HostCard.tsx @@ -79,7 +79,7 @@ export default function HostCard({ if (!isTeamVault || pinSource === "none" || pinSource === "personal") return undefined; const updatedBy = (connection as { updated_by?: string }).updated_by; const member = updatedBy ? teamMembers.find((m) => m.user_id === updatedBy) : undefined; - return member?.display_name ?? t("hosts.card.teamMemberFallback"); + return member?.handle ?? t("hosts.card.teamMemberFallback"); })(); const handlePinClick = () => { if (!isTeamVault) { @@ -119,12 +119,12 @@ export default function HostCard({ const presence = useConnectionPresence(connection); const presenceTitle = presence ? presence.overflow > 0 - ? t("hosts.card.inUseByOverflow", { name: presence.primary.displayName, count: presence.overflow }) - : t("hosts.card.inUseBy", { name: presence.primary.displayName }) + ? t("hosts.card.inUseByOverflow", { name: presence.primary.handle, count: presence.overflow }) + : t("hosts.card.inUseBy", { name: presence.primary.handle }) : ""; const presenceAvatar = presence && ( - + {presence.overflow > 0 && ( +{presence.overflow} diff --git a/src/components/layout/VaultHeader.tsx b/src/components/layout/VaultHeader.tsx index 61ef5e623..ebe83d281 100644 --- a/src/components/layout/VaultHeader.tsx +++ b/src/components/layout/VaultHeader.tsx @@ -47,7 +47,7 @@ function OnlineMembersStack({ members, roles, onInviteClick }: { members: TeamMe {visible.map((m, i) => (
- +
))} {overflow > 0 && ( @@ -98,11 +98,11 @@ function OnlineMembersStack({ members, roles, onInviteClick }: { members: TeamMe return (
- + {m.is_online && }
- {m.display_name} + {m.handle} {memberRoles.length > 0 && (
{memberRoles.map((r) => { diff --git a/src/components/members/MembersPage.BulkActions.test.tsx b/src/components/members/MembersPage.BulkActions.test.tsx index 41ba9daf1..b981880f1 100644 --- a/src/components/members/MembersPage.BulkActions.test.tsx +++ b/src/components/members/MembersPage.BulkActions.test.tsx @@ -4,7 +4,7 @@ import type { ContextMenuItem } from "@/components/shared/ContextMenu"; const h = vi.hoisted(() => ({ getMyUserId: vi.fn(), - getMyEmail: vi.fn(), + getMe: vi.fn(), loadTeams: vi.fn(), loadMembers: vi.fn(), loadRoles: vi.fn(), @@ -20,9 +20,9 @@ const h = vi.hoisted(() => ({ { id: "r-ed", team_id: "t1", name: "editor", is_builtin: false, permissions: 0, position: 2, created_at: "" }, ], members: [ - { team_id: "t1", user_id: "me", invited_by_display_name: null, joined_at: "2024-01-01T00:00:00Z", display_name: "Me", public_key: "pk", role_ids: ["r-mem"] }, - { team_id: "t1", user_id: "u1", invited_by_display_name: null, joined_at: "2024-01-02T00:00:00Z", display_name: "Ann", public_key: "pk", role_ids: ["r-mem", "r-ed"] }, - { team_id: "t1", user_id: "u2", invited_by_display_name: null, joined_at: "2024-01-03T00:00:00Z", display_name: "Bob", public_key: "pk", role_ids: ["r-mem"] }, + { team_id: "t1", user_id: "me", invited_by_display_name: null, joined_at: "2024-01-01T00:00:00Z", handle: "merry-quartz-2597", public_key: "pk", role_ids: ["r-mem"] }, + { team_id: "t1", user_id: "u1", invited_by_display_name: null, joined_at: "2024-01-02T00:00:00Z", handle: "amber-lynx-4410", public_key: "pk", role_ids: ["r-mem", "r-ed"] }, + { team_id: "t1", user_id: "u2", invited_by_display_name: null, joined_at: "2024-01-03T00:00:00Z", handle: "brisk-otter-8823", public_key: "pk", role_ids: ["r-mem"] }, ], })); @@ -97,10 +97,10 @@ vi.mock("@/hooks/usePermission", () => ({ vi.mock("@/services/teamService", () => ({ searchUsers: vi.fn(), getMyUserId: h.getMyUserId, - getMyEmail: h.getMyEmail, inviteByEmail: vi.fn(), revokePendingInvitation: vi.fn(), })); +vi.mock("@/services/account", () => ({ getMe: h.getMe })); vi.mock("@/services/teamActionFeedback", () => ({ runTeamAction: async (o: { run: () => Promise }) => o.run(), })); @@ -186,7 +186,7 @@ import MembersPage from "./MembersPage"; beforeEach(() => { Object.values(h).forEach((v) => { if (typeof v === "function" && "mockReset" in v) (v as ReturnType).mockReset(); }); h.getMyUserId.mockResolvedValue("me"); - h.getMyEmail.mockResolvedValue("me@x.com"); + h.getMe.mockResolvedValue({ handle: "merry-quartz-2597" }); h.loadTeams.mockResolvedValue(undefined); h.loadMembers.mockResolvedValue(undefined); h.loadRoles.mockResolvedValue(undefined); diff --git a/src/components/members/MembersPage.InvitePanel.test.tsx b/src/components/members/MembersPage.InvitePanel.test.tsx index 7f3cb8290..41dd4ff58 100644 --- a/src/components/members/MembersPage.InvitePanel.test.tsx +++ b/src/components/members/MembersPage.InvitePanel.test.tsx @@ -8,6 +8,7 @@ const h = vi.hoisted(() => ({ add: vi.fn(), assign: vi.fn(), reload: vi.fn(), + getMe: vi.fn(async () => ({ handle: "merry-quartz-2597" })), usedSeats: 2, totalSeats: 3, })); @@ -26,10 +27,10 @@ vi.mock("@/components/shared/Panel", () => ({ vi.mock("@/services/teamService", () => ({ searchUsers: h.searchUsers, getMyUserId: vi.fn(), - getMyEmail: vi.fn(), inviteByEmail: h.inviteByEmail, revokePendingInvitation: vi.fn(), })); +vi.mock("@/services/account", () => ({ getMe: h.getMe })); vi.mock("@/services/teamActionFeedback", () => ({ runTeamAction: async (o: { run: () => Promise }) => o.run(), })); @@ -78,7 +79,7 @@ const baseProps = { onMemberAdded: vi.fn(), }; -const inA = { user_id: "inA", display_name: "Included A", public_key: "pkA" }; +const inA = { user_id: "inA", handle: "included-alpha-3140", public_key: "pkA" }; beforeEach(() => { h.searchUsers.mockReset(); @@ -133,14 +134,14 @@ test("existingIds filter: excluded id absent from rendered results, included id vi.useFakeTimers(); h.searchUsers.mockResolvedValue([ inA, - { user_id: "inB", display_name: "Excluded B", public_key: "pkB" }, + { user_id: "inB", handle: "excluded-bravo-9022", public_key: "pkB" }, ]); render(); await typeAndDebounce("in"); - expect(screen.getByText("Included A")).toBeTruthy(); - expect(screen.queryByText("Excluded B")).toBeNull(); + expect(screen.getByText("included-alpha-3140")).toBeTruthy(); + expect(screen.queryByText("excluded-bravo-9022")).toBeNull(); }); test("add success (not at limit): addMemberById + assignMemberRole(default role) + reload + onMemberAdded", async () => { @@ -153,7 +154,7 @@ test("add success (not at limit): addMemberById + assignMemberRole(default role) await typeAndDebounce("in"); vi.useRealTimers(); h.reload.mockClear(); - fireEvent.click(screen.getByText("Included A")); + fireEvent.click(screen.getByText("included-alpha-3140")); await waitFor(() => expect(baseProps.onMemberAdded).toHaveBeenCalled()); expect(h.add).toHaveBeenCalledWith("t1", "inA"); @@ -170,7 +171,7 @@ test("add at seat limit: addMemberById NOT called, BuySeatsModal shown with that await typeAndDebounce("in"); vi.useRealTimers(); - fireEvent.click(screen.getByText("Included A")); + fireEvent.click(screen.getByText("included-alpha-3140")); expect(h.add).not.toHaveBeenCalled(); const modal = await screen.findByTestId("buy-seats-modal"); @@ -185,7 +186,7 @@ test("add rejects {code:402} (not at limit): BuySeatsModal shown, no error text" await typeAndDebounce("in"); vi.useRealTimers(); - fireEvent.click(screen.getByText("Included A")); + fireEvent.click(screen.getByText("included-alpha-3140")); const modal = await screen.findByTestId("buy-seats-modal"); expect(modal.dataset.pendingUser).toBe("inA"); @@ -200,7 +201,7 @@ test("add rejects Error with '402' in message (no code prop): BuySeatsModal show await typeAndDebounce("in"); vi.useRealTimers(); - fireEvent.click(screen.getByText("Included A")); + fireEvent.click(screen.getByText("included-alpha-3140")); const modal = await screen.findByTestId("buy-seats-modal"); expect(modal.dataset.pendingUser).toBe("inA"); @@ -215,7 +216,7 @@ test("add rejects generic error (no 402): error text shown, BuySeatsModal NOT re await typeAndDebounce("in"); vi.useRealTimers(); - fireEvent.click(screen.getByText("Included A")); + fireEvent.click(screen.getByText("included-alpha-3140")); expect(await screen.findByText("nope")).toBeTruthy(); expect(screen.queryByTestId("buy-seats-modal")).toBeNull(); @@ -290,7 +291,7 @@ test("BuySeatsModal onSuccess: reloadSubscription + onMemberAdded called, modal await typeAndDebounce("in"); vi.useRealTimers(); - fireEvent.click(screen.getByText("Included A")); + fireEvent.click(screen.getByText("included-alpha-3140")); await screen.findByTestId("buy-seats-modal"); h.reload.mockClear(); diff --git a/src/components/members/MembersPage.InviteToSession.test.tsx b/src/components/members/MembersPage.InviteToSession.test.tsx index ab2ff2b77..3a32f5982 100644 --- a/src/components/members/MembersPage.InviteToSession.test.tsx +++ b/src/components/members/MembersPage.InviteToSession.test.tsx @@ -4,7 +4,7 @@ import type { ContextMenuItem } from "@/components/shared/ContextMenu"; const h = vi.hoisted(() => ({ getMyUserId: vi.fn(), - getMyEmail: vi.fn(), + getMe: vi.fn(), loadTeams: vi.fn(), loadMembers: vi.fn(), loadRoles: vi.fn(), @@ -18,9 +18,9 @@ const h = vi.hoisted(() => ({ { id: "r-mem", team_id: "t1", name: "member", is_builtin: true, permissions: 0, position: 1, created_at: "" }, ], members: [ - { team_id: "t1", user_id: "me", invited_by_display_name: null, joined_at: "2024-01-01T00:00:00Z", display_name: "Me", public_key: "pk", role_ids: ["r-mem"] }, - { team_id: "t1", user_id: "u1", invited_by_display_name: null, joined_at: "2024-01-02T00:00:00Z", display_name: "Ann", public_key: "pk", role_ids: ["r-mem"] }, - { team_id: "t1", user_id: "u2", invited_by_display_name: null, joined_at: "2024-01-03T00:00:00Z", display_name: "Bob", public_key: "pk", role_ids: ["r-mem"] }, + { team_id: "t1", user_id: "me", invited_by_display_name: null, joined_at: "2024-01-01T00:00:00Z", handle: "merry-quartz-2597", public_key: "pk", role_ids: ["r-mem"] }, + { team_id: "t1", user_id: "u1", invited_by_display_name: null, joined_at: "2024-01-02T00:00:00Z", handle: "amber-lynx-4410", public_key: "pk", role_ids: ["r-mem"] }, + { team_id: "t1", user_id: "u2", invited_by_display_name: null, joined_at: "2024-01-03T00:00:00Z", handle: "brisk-otter-8823", public_key: "pk", role_ids: ["r-mem"] }, ], // one hosted session with the key retained (invitable), one hosted with no key, // one guest session, and one active-on-the-server session hosted elsewhere. @@ -102,10 +102,10 @@ vi.mock("@/hooks/usePermission", () => ({ vi.mock("@/services/teamService", () => ({ searchUsers: vi.fn(), getMyUserId: h.getMyUserId, - getMyEmail: h.getMyEmail, inviteByEmail: vi.fn(), revokePendingInvitation: vi.fn(), })); +vi.mock("@/services/account", () => ({ getMe: h.getMe })); vi.mock("@/services/teamVaultActivation", () => ({ markTeamVaultLoadedAfterLocalActivation: vi.fn() })); vi.mock("@/services/billingCheckout", () => ({ openBillingCheckout: vi.fn() })); vi.mock("@/services/teamVaultSync", () => ({ initTeamVaultKey: vi.fn() })); @@ -222,7 +222,7 @@ beforeEach(() => { resetFixtures(); Object.values(h).forEach((v) => { if (typeof v === "function" && "mockReset" in v) (v as ReturnType).mockReset(); }); h.getMyUserId.mockResolvedValue("me"); - h.getMyEmail.mockResolvedValue("me@x.com"); + h.getMe.mockResolvedValue({ handle: "merry-quartz-2597" }); h.loadTeams.mockResolvedValue(undefined); h.loadMembers.mockResolvedValue(undefined); h.loadRoles.mockResolvedValue(undefined); @@ -285,7 +285,7 @@ test("a session that has already spent its guest cap is not offered", async () = // Pro host, cap 1, one guest already live -> no seat left for anyone. patchConnection("local-1", { myUserId: "me", - participants: [{ user_id: "me", handle: "Me" }, { user_id: "guest-1", handle: "Guest" }], + participants: [{ user_id: "me", handle: "merry-quartz-2597" }, { user_id: "guest-1", handle: "guest-fox-1207" }], }); await renderPage(); expect(screen.queryByTestId(`ctx-u1::${INVITE_PROD}`)).toBeNull(); @@ -301,7 +301,7 @@ test("a member who already holds a standing invite is not offered that session", }); test("a member already live in the session is not offered it, while others still are", async () => { - patchConnection("local-1", { myUserId: "me", participants: [{ user_id: "u1", handle: "Ann" }] }); + patchConnection("local-1", { myUserId: "me", participants: [{ user_id: "u1", handle: "amber-lynx-4410" }] }); patchActiveSession("mp-1", { vault_ids: [] }); // Cap 1 spent by u1 being live; raise the cap via the session's vault-owner tier // so this test isolates the dedupe guard from the cap guard. diff --git a/src/components/members/MembersPage.MemberDetailPanel.test.tsx b/src/components/members/MembersPage.MemberDetailPanel.test.tsx index 8b69981b9..b2958efd1 100644 --- a/src/components/members/MembersPage.MemberDetailPanel.test.tsx +++ b/src/components/members/MembersPage.MemberDetailPanel.test.tsx @@ -55,7 +55,7 @@ const baseMember: TeamMember = { user_id: "u1", invited_by_display_name: null, joined_at: "2024-01-01T00:00:00Z", - display_name: "Ann", + handle: "amber-lynx-4410", public_key: "pk", role_ids: ["r-mem"], }; diff --git a/src/components/members/MembersPage.PendingInviteCard.test.tsx b/src/components/members/MembersPage.PendingInviteCard.test.tsx index 6436f9a71..308d606d6 100644 --- a/src/components/members/MembersPage.PendingInviteCard.test.tsx +++ b/src/components/members/MembersPage.PendingInviteCard.test.tsx @@ -21,7 +21,7 @@ import { PendingInviteCard } from "./MembersPage"; const inv = { id: "inv1", - display_name: "Jane Doe", + display_name: "jade-heron-7715", role: "member", invited_by_display_name: null, created_at: "2024-01-01", @@ -41,9 +41,9 @@ beforeEach(() => { }); afterEach(() => cleanup()); -test("renders display_name and role", () => { +test("renders the invitee handle and role", () => { render(); - expect(screen.getByText("Jane Doe")).toBeTruthy(); + expect(screen.getByText("jade-heron-7715")).toBeTruthy(); expect(screen.getByText("member")).toBeTruthy(); }); diff --git a/src/components/members/MembersPage.PrivateVaultAdd.test.tsx b/src/components/members/MembersPage.PrivateVaultAdd.test.tsx index 19f587bd0..d8255c768 100644 --- a/src/components/members/MembersPage.PrivateVaultAdd.test.tsx +++ b/src/components/members/MembersPage.PrivateVaultAdd.test.tsx @@ -3,7 +3,7 @@ import { render, screen, cleanup, fireEvent, waitFor, act } from "@testing-libra const h = vi.hoisted(() => ({ getMyUserId: vi.fn(), - getMyEmail: vi.fn(), + getMe: vi.fn(), searchUsers: vi.fn(), loadTeams: vi.fn(), loadMembers: vi.fn(), @@ -57,10 +57,10 @@ vi.mock("@/hooks/usePermission", () => ({ vi.mock("@/services/teamService", () => ({ searchUsers: h.searchUsers, getMyUserId: h.getMyUserId, - getMyEmail: h.getMyEmail, inviteByEmail: vi.fn(), revokePendingInvitation: vi.fn(), })); +vi.mock("@/services/account", () => ({ getMe: h.getMe })); vi.mock("@/services/teamActionFeedback", () => ({ runTeamAction: async (o: { run: () => Promise }) => o.run(), })); @@ -152,12 +152,12 @@ vi.mock("@/stores/historyStore", () => ({ import MembersPage from "./MembersPage"; -const foundUser = { user_id: "u1", display_name: "Zoe", public_key: "pk1" }; +const foundUser = { user_id: "u1", handle: "zesty-otter-1180", public_key: "pk1" }; beforeEach(() => { Object.values(h).forEach((v) => { if (typeof v === "function" && "mockReset" in v) (v as ReturnType).mockReset(); }); h.getMyUserId.mockResolvedValue("me"); - h.getMyEmail.mockResolvedValue("me@x.com"); + h.getMe.mockResolvedValue({ handle: "merry-quartz-2597" }); h.loadTeams.mockResolvedValue(undefined); h.createTeam.mockResolvedValue({ id: "newteam", name: "V" }); h.addMemberById.mockResolvedValue(undefined); @@ -177,7 +177,7 @@ afterEach(() => { /** Renders the page (private-vault branch) and opens the invite panel. */ async function renderAndOpenInvite() { render(); - // flush getMyUserId/getMyEmail/loadTeams so canPrivateInvite becomes true + // flush getMyUserId/getMe/loadTeams so canPrivateInvite becomes true await act(async () => { await Promise.resolve(); await Promise.resolve(); }); fireEvent.click(screen.getByRole("button", { name: /members.toolbar.inviteBtn/ })); } @@ -212,7 +212,7 @@ test("handlePrivateAdd: ordered createTeam -> setVaultTeamId -> initTeamVaultKey await act(async () => { await vi.advanceTimersByTimeAsync(250); }); vi.useRealTimers(); - fireEvent.click(screen.getByText("Zoe")); + fireEvent.click(screen.getByText("zesty-otter-1180")); await waitFor(() => expect(h.assignMemberRole).toHaveBeenCalled()); @@ -242,7 +242,7 @@ test("handlePrivateAdd: role not found in reloaded roles -> assignMemberRole NOT await act(async () => { await vi.advanceTimersByTimeAsync(250); }); vi.useRealTimers(); - fireEvent.click(screen.getByText("Zoe")); + fireEvent.click(screen.getByText("zesty-otter-1180")); await waitFor(() => expect(h.addMemberById).toHaveBeenCalledWith("newteam", "u1")); await waitFor(() => expect(h.loadRoles).toHaveBeenCalled()); @@ -258,7 +258,7 @@ test("handlePrivateAdd: createTeam rejects -> error shown, addMemberById never c await act(async () => { await vi.advanceTimersByTimeAsync(250); }); vi.useRealTimers(); - fireEvent.click(screen.getByText("Zoe")); + fireEvent.click(screen.getByText("zesty-otter-1180")); expect(await screen.findByText("boom")).toBeTruthy(); expect(h.addMemberById).not.toHaveBeenCalled(); diff --git a/src/components/members/MembersPage.tsx b/src/components/members/MembersPage.tsx index bdd56808c..ff529dfa8 100644 --- a/src/components/members/MembersPage.tsx +++ b/src/components/members/MembersPage.tsx @@ -13,11 +13,11 @@ import { MiniAvatar, avatarColor } from "@/components/shared/AvatarStack"; import { UserSearchField } from "@/components/shared/UserSearchField"; import { getMyUserId, - getMyEmail, inviteByEmail, revokePendingInvitation, } from "@/services/teamService"; import type { PendingInvitation } from "@/stores/teamStore"; +import { getMe } from "@/services/account"; import { BaseCard } from "@/components/shared/BaseCard"; import type { ContextMenuItem } from "@/components/shared/ContextMenu"; import { SidePanelLayout } from "@/components/shared/SidePanelLayout"; @@ -276,7 +276,7 @@ interface MemberCardProps { function MemberAvatar({ member, size }: { member: TeamMember; size: number }) { return (
- + {member.is_online && ( )} @@ -313,7 +313,7 @@ function MemberCard({
-

{member.display_name}

+

{member.handle}

{isMe && ( {t("members.youBadge")} )} @@ -338,7 +338,7 @@ function MemberCard({
-

{member.display_name}

+

{member.handle}

{isOwner && } {isMe && ( {t("members.youBadge")} @@ -394,12 +394,12 @@ export function MemberDetailPanel({ try { if (hasRole) { await runTeamAction({ - pending: t("members.toast.removingRoleFrom", { role: role.name, name: member.display_name }), - success: t("members.toast.roleRemovedFrom", { role: role.name, name: member.display_name }), + pending: t("members.toast.removingRoleFrom", { role: role.name, name: member.handle }), + success: t("members.toast.roleRemovedFrom", { role: role.name, name: member.handle }), run: () => removeMemberRole(teamId, member.user_id, role.id), }); push({ - label: t("members.history.removeRole", { name: member.display_name }), + label: t("members.history.removeRole", { name: member.handle }), undo: async () => { await useTeamStore.getState().assignMemberRole(teamId, member.user_id, role.id); onUpdated(); @@ -411,12 +411,12 @@ export function MemberDetailPanel({ }); } else { await runTeamAction({ - pending: t("members.toast.assigningRoleTo", { role: role.name, name: member.display_name }), - success: t("members.toast.roleAssignedTo", { role: role.name, name: member.display_name }), + pending: t("members.toast.assigningRoleTo", { role: role.name, name: member.handle }), + success: t("members.toast.roleAssignedTo", { role: role.name, name: member.handle }), run: () => assignMemberRole(teamId, member.user_id, role.id), }); push({ - label: t("members.history.assignRole", { name: member.display_name }), + label: t("members.history.assignRole", { name: member.handle }), undo: async () => { await useTeamStore.getState().removeMemberRole(teamId, member.user_id, role.id); onUpdated(); @@ -443,12 +443,12 @@ export function MemberDetailPanel({ setRemoving(true); setError(""); try { await runTeamAction({ - pending: t("members.toast.removingMember", { name: member.display_name }), - success: t("members.toast.memberRemoved", { name: member.display_name }), + pending: t("members.toast.removingMember", { name: member.handle }), + success: t("members.toast.memberRemoved", { name: member.handle }), run: () => removeMember(teamId, member.user_id), }); push({ - label: t("members.history.remove", { name: member.display_name }), + label: t("members.history.remove", { name: member.handle }), undo: async () => { await useTeamStore.getState().addMemberById(teamId, snapshot.user_id); for (const rid of snapshot.role_ids) { @@ -486,7 +486,7 @@ export function MemberDetailPanel({ } onClose={onClose} /> @@ -727,10 +727,10 @@ export function InvitePanel({ teamId, existingIds, teamRoles, onClose, onMemberA setAdding(user.user_id); setError(""); setSuccess(""); try { const result = await runTeamAction({ - pending: t("members.toast.invitingUser", { name: user.display_name }), + pending: t("members.toast.invitingUser", { name: user.handle }), success: (r) => r.status === "pending" - ? t("members.toast.invitationSentToUser", { name: user.display_name }) - : t("members.toast.userAdded", { name: user.display_name }), + ? t("members.toast.invitationSentToUser", { name: user.handle }) + : t("members.toast.userAdded", { name: user.handle }), run: () => addMemberById(teamId, user.user_id), }); if (result.status === "pending") { @@ -740,8 +740,8 @@ export function InvitePanel({ teamId, existingIds, teamRoles, onClose, onMemberA } reset(); setSuccess(result.status === "pending" - ? t("members.toast.invitationSentToUser", { name: user.display_name }) - : t("members.toast.userAdded", { name: user.display_name })); + ? t("members.toast.invitationSentToUser", { name: user.handle }) + : t("members.toast.userAdded", { name: user.handle })); await reloadSubscription(); onMemberAdded(); } catch (e) { @@ -1026,7 +1026,7 @@ export default function MembersPage() { const openCloudAuth = useUIStore((s) => s.openCloudAuth); const [myUserId, setMyUserId] = useState(""); - const [myEmail, setMyEmail] = useState(null); + const [myHandle, setMyHandle] = useState(null); const [primaryVaultId, setPrimaryVaultId] = useState(null); const [search, setSearch] = useState(""); const [roleFilter, setRoleFilter] = useState([]); @@ -1050,7 +1050,7 @@ export default function MembersPage() { useEffect(() => { getMyUserId().then((id) => { if (id) setMyUserId(id); }).catch(() => {}); - getMyEmail().then((email) => { setMyEmail(email ?? ""); }).catch(() => { setMyEmail(""); }); + getMe().then((me) => { setMyHandle(me?.handle ?? ""); }).catch(() => { setMyHandle(""); }); loadTeams().catch(() => {}); }, [loadTeams]); @@ -1130,7 +1130,7 @@ export default function MembersPage() { const searchLower = search.trim().toLowerCase(); const filteredMembers = useMemo(() => { let result = members; - if (searchLower) result = result.filter((m) => m.display_name.toLowerCase().includes(searchLower)); + if (searchLower) result = result.filter((m) => m.handle.toLowerCase().includes(searchLower)); if (roleFilter.length > 0) result = result.filter((m) => roleFilter.some((rid) => m.role_ids.includes(rid))); return result; }, [members, searchLower, roleFilter]); @@ -1138,15 +1138,15 @@ export default function MembersPage() { const sortedMembers = useMemo(() => { return [...filteredMembers].sort((a, b) => { switch (sortMode) { - case "name-asc": return a.display_name.localeCompare(b.display_name); - case "name-desc": return b.display_name.localeCompare(a.display_name); + case "name-asc": return a.handle.localeCompare(b.handle); + case "name-desc": return b.handle.localeCompare(a.handle); case "newest": return b.joined_at.localeCompare(a.joined_at); case "oldest": return a.joined_at.localeCompare(b.joined_at); case "role-asc": { const posA = Math.min(...(a.role_ids.map((rid) => teamRoles.find((r) => r.id === rid)?.position ?? 9999))); const posB = Math.min(...(b.role_ids.map((rid) => teamRoles.find((r) => r.id === rid)?.position ?? 9999))); if (posA !== posB) return posA - posB; - return a.display_name.localeCompare(b.display_name); + return a.handle.localeCompare(b.handle); } default: return 0; } @@ -1208,7 +1208,7 @@ export default function MembersPage() { onClick: () => { void removeMemberRole(teamId!, member.user_id, r.id).then(() => { push({ - label: t("members.history.removeRole", { name: member.display_name }), + label: t("members.history.removeRole", { name: member.handle }), undo: async () => { await assignMemberRole(teamId!, member.user_id, r.id); reload(); }, redo: async () => { await removeMemberRole(teamId!, member.user_id, r.id); reload(); }, }); @@ -1224,7 +1224,7 @@ export default function MembersPage() { onClick: () => { void assignMemberRole(teamId!, member.user_id, r.id).then(() => { push({ - label: t("members.history.assignRole", { name: member.display_name }), + label: t("members.history.assignRole", { name: member.handle }), undo: async () => { await removeMemberRole(teamId!, member.user_id, r.id); reload(); }, redo: async () => { await assignMemberRole(teamId!, member.user_id, r.id); reload(); }, }); @@ -1257,8 +1257,8 @@ export default function MembersPage() { label: connectionName, onClick: () => { void runTeamAction({ - pending: t("members.toast.invitingToSession", { name: member.display_name }), - success: t("members.toast.invitedToSession", { name: member.display_name }), + pending: t("members.toast.invitingToSession", { name: member.handle }), + success: t("members.toast.invitedToSession", { name: member.handle }), run: () => useTeamSessionStore.getState().inviteToActiveSession(localSessionId, member), }).catch(() => { /* toast already reports the failure */ }); }, @@ -1280,7 +1280,7 @@ export default function MembersPage() { const snapshot = { ...member }; void removeMember(teamId!, member.user_id).then(() => { push({ - label: t("members.history.remove", { name: member.display_name }), + label: t("members.history.remove", { name: member.handle }), undo: async () => { await addMemberById(teamId!, snapshot.user_id); for (const rid of snapshot.role_ids) { @@ -1518,12 +1518,12 @@ const vaultTabs = selectedVaultIds.length > 1 > {layoutMode === "grid" ? ( - +
- {myEmail === null + {myHandle === null ?
- :

{myEmail || t("members.you")}

+ :

{myHandle || t("members.you")}

} {t("members.youBadge")}
@@ -1532,12 +1532,12 @@ const vaultTabs = selectedVaultIds.length > 1 ) : ( - +
- {myEmail === null + {myHandle === null ?
- :

{myEmail || t("members.you")}

+ :

{myHandle || t("members.you")}

} {t("members.youBadge")} diff --git a/src/components/settings/BuySeatsModal.test.tsx b/src/components/settings/BuySeatsModal.test.tsx index 5ad6c8f5e..e655ba1cf 100644 --- a/src/components/settings/BuySeatsModal.test.tsx +++ b/src/components/settings/BuySeatsModal.test.tsx @@ -26,7 +26,7 @@ import BuySeatsModal from "./BuySeatsModal"; const props = { teamId: "t1", - pendingUser: null as { user_id: string; display_name: string } | null, + pendingUser: null as { user_id: string; handle: string } | null, pendingRole: "member", onClose: vi.fn(), onSuccess: vi.fn(), @@ -70,7 +70,7 @@ test("success without pendingUser: POST /billing/seats, load, onSuccess, NO addM test("success WITH pendingUser: also calls addMemberById(teamId, user, role)", async () => { connected(); h.appFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); - render(); + render(); fireEvent.click(screen.getByText("settings.account.buySeats.buyAndInvite")); await waitFor(() => expect(props.onSuccess).toHaveBeenCalled()); expect(h.addMemberById).toHaveBeenCalledWith("t1", "u9", "editor"); diff --git a/src/components/settings/BuySeatsModal.tsx b/src/components/settings/BuySeatsModal.tsx index 725dd9f2d..450bf1f30 100644 --- a/src/components/settings/BuySeatsModal.tsx +++ b/src/components/settings/BuySeatsModal.tsx @@ -11,7 +11,7 @@ const SEAT_PRICE_MONTHLY = 15; interface Props { teamId: string; - pendingUser: { user_id: string; display_name: string } | null; + pendingUser: { user_id: string; handle: string } | null; pendingRole: string; onClose: () => void; onSuccess: () => void; @@ -79,7 +79,7 @@ export default function BuySeatsModal({ teamId, pendingUser, pendingRole, onClos

{t("settings.account.buySeats.seatsUsed", { used: usedSeats, total: totalSeats })} - {pendingUser && <> · {t("settings.account.buySeats.inviting")} {pendingUser.display_name}} + {pendingUser && <> · {t("settings.account.buySeats.inviting")} {pendingUser.handle}}