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/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/layout/SidebarAccountButton.tsx b/src/components/layout/SidebarAccountButton.tsx index dd9fedfc5..8ab759a66 100644 --- a/src/components/layout/SidebarAccountButton.tsx +++ b/src/components/layout/SidebarAccountButton.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { useUIStore } from "@/stores/uiStore"; import { useThemeStore } from "@/stores/themeStore"; import { useRipple } from "@/hooks/useRipple"; -import { getAccountMode, getMe, lockVaultSession, logout } from "@/services/account"; +import { getAccountMode, getMyHandle, lockVaultSession, logout } from "@/services/account"; import { getSavedAccounts, saveCurrentAccount, switchToAccount, removeSavedAccount, type SavedAccount } from "@/services/savedAccounts"; import { DropdownMenuItem } from "@/components/shared/DropdownMenuItem"; import { useCopyHandle } from "@/hooks/useCopyHandle"; @@ -28,21 +28,15 @@ export function SidebarAccountButton() { const refreshAccountInfo = async () => { const { invoke: inv } = await import("@tauri-apps/api/core"); - const [mode, email, accountId, handle] = await Promise.all([ + const [mode, email, accountId] = await Promise.all([ getAccountMode().catch(() => null), inv("keychain_get", { key: "email" }).catch(() => null), inv("keychain_get", { key: "account_id" }).catch(() => null), - // Cached by getMe(); an account that signed in before handles existed has - // none yet, so fall back to the server rather than hiding the row forever. - inv("keychain_get", { key: "handle" }).catch(() => null), ]); setAccountMode(mode); setAccountEmail(email); setCurrentAccountId(accountId); - setAccountHandle(handle); - if (!handle && mode === "server") { - getMe().then((me) => setAccountHandle(me?.handle ?? null)).catch(() => {}); - } + void getMyHandle().then((handle) => setAccountHandle(handle || null)).catch(() => {}); }; useEffect(() => { refreshAccountInfo(); }, []); 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..9efb266c8 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(), + getMyHandle: 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", () => ({ getMyHandle: h.getMyHandle })); 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.getMyHandle.mockResolvedValue("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..2e92adfe1 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(), + getMyHandle: vi.fn(async () => "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", () => ({ getMyHandle: h.getMyHandle })); 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 82adda270..14a961ad3 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(), + getMyHandle: 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", () => ({ getMyHandle: h.getMyHandle })); 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.getMyHandle.mockResolvedValue("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", display_name: "Me" }, { user_id: "guest-1", display_name: "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", display_name: "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..b41444b9d 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(), + getMyHandle: 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", () => ({ getMyHandle: h.getMyHandle })); 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.getMyHandle.mockResolvedValue("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.SelfCard.test.tsx b/src/components/members/MembersPage.SelfCard.test.tsx new file mode 100644 index 000000000..f3eae0b43 --- /dev/null +++ b/src/components/members/MembersPage.SelfCard.test.tsx @@ -0,0 +1,151 @@ +import { test, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; + +const h = vi.hoisted(() => ({ + getMyUserId: vi.fn(), + getMyHandle: vi.fn(), + loadTeams: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); +vi.mock("@iconify/react", () => ({ Icon: () => null })); +vi.mock("@/components/shared/StatusDot", () => ({ StatusDot: () => null })); +vi.mock("@/components/shared/Panel", () => ({ + PanelShell: ({ children }: { children: React.ReactNode }) =>
{children}
, + PanelHeader: ({ children }: { children?: React.ReactNode }) =>
{children}
, + PanelHeaderIconButton: () => null, + FormSection: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock("@/components/shared/SidePanelLayout", () => ({ + SidePanelLayout: ({ panel, children }: { panel: React.ReactNode; children: React.ReactNode }) => ( +
{panel}{children}
+ ), +})); +vi.mock("@/components/shared/DragSelectSurface", () => ({ + DragSelectSurface: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock("@/components/shared/ToolbarViewControls", () => ({ ToolbarViewControls: () => null })); +// Unlike the other MembersPage test files, BaseCard is left rendering its +// children — the self-card assertions below live inside it. +vi.mock("@/components/settings/BuySeatsModal", () => ({ default: () => null })); +vi.mock("@/components/settings/sections/RolesSection", () => ({ + RoleModal: () => null, + PERM_META: {}, + TeamRolesPanel: () => null, +})); +vi.mock("@/hooks/useListKeyNav", () => ({ useListKeyNav: () => ({ focusedId: null, setFocusedId: () => {} }) })); +vi.mock("@/hooks/usePermission", () => ({ + PERM_BITS: { MANAGE_MEMBERS: 1, MANAGE_ROLES: 2, INVITE_MEMBERS: 4 }, + effectivePermissions: () => 0, + hasBuiltinRole: () => false, +})); +vi.mock("@/services/teamService", () => ({ + searchUsers: vi.fn(), + getMyUserId: h.getMyUserId, + inviteByEmail: vi.fn(), + revokePendingInvitation: vi.fn(), +})); +vi.mock("@/services/account", () => ({ getMyHandle: h.getMyHandle })); +vi.mock("@/services/teamActionFeedback", () => ({ + runTeamAction: async (o: { run: () => Promise }) => o.run(), +})); +vi.mock("@/services/teamVaultActivation", () => ({ markTeamVaultLoadedAfterLocalActivation: vi.fn() })); +vi.mock("@/services/billingCheckout", () => ({ openBillingCheckout: vi.fn() })); +vi.mock("@/services/teamVaultSync", () => ({ initTeamVaultKey: vi.fn() })); +vi.mock("@/stores/teamVaultStateStore", () => ({ + useTeamVaultStateStore: { getState: () => ({ tag: "vault-state" }) }, +})); + +// A private (non-team) vault selected — the branch the self-card renders in. +vi.mock("@/stores/vaultStore", () => { + const state = { + selectedVaultIds: ["v1"], + vaults: [{ id: "v1", name: "V", teamId: null }], + setVaultTeamId: vi.fn(), + }; + const useVaultStore = Object.assign( + (sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state), + { getState: () => state }, + ); + return { useVaultStore }; +}); +vi.mock("@/stores/teamStore", () => { + const state = { + teams: [], loadTeams: h.loadTeams, membersByTeam: {}, loadMembers: vi.fn(), + rolesByTeam: {}, loadRoles: vi.fn(), pendingInvitationsByTeam: {}, loadPendingInvitations: vi.fn(), + createTeam: vi.fn(), addMemberById: vi.fn(), assignMemberRole: vi.fn(), + removeMemberRole: vi.fn(), removeMember: vi.fn(), + }; + const useTeamStore = Object.assign( + (sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state), + { getState: () => state }, + ); + return { useTeamStore }; +}); +vi.mock("@/stores/subscriptionStore", () => { + // "server" + isTeams (cloud account, Teams tier, private vault, no team + // yet) is the branch the self-card renders in: a local-only account sees + // the sign-in CTA instead, and a non-Teams cloud account sees the upgrade CTA. + const state = { isTeams: true, accountMode: "server", usedSeats: 1, totalSeats: 1, load: vi.fn() }; + const useSubscriptionStore = Object.assign( + (sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state), + { getState: () => state }, + ); + return { useSubscriptionStore }; +}); +vi.mock("@/stores/uiStore", () => { + const state = { + membersLayoutMode: "list", membersSortMode: "name-asc", + setMembersLayoutMode: vi.fn(), setMembersSortMode: vi.fn(), + membersInvitePending: false, clearMembersInvitePending: vi.fn(), + openSettings: vi.fn(), openCloudAuth: vi.fn(), + }; + const useUIStore = Object.assign( + (sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state), + { getState: () => state }, + ); + return { useUIStore }; +}); +vi.mock("@/stores/teamSessionStore", () => { + const state = { activeSessions: [], connections: {}, startSharing: vi.fn(), inviteToActiveSession: vi.fn() }; + const useTeamSessionStore = Object.assign( + (sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state), + { getState: () => state }, + ); + return { useTeamSessionStore }; +}); +vi.mock("@/stores/historyStore", () => ({ + useHistoryStore: (sel: (s: { push: () => void }) => unknown) => sel({ push: vi.fn() }), +})); + +import MembersPage from "./MembersPage"; + +beforeEach(() => { + h.getMyUserId.mockReset().mockResolvedValue("me"); + h.getMyHandle.mockReset(); + h.loadTeams.mockReset().mockResolvedValue(undefined); +}); +afterEach(() => cleanup()); + +// getMyHandle() resolves to "" rather than rejecting on a keychain miss with +// no server to fall back to (a local-only account). Before this fix the +// self-card kept `myHandle === null` — its loading-skeleton state — forever +// in that case, because the effect never called anything that settled it. +test("self-card falls back to the you label, not a permanent skeleton, when getMyHandle resolves empty", async () => { + h.getMyHandle.mockResolvedValue(""); + render(); + + expect(await screen.findByText("members.you")).toBeTruthy(); + // The skeleton is a plain div with an animate-pulse class and no accessible text. + expect(document.querySelector(".animate-pulse")).toBeNull(); +}); + +test("self-card shows the resolved handle once getMyHandle settles", async () => { + h.getMyHandle.mockResolvedValue("merry-quartz-2597"); + render(); + + expect(await screen.findByText("merry-quartz-2597")).toBeTruthy(); +}); diff --git a/src/components/members/MembersPage.tsx b/src/components/members/MembersPage.tsx index bdd56808c..4bacd077b 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 { getMyHandle } 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,9 @@ export default function MembersPage() { useEffect(() => { getMyUserId().then((id) => { if (id) setMyUserId(id); }).catch(() => {}); - getMyEmail().then((email) => { setMyEmail(email ?? ""); }).catch(() => { setMyEmail(""); }); + // getMyHandle() resolves to "" (never rejects) on a keychain miss with no + // server to fall back to, so this always settles the loading skeleton. + getMyHandle().then(setMyHandle).catch(() => setMyHandle("")); loadTeams().catch(() => {}); }, [loadTeams]); @@ -1130,7 +1132,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 +1140,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 +1210,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 +1226,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,9 +1259,9 @@ 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 }), - run: () => useTeamSessionStore.getState().inviteToActiveSession(localSessionId, member), + pending: t("members.toast.invitingToSession", { name: member.handle }), + success: t("members.toast.invitedToSession", { name: member.handle }), + run: () => useTeamSessionStore.getState().inviteToActiveSession(localSessionId, { ...member, handle: member.handle ?? "" }), }).catch(() => { /* toast already reports the failure */ }); }, })) @@ -1280,7 +1282,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 +1520,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 +1534,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/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/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}}

- -
-
- ) : ( - displayNameField.start(displayName ?? "")} - /> - ) - )} {mode === "server" && ( ({ id, team_id: "t1", name: id, permissions, is_builtin, position, created_at: "" }); const member = (user_id: string, role_ids: string[]): TeamMember => - ({ team_id: "t1", user_id, display_name: "", public_key: "", invited_by_display_name: null, joined_at: "", role_ids }); + ({ team_id: "t1", user_id, handle: "", public_key: "", invited_by_display_name: null, joined_at: "", role_ids }); const managerRole = role("mgr", PERM_BITS.MANAGE_ROLES, 0, true); diff --git a/src/components/settings/sections/TeamRolesPanel.test.tsx b/src/components/settings/sections/TeamRolesPanel.test.tsx index 84b13ae80..f6e83765e 100644 --- a/src/components/settings/sections/TeamRolesPanel.test.tsx +++ b/src/components/settings/sections/TeamRolesPanel.test.tsx @@ -17,7 +17,7 @@ import { useSubscriptionStore } from "@/stores/subscriptionStore"; const role = (id: string, permissions: number, is_builtin = false): TeamRole => ({ id, team_id: "t1", name: id, permissions, is_builtin, position: 0 } as TeamRole); const member = (user_id: string, role_ids: string[]): TeamMember => - ({ team_id: "t1", user_id, display_name: "", public_key: "", invited_by_display_name: null, joined_at: "", role_ids }); + ({ team_id: "t1", user_id, handle: "", public_key: "", invited_by_display_name: null, joined_at: "", role_ids }); beforeEach(() => { localStorage.clear(); diff --git a/src/components/settings/sections/VaultsSection.PrivateVaultMembersPanel.test.tsx b/src/components/settings/sections/VaultsSection.PrivateVaultMembersPanel.test.tsx index f27f54b8e..08c948ed1 100644 --- a/src/components/settings/sections/VaultsSection.PrivateVaultMembersPanel.test.tsx +++ b/src/components/settings/sections/VaultsSection.PrivateVaultMembersPanel.test.tsx @@ -2,7 +2,7 @@ import { test, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, cleanup, fireEvent, waitFor, act } from "@testing-library/react"; const h = vi.hoisted(() => ({ - searchUsers: vi.fn(async () => [] as { user_id: string; display_name: string; public_key: string }[]), + searchUsers: vi.fn(async () => [] as { user_id: string; handle: string; public_key: string }[]), openBillingCheckout: vi.fn(async () => {}), })); @@ -108,7 +108,7 @@ test("server + teams + user id: renders invite UI (owner row + invite search)", test("search debounce: no search under 2 chars, one call at 250ms rendering results", async () => { vi.useFakeTimers(); - h.searchUsers.mockResolvedValue([{ user_id: "u1", display_name: "Alice", public_key: "pk" }]); + h.searchUsers.mockResolvedValue([{ user_id: "u1", handle: "amber-lynx-4410", public_key: "pk" }]); render(); const input = screen.getByPlaceholderText("settings.vaults.members.searchByEmailPlaceholder"); @@ -120,7 +120,7 @@ test("search debounce: no search under 2 chars, one call at 250ms rendering resu await act(async () => { await vi.advanceTimersByTimeAsync(250); }); expect(h.searchUsers).toHaveBeenCalledTimes(1); expect(h.searchUsers).toHaveBeenCalledWith("al"); - expect(screen.getByText("Alice")).toBeTruthy(); + expect(screen.getByText("amber-lynx-4410")).toBeTruthy(); }); test("handleAdd error: createTeam rejects → error surfaced, setVaultTeamId not reached", async () => { @@ -130,14 +130,14 @@ test("handleAdd error: createTeam rejects → error surfaced, setVaultTeamId not useVaultStore.setState({ setVaultTeamId }); vi.useFakeTimers(); - h.searchUsers.mockResolvedValue([{ user_id: "u1", display_name: "Alice", public_key: "pk" }]); + h.searchUsers.mockResolvedValue([{ user_id: "u1", handle: "amber-lynx-4410", public_key: "pk" }]); render(); const input = screen.getByPlaceholderText("settings.vaults.members.searchByEmailPlaceholder"); fireEvent.change(input, { target: { value: "al" } }); await act(async () => { await vi.advanceTimersByTimeAsync(250); }); vi.useRealTimers(); - fireEvent.click(screen.getByText("Alice")); + fireEvent.click(screen.getByText("amber-lynx-4410")); expect(await screen.findByText("boom")).toBeTruthy(); expect(createTeam).toHaveBeenCalledWith("My Vault"); diff --git a/src/components/settings/sections/VaultsSection.TeamVaultPanel.test.tsx b/src/components/settings/sections/VaultsSection.TeamVaultPanel.test.tsx index 051798314..0b814bbf0 100644 --- a/src/components/settings/sections/VaultsSection.TeamVaultPanel.test.tsx +++ b/src/components/settings/sections/VaultsSection.TeamVaultPanel.test.tsx @@ -42,7 +42,7 @@ const role = (id: string, name: string, permissions: number, extra: Partial - ({ team_id: "t1", user_id: userId, invited_by_display_name: null, joined_at: "", display_name: userId, public_key: "pk", role_ids: roleIds }); + ({ team_id: "t1", user_id: userId, invited_by_display_name: null, joined_at: "", handle: userId, public_key: "pk", role_ids: roleIds }); const invite = (id: string, name: string): PendingInvitation => ({ id, display_name: name, role: "member", invited_by_display_name: null, created_at: "", expires_at: "" }); @@ -73,11 +73,11 @@ afterEach(() => cleanup()); test("canManage true: listPendingInvitations loaded on mount and pending invites rendered", async () => { setup(MANAGE_MEMBERS); - h.listPendingInvitations.mockResolvedValue([invite("inv1", "Pending Pat")]); + h.listPendingInvitations.mockResolvedValue([invite("inv1", "pending-pat-5150")]); render(); await waitFor(() => expect(h.listPendingInvitations).toHaveBeenCalledWith("t1")); - expect(await screen.findByText("Pending Pat")).toBeTruthy(); + expect(await screen.findByText("pending-pat-5150")).toBeTruthy(); }); test("canManage false: listPendingInvitations NOT called", async () => { @@ -102,13 +102,13 @@ test("canInvite false: InviteBar hidden (no invite header)", () => { test("handleRevoke: revokePendingInvitation called and invite removed optimistically", async () => { setup(MANAGE_MEMBERS); - h.listPendingInvitations.mockResolvedValue([invite("inv1", "Pending Pat")]); + h.listPendingInvitations.mockResolvedValue([invite("inv1", "pending-pat-5150")]); render(); - const pat = await screen.findByText("Pending Pat"); + const pat = await screen.findByText("pending-pat-5150"); fireEvent.click(screen.getByTitle("settings.vaults.members.revokeTitle")); await waitFor(() => expect(h.revokePendingInvitation).toHaveBeenCalledWith("t1", "inv1")); - await waitFor(() => expect(screen.queryByText("Pending Pat")).toBeNull()); + await waitFor(() => expect(screen.queryByText("pending-pat-5150")).toBeNull()); void pat; }); diff --git a/src/components/settings/sections/VaultsSection.tsx b/src/components/settings/sections/VaultsSection.tsx index 39fc7544b..d63b63d80 100644 --- a/src/components/settings/sections/VaultsSection.tsx +++ b/src/components/settings/sections/VaultsSection.tsx @@ -208,8 +208,8 @@ function InviteBar({ teamId, existingIds, roles, canInvite, onMemberAdded }: { setError(""); setSuccess(""); try { await runTeamAction({ - pending: t("settings.vaults.members.adding", { name: user.display_name }), - success: t("settings.vaults.members.added", { name: user.display_name }), + pending: t("settings.vaults.members.adding", { name: user.handle }), + success: t("settings.vaults.members.added", { name: user.handle }), run: () => addMemberById(teamId, user.user_id), }); for (const roleId of selectedRoleIds) { @@ -361,14 +361,14 @@ function MemberRow({ member, isMe, myMember, teamId, roles }: { try { if (hasRole) { await runTeamAction({ - pending: t("settings.vaults.members.removingRole", { role: role.name, name: member.display_name }), - success: t("settings.vaults.members.roleRemoved", { role: role.name, name: member.display_name }), + pending: t("settings.vaults.members.removingRole", { role: role.name, name: member.handle }), + success: t("settings.vaults.members.roleRemoved", { role: role.name, name: member.handle }), run: () => removeMemberRole(teamId, member.user_id, role.id), }); } else { await runTeamAction({ - pending: t("settings.vaults.members.assigningRole", { role: role.name, name: member.display_name }), - success: t("settings.vaults.members.roleAssigned", { role: role.name, name: member.display_name }), + pending: t("settings.vaults.members.assigningRole", { role: role.name, name: member.handle }), + success: t("settings.vaults.members.roleAssigned", { role: role.name, name: member.handle }), run: () => assignMemberRole(teamId, member.user_id, role.id), }); } @@ -384,8 +384,8 @@ function MemberRow({ member, isMe, myMember, teamId, roles }: { setBusy(true); setError(""); try { await runTeamAction({ - pending: t("settings.vaults.members.removingMember", { name: member.display_name }), - success: t("settings.vaults.members.memberRemoved", { name: member.display_name }), + pending: t("settings.vaults.members.removingMember", { name: member.handle }), + success: t("settings.vaults.members.memberRemoved", { name: member.handle }), run: () => removeMember(teamId, member.user_id), }); } @@ -399,10 +399,10 @@ function MemberRow({ member, isMe, myMember, teamId, roles }: { return (
- +
-

{member.display_name}

+

{member.handle}

{isMe && {t("settings.vaults.members.youBadge")}}
@@ -628,11 +628,11 @@ function TeamMembersSummary({ teamId }: { teamId: string }) { {preview.map((m, i) => (
- +
))} {overflow > 0 && ( diff --git a/src/components/shared/AvatarStack.test.tsx b/src/components/shared/AvatarStack.test.tsx new file mode 100644 index 000000000..3f0261d27 --- /dev/null +++ b/src/components/shared/AvatarStack.test.tsx @@ -0,0 +1,48 @@ +import { describe, expect, test } from "vitest"; +import { avatarColor, 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("?"); + }); + + // An older server (no migration 035) omits `handle` on TeamMember entirely. + test("returns a question mark when the handle is missing", () => { + expect(handleInitials(undefined)).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"); + }); +}); + +describe("avatarColor", () => { + test("returns a stable color for a real handle", () => { + const color = avatarColor("merry-quartz-2597"); + expect(color).toBe(avatarColor("merry-quartz-2597")); + expect(color).toMatch(/^#[0-9a-f]{6}$/); + }); + + test("does not throw for a missing or empty handle", () => { + expect(() => avatarColor(undefined)).not.toThrow(); + expect(() => avatarColor("")).not.toThrow(); + expect(avatarColor(undefined)).toBe(avatarColor("")); + }); +}); diff --git a/src/components/shared/AvatarStack.tsx b/src/components/shared/AvatarStack.tsx index cada23091..29972ede4 100644 --- a/src/components/shared/AvatarStack.tsx +++ b/src/components/shared/AvatarStack.tsx @@ -5,14 +5,27 @@ const AVATAR_COLORS = [ "#f59e0b", "#10b981", "#3b82f6", "#14b8a6", ]; -export function avatarColor(name: string): string { +export function avatarColor(name: string | undefined): string { + const safe = name ?? ""; let h = 0; - for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h); + for (let i = 0; i < safe.length; i++) h = safe.charCodeAt(i) + ((h << 5) - h); 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. + * Returns "?" for a missing or empty handle (e.g. an older server that + * omits `handle` before migration 035). */ +export function handleInitials(handle: string | undefined): string { + if (!handle) return "?"; + 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; + name: string | undefined; size?: number; } @@ -25,10 +38,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/shared/UserSearchField.test.tsx b/src/components/shared/UserSearchField.test.tsx index 526ad8c4f..16b8a95fd 100644 --- a/src/components/shared/UserSearchField.test.tsx +++ b/src/components/shared/UserSearchField.test.tsx @@ -7,8 +7,8 @@ vi.mock("@/components/shared/AvatarStack", () => ({ MiniAvatar: () => null })); import { UserSearchField } from "./UserSearchField"; -const zoe = { user_id: "u1", display_name: "Zoe", handle: "zoe", is_teammate: false }; -const ada = { user_id: "u2", display_name: "Ada", handle: "ada", is_teammate: false }; +const zoe = { user_id: "u1", handle: "zesty-otter-1180", is_teammate: false }; +const ada = { user_id: "u2", handle: "amber-lynx-4410", is_teammate: false }; function renderField(overrides: Partial> = {}) { const props: React.ComponentProps = { @@ -36,13 +36,13 @@ afterEach(cleanup); test("renders one row per result and reports the clicked user", () => { const props = renderField({ results: [zoe, ada] }); expect(screen.getAllByText("Add")).toHaveLength(2); - fireEvent.click(screen.getByText("Ada")); + fireEvent.click(screen.getByText("amber-lynx-4410")); expect(props.onAdd).toHaveBeenCalledExactlyOnceWith(ada); }); test("a closed dropdown renders no rows", () => { renderField({ open: false }); - expect(screen.queryByText("Zoe")).toBeNull(); + expect(screen.queryByText("zesty-otter-1180")).toBeNull(); }); test("an empty result set renders nothing without emptyLabel, and the label with it", () => { @@ -62,8 +62,8 @@ test("the row being added shows a spinner instead of its add badge", () => { test("every result row is disabled while an add is in flight", () => { renderField({ results: [zoe, ada], adding: "u1" }); - expect(screen.getByText("Zoe").closest("button")).toHaveProperty("disabled", true); - expect(screen.getByText("Ada").closest("button")).toHaveProperty("disabled", true); + expect(screen.getByText("zesty-otter-1180").closest("button")).toHaveProperty("disabled", true); + expect(screen.getByText("amber-lynx-4410").closest("button")).toHaveProperty("disabled", true); }); test("searching swaps the search icon for a spinner", () => { diff --git a/src/components/shared/UserSearchField.tsx b/src/components/shared/UserSearchField.tsx index 98feec5bc..f8fafc13b 100644 --- a/src/components/shared/UserSearchField.tsx +++ b/src/components/shared/UserSearchField.tsx @@ -94,8 +94,8 @@ export function UserSearchField({ disabled={!!adding} onClick={() => onAdd(user)} > - - {user.display_name} + + {user.handle} {adding === user.user_id ? : 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..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; @@ -87,9 +88,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()} + {handleInitials(p.handle)}
))} {mpState.participants.length > 5 && ( diff --git a/src/components/terminal/PeopleTab.test.tsx b/src/components/terminal/PeopleTab.test.tsx index b0c2e52f0..d9212805a 100644 --- a/src/components/terminal/PeopleTab.test.tsx +++ b/src/components/terminal/PeopleTab.test.tsx @@ -51,7 +51,7 @@ test("teaches the resolution rule when nothing matches", async () => { test("a stranger row is marked and shows its handle", async () => { h.allTeammates.mockResolvedValue([]); - h.searchUsers.mockResolvedValue([{ user_id: "s1", display_name: "Sam", handle: "sam-q", is_teammate: false }]); + h.searchUsers.mockResolvedValue([{ user_id: "s1", handle: "sam-q", is_teammate: false }]); render(); await userEvent.type(screen.getByRole("textbox"), "sam-q"); const row = await screen.findByRole("button", { name: /sam/i }); @@ -60,37 +60,27 @@ 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: "kevin-p-6620", 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: /kevin-p-6620/i }); expect((row as HTMLButtonElement).disabled).toBe(true); await userEvent.click(row); expect(onInvite).not.toHaveBeenCalled(); }); test("inviting remembers the person", async () => { - h.searchUsers.mockResolvedValue([{ user_id: "s1", display_name: "Sam", handle: "sam-q", is_teammate: false }]); + h.searchUsers.mockResolvedValue([{ user_id: "s1", handle: "sam-q", is_teammate: false }]); render( {}} />); await userEvent.type(screen.getByRole("textbox"), "sam-q"); await userEvent.click(await screen.findByRole("button", { name: /sam/i })); await waitFor(() => expect(useRecentPeopleStore.getState().recent[0].user_id).toBe("s1")); }); -// An older server omits `handle` from /members, so a teammate row must never -// render a dangling "@" with nothing after it — it shipped once already. -test("a teammate row with no handle shows its name and renders no handle line", async () => { - h.allTeammates.mockResolvedValue([{ user_id: "u-alice", team_id: "t1", display_name: "Alice", is_online: true, teamIds: ["t1"] }]); - render(); - const row = await screen.findByRole("button", { name: /alice/i }); - expect(within(row).getByText("Alice")).toBeTruthy(); - expect(row.textContent).not.toMatch(/@/); -}); - test("Recent's own empty state stands alone even while Your teams has results", async () => { - h.allTeammates.mockResolvedValue([{ user_id: "u-alice", team_id: "t1", display_name: "Alice", is_online: true, teamIds: ["t1"] }]); + h.allTeammates.mockResolvedValue([{ user_id: "u-alice", team_id: "t1", handle: "amber-lynx-4410", is_online: true, teamIds: ["t1"] }]); render(); - await screen.findByRole("button", { name: /alice/i }); + await screen.findByRole("button", { name: /amber-lynx-4410/i }); expect(screen.getByText("terminal.share.recentEmpty")).toBeTruthy(); }); @@ -98,8 +88,8 @@ test("Recent's own empty state stands alone even while Your teams has results", // replaced it as ShareMenu's only invite surface) ─────────────────────────────── const roster = [ - { user_id: "u-alice", team_id: "t1", display_name: "Alice", is_online: true, teamIds: ["t1"] }, - { user_id: "u-bob", team_id: "t2", display_name: "Bob", is_online: false, teamIds: ["t2"] }, + { user_id: "u-alice", team_id: "t1", handle: "amber-lynx-4410", is_online: true, teamIds: ["t1"] }, + { user_id: "u-bob", team_id: "t2", handle: "brisk-otter-8823", is_online: false, teamIds: ["t2"] }, ]; type TabProps = Parameters[0]; @@ -119,17 +109,17 @@ function Harness({ onInvite, ...props }: Omit) { } test("a teammate row shows its handle when present", async () => { - h.allTeammates.mockResolvedValue([{ user_id: "u-alice", team_id: "t1", display_name: "Alice", handle: "alice-h", is_online: true, teamIds: ["t1"] }]); + h.allTeammates.mockResolvedValue([{ user_id: "u-alice", team_id: "t1", handle: "amber-lynx-4410", is_online: true, teamIds: ["t1"] }]); render(); - const row = await screen.findByRole("button", { name: /alice/i }); - expect(within(row).getByText("@alice-h")).toBeTruthy(); + const row = await screen.findByRole("button", { name: /amber-lynx-4410/i }); + expect(within(row).getByText("@amber-lynx-4410")).toBeTruthy(); }); test("marks a covered teammate as having access and does not call onInvite", async () => { h.allTeammates.mockResolvedValue(roster); const onInvite = vi.fn(); render(); - const row = (await screen.findByRole("button", { name: /alice/i })) as HTMLButtonElement; + const row = (await screen.findByRole("button", { name: /amber-lynx-4410/i })) as HTMLButtonElement; expect(row.disabled).toBe(true); expect(within(row).getByText("terminal.share.inviteHasAccess")).toBeTruthy(); await userEvent.click(row); @@ -142,12 +132,12 @@ 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: "amber-lynx-4410", last_invited_at: "" }], recentUpdatedAt: "", }); const onInvite = vi.fn(); render(); - const rows = await screen.findAllByRole("button", { name: /alice/i }); + const rows = await screen.findAllByRole("button", { name: /amber-lynx-4410/i }); expect(rows).toHaveLength(1); expect((rows[0] as HTMLButtonElement).disabled).toBe(true); expect(within(rows[0]).getByText("terminal.share.inviteHasAccess")).toBeTruthy(); @@ -160,7 +150,7 @@ test("disables the row while an invite is in flight and shows Invited after", as let resolve: () => void; const onInvite = vi.fn(() => new Promise((r) => { resolve = r; })); render(); - const row = (await screen.findByRole("button", { name: /alice/i })) as HTMLButtonElement; + const row = (await screen.findByRole("button", { name: /amber-lynx-4410/i })) as HTMLButtonElement; await userEvent.click(row); expect(row.disabled).toBe(true); await userEvent.click(row); @@ -173,7 +163,7 @@ test("surfaces a failed invite inline and re-enables the row", async () => { h.allTeammates.mockResolvedValue(roster); const onInvite = vi.fn().mockRejectedValue(new Error("boom")); render(); - const row = (await screen.findByRole("button", { name: /alice/i })) as HTMLButtonElement; + const row = (await screen.findByRole("button", { name: /amber-lynx-4410/i })) as HTMLButtonElement; await userEvent.click(row); expect(await screen.findByText("terminal.share.inviteFailed")).toBeTruthy(); expect(row.disabled).toBe(false); @@ -200,7 +190,7 @@ test("does not show a load-failure message while the roster request is still pen test("reloads the roster when the team list changes (ShareMenu's loadTeams races the mount effect)", async () => { h.allTeammates.mockResolvedValue(roster); render(); - await screen.findByRole("button", { name: /alice/i }); + await screen.findByRole("button", { name: /amber-lynx-4410/i }); expect(h.allTeammates).toHaveBeenCalledTimes(1); act(() => { @@ -224,8 +214,8 @@ test("a Pro host at cap 1 with one participant disables every not-already-covere onInvite={onInvite} />, ); - const alice = (await screen.findByRole("button", { name: /alice/i })) as HTMLButtonElement; - const bob = (await screen.findByRole("button", { name: /bob/i })) as HTMLButtonElement; + const alice = (await screen.findByRole("button", { name: /amber-lynx-4410/i })) as HTMLButtonElement; + const bob = (await screen.findByRole("button", { name: /brisk-otter-8823/i })) as HTMLButtonElement; expect(alice.disabled).toBe(true); expect(bob.disabled).toBe(true); expect(screen.getAllByText("terminal.share.inviteCapReached").length).toBe(2); @@ -241,7 +231,7 @@ test("a Teams host at cap 10 with two participants leaves rows tappable", async render( , ); - const alice = (await screen.findByRole("button", { name: /alice/i })) as HTMLButtonElement; + const alice = (await screen.findByRole("button", { name: /amber-lynx-4410/i })) as HTMLButtonElement; expect(alice.disabled).toBe(false); expect(screen.queryByText("terminal.share.inviteCapReached")).toBeNull(); }); @@ -252,7 +242,7 @@ test("a teammate in both participantIds and invitedIds counts once, not twice", , ); // Committed seats = 1 (deduped). If counted twice, this would read 2 and hit the cap. - const alice = (await screen.findByRole("button", { name: /alice/i })) as HTMLButtonElement; + const alice = (await screen.findByRole("button", { name: /amber-lynx-4410/i })) as HTMLButtonElement; expect(alice.disabled).toBe(false); }); @@ -260,8 +250,8 @@ test("after inviting one teammate at cap 1 with no participants, the remaining r h.allTeammates.mockResolvedValue(roster); const onInvite = vi.fn().mockResolvedValue(undefined); render(); - const alice = (await screen.findByRole("button", { name: /alice/i })) as HTMLButtonElement; - const bob = (await screen.findByRole("button", { name: /bob/i })) as HTMLButtonElement; + const alice = (await screen.findByRole("button", { name: /amber-lynx-4410/i })) as HTMLButtonElement; + const bob = (await screen.findByRole("button", { name: /brisk-otter-8823/i })) as HTMLButtonElement; expect(bob.disabled).toBe(false); await userEvent.click(alice); @@ -278,7 +268,7 @@ test("an already-invited row cannot be tapped a second time", async () => { h.allTeammates.mockResolvedValue(roster); const onInvite = vi.fn().mockResolvedValue(undefined); render(); - const alice = (await screen.findByRole("button", { name: /alice/i })) as HTMLButtonElement; + const alice = (await screen.findByRole("button", { name: /amber-lynx-4410/i })) as HTMLButtonElement; await userEvent.click(alice); await screen.findByText("terminal.share.inviteSent"); diff --git a/src/components/terminal/PeopleTab.tsx b/src/components/terminal/PeopleTab.tsx index 539eef21c..3a4cb8ec9 100644 --- a/src/components/terminal/PeopleTab.tsx +++ b/src/components/terminal/PeopleTab.tsx @@ -96,13 +96,7 @@ function PersonRow({ /> )} - {target.display_name} - {/* handle is optional: an older server omits it from /members */} - {target.handle && ( - - @{target.handle} - - )} + {target.handle ? `@${target.handle}` : "?"} {isStranger && ( ({ - target: { user_id: m.user_id, display_name: m.display_name, handle: m.handle, team_id: m.teamIds[0] }, + // An older server (no migration 035) omits `handle`; never render a bare "@". + target: { user_id: m.user_id, handle: m.handle ?? "", team_id: m.teamIds[0] }, teamIds: m.teamIds, isStranger: false, isOnline: !!m.is_online, })); const strangerEntries: RowEntry[] = groups.strangers.map((s) => ({ - target: { user_id: s.user_id, display_name: s.display_name, handle: s.handle }, + target: { user_id: s.user_id, handle: s.handle }, teamIds: [], isStranger: true, })); diff --git a/src/components/terminal/ShareMenu.invitePeople.test.tsx b/src/components/terminal/ShareMenu.invitePeople.test.tsx index 3cc69095d..5a9f5c358 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", 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()); @@ -78,7 +87,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, }, @@ -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", display_name: "Me" }, { user_id: "alice", display_name: "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", 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,24 +217,24 @@ 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(); }); 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; + const alice = (await screen.findByRole("button", { name: /merry/i })) as HTMLButtonElement; expect(alice.disabled).toBe(true); expect(screen.getByText("terminal.share.inviteCapReached")).toBeTruthy(); }); 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/hooks/useConnectionPresence.test.tsx b/src/hooks/useConnectionPresence.test.tsx index f3daee253..7fc859399 100644 --- a/src/hooks/useConnectionPresence.test.tsx +++ b/src/hooks/useConnectionPresence.test.tsx @@ -40,13 +40,13 @@ test("null when only self is present", () => { expect(result.current).toBeNull(); }); -test("single other user → primary set, overflow 0, name resolved", () => { +test("single other user → primary set, overflow 0, handle resolved", () => { useConnectionPresenceStore.setState({ myUserId: "me", usageByConnection: { c1: ["me", "u1"] } } as never); - useTeamStore.setState({ membersByTeam: { "team-1": [{ user_id: "u1", display_name: "Alice" }] } } as never); + useTeamStore.setState({ membersByTeam: { "team-1": [{ user_id: "u1", handle: "amber-lynx-4410" }] } } as never); const { result } = renderHook(() => useConnectionPresence(conn("c1", "team-1"))); - expect(result.current?.primary).toEqual({ id: "u1", displayName: "Alice" }); + expect(result.current?.primary).toEqual({ id: "u1", handle: "amber-lynx-4410" }); expect(result.current?.overflow).toBe(0); - expect(result.current?.allDisplayNames).toEqual(["Alice"]); + expect(result.current?.allHandles).toEqual(["amber-lynx-4410"]); }); test("two others → overflow 1, order preserved (usage order, self filtered)", () => { @@ -54,39 +54,39 @@ test("two others → overflow 1, order preserved (usage order, self filtered)", useTeamStore.setState({ membersByTeam: { "team-1": [ - { user_id: "u1", display_name: "Alice" }, - { user_id: "u2", display_name: "Bob" }, + { user_id: "u1", handle: "amber-lynx-4410" }, + { user_id: "u2", handle: "brisk-otter-8823" }, ], }, } as never); const { result } = renderHook(() => useConnectionPresence(conn("c1", "team-1"))); expect(result.current?.primary.id).toBe("u1"); expect(result.current?.overflow).toBe(1); - expect(result.current?.allDisplayNames).toEqual(["Alice", "Bob"]); + expect(result.current?.allHandles).toEqual(["amber-lynx-4410", "brisk-otter-8823"]); }); test('unknown user id falls back to "Member"', () => { useConnectionPresenceStore.setState({ myUserId: null, usageByConnection: { c1: ["u9"] } } as never); const { result } = renderHook(() => useConnectionPresence(conn("c1", "team-1"))); - expect(result.current?.primary.displayName).toBe("Member"); + expect(result.current?.primary.handle).toBe("Member"); }); test("myUserId null → no self filtering (all users are others)", () => { useConnectionPresenceStore.setState({ myUserId: null, usageByConnection: { c1: ["me"] } } as never); - useTeamStore.setState({ membersByTeam: { "team-1": [{ user_id: "me", display_name: "Self" }] } } as never); + useTeamStore.setState({ membersByTeam: { "team-1": [{ user_id: "me", handle: "merry-quartz-2597" }] } } as never); const { result } = renderHook(() => useConnectionPresence(conn("c1", "team-1"))); expect(result.current).not.toBeNull(); - expect(result.current?.primary.displayName).toBe("Self"); + expect(result.current?.primary.handle).toBe("merry-quartz-2597"); }); test("cross-team dedup: first occurrence of a user_id wins", () => { useConnectionPresenceStore.setState({ myUserId: null, usageByConnection: { c1: ["u1"] } } as never); useTeamStore.setState({ membersByTeam: { - A: [{ user_id: "u1", display_name: "First" }], - B: [{ user_id: "u1", display_name: "Second" }], + A: [{ user_id: "u1", handle: "first-heron-1001" }], + B: [{ user_id: "u1", handle: "second-heron-2002" }], }, } as never); const { result } = renderHook(() => useConnectionPresence(conn("c1", "team-1"))); - expect(result.current?.primary.displayName).toBe("First"); + expect(result.current?.primary.handle).toBe("first-heron-1001"); }); diff --git a/src/hooks/useConnectionPresence.ts b/src/hooks/useConnectionPresence.ts index 1775c9554..f780d54d7 100644 --- a/src/hooks/useConnectionPresence.ts +++ b/src/hooks/useConnectionPresence.ts @@ -4,10 +4,10 @@ import { useConnectionPresenceStore } from "@/stores/connectionPresenceStore"; import { useTeamStore } from "@/stores/teamStore"; export interface ConnectionPresence { - primary: { id: string; displayName: string }; + primary: { id: string; handle: string }; overflow: number; - /** All non-self user IDs in usage order (primary first). Useful for tooltips. */ - allDisplayNames: string[]; + /** All non-self handles in usage order (primary first). Useful for tooltips. */ + allHandles: string[]; } /** @@ -32,18 +32,18 @@ export function useConnectionPresence(connection: Connection): ConnectionPresenc if (others.length === 0) return null; // Build a flat lookup across all loaded teams (a user appears once per team). - const nameById = new Map(); + const handleById = new Map(); for (const members of Object.values(membersByTeam)) { for (const m of members) { - if (!nameById.has(m.user_id)) nameById.set(m.user_id, m.display_name); + if (m.handle && !handleById.has(m.user_id)) handleById.set(m.user_id, m.handle); } } - const resolved = others.map((id) => ({ id, displayName: nameById.get(id) ?? "Member" })); + const resolved = others.map((id) => ({ id, handle: handleById.get(id) ?? "Member" })); return { primary: resolved[0], overflow: resolved.length - 1, - allDisplayNames: resolved.map((r) => r.displayName), + allHandles: resolved.map((r) => r.handle), }; }, [vaultId, connection.id, userIds, myUserId, membersByTeam]); } diff --git a/src/hooks/useUserSearch.test.tsx b/src/hooks/useUserSearch.test.tsx index 57739af0a..aa9ebb547 100644 --- a/src/hooks/useUserSearch.test.tsx +++ b/src/hooks/useUserSearch.test.tsx @@ -6,8 +6,8 @@ vi.mock("@/services/teamService", () => ({ searchUsers: h.searchUsers })); import { useUserSearch } from "./useUserSearch"; -const zoe = { user_id: "u1", display_name: "Zoe", handle: "zoe", is_teammate: false }; -const ada = { user_id: "u2", display_name: "Ada", handle: "ada", is_teammate: false }; +const zoe = { user_id: "u1", handle: "zesty-otter-1180", is_teammate: false }; +const ada = { user_id: "u2", handle: "amber-lynx-4410", is_teammate: false }; beforeEach(() => { h.searchUsers.mockReset(); diff --git a/src/hooks/useWritableVaultIds.test.tsx b/src/hooks/useWritableVaultIds.test.tsx index 0260df508..f2727f1ca 100644 --- a/src/hooks/useWritableVaultIds.test.tsx +++ b/src/hooks/useWritableVaultIds.test.tsx @@ -15,7 +15,7 @@ import type { TeamMember, TeamRole } from "@/services/teamService"; const role = (id: string, permissions: number, name = id, is_builtin = false): TeamRole => ({ id, team_id: "t1", name, permissions, is_builtin, position: 0 } as TeamRole); const member = (user_id: string, role_ids: string[]): TeamMember => - ({ team_id: "t1", user_id, display_name: "", public_key: "", invited_by_display_name: null, joined_at: "", role_ids }); + ({ team_id: "t1", user_id, handle: "", public_key: "", invited_by_display_name: null, joined_at: "", role_ids }); beforeEach(() => { h.getMyUserId.mockResolvedValue("me"); diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index a9d6dcc79..d641ab4f6 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -84,8 +84,6 @@ "accountNotFound": "Account not found", "noAccountFoundCreateOne": "No account found. Please create one first.", "serverLoginFailed": "Server login failed", - "displayNameLength": "Display name must be 1–50 characters", - "updateDisplayNameFailed": "Failed to update display name: {{status}}", "sessionRefreshFailed": "Session refresh failed", "resendVerificationFailed": "Could not resend verification email", "noAccountFound": "No account found", 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/en/settings.json b/src/i18n/locales/en/settings.json index 895742f7d..c9b99e52b 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -245,12 +245,6 @@ "save": "Save", "toastVerification": "Verification email sent to {{email}}." }, - "displayName": { - "title": "Display name", - "cannotBeEmpty": "Cannot be empty", - "saving": "Saving…", - "save": "Save" - }, "handle": { "title": "Handle", "copy": "Copy", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 04f0db65f..66cf51a65 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -84,8 +84,6 @@ "accountNotFound": "Compte introuvable", "noAccountFoundCreateOne": "Aucun compte trouvé. Veuillez d'abord en créer un.", "serverLoginFailed": "Échec de la connexion au serveur", - "displayNameLength": "Le nom affiché doit contenir entre 1 et 50 caractères", - "updateDisplayNameFailed": "Échec de la mise à jour du nom affiché : {{status}}", "sessionRefreshFailed": "Échec du renouvellement de la session", "resendVerificationFailed": "Impossible de renvoyer l'e-mail de vérification", "noAccountFound": "Aucun compte trouvé", 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/fr/settings.json b/src/i18n/locales/fr/settings.json index 9860a9999..e56869904 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -245,12 +245,6 @@ "save": "Enregistrer", "toastVerification": "E-mail de vérification envoyé à {{email}}." }, - "displayName": { - "title": "Nom d'affichage", - "cannotBeEmpty": "Ne peut pas être vide", - "saving": "Enregistrement…", - "save": "Enregistrer" - }, "handle": { "title": "Pseudo", "copy": "Copier", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 9d4d1ecdf..66784a513 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -84,8 +84,6 @@ "accountNotFound": "Учётная запись не найдена", "noAccountFoundCreateOne": "Учётная запись не найдена. Сначала создайте её.", "serverLoginFailed": "Не удалось войти на сервер", - "displayNameLength": "Отображаемое имя должно содержать от 1 до 50 символов", - "updateDisplayNameFailed": "Не удалось обновить отображаемое имя: {{status}}", "sessionRefreshFailed": "Не удалось обновить сессию", "resendVerificationFailed": "Не удалось повторно отправить письмо для подтверждения", "noAccountFound": "Учётная запись не найдена", 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/ru/settings.json b/src/i18n/locales/ru/settings.json index 7420a895f..94bceb337 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -245,12 +245,6 @@ "save": "Сохранить", "toastVerification": "Письмо для подтверждения отправлено на {{email}}." }, - "displayName": { - "title": "Отображаемое имя", - "cannotBeEmpty": "Не может быть пустым", - "saving": "Сохранение…", - "save": "Сохранить" - }, "handle": { "title": "Псевдоним", "copy": "Копировать", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 80d65005c..2d2302390 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -84,8 +84,6 @@ "accountNotFound": "未找到账户", "noAccountFoundCreateOne": "未找到账户。请先创建一个。", "serverLoginFailed": "服务器登录失败", - "displayNameLength": "显示名称必须为 1-50 个字符", - "updateDisplayNameFailed": "更新显示名称失败:{{status}}", "sessionRefreshFailed": "会话刷新失败", "resendVerificationFailed": "无法重新发送验证邮件", "noAccountFound": "未找到账户", 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/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 6e9e44cb1..dd813f630 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -132,12 +132,6 @@ "save": "保存", "toastVerification": "验证邮件已发送到 {{email}}。" }, - "displayName": { - "title": "显示名称", - "cannotBeEmpty": "不能为空", - "saving": "正在保存…", - "save": "保存" - }, "handle": { "title": "Handle", "copy": "复制", diff --git a/src/plugins/domains/sharing.test.ts b/src/plugins/domains/sharing.test.ts index f69353c2f..b89c0b5c5 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-lynx-2222" }], 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-lynx-2222" }], }], fetchActiveSessions: vi.fn(async () => {}), state: (id: string) => (id === "s1" ? hostState() : undefined), @@ -45,7 +45,7 @@ test("shareSession refuses while the session's tab is broadcasting", async () => test("shareSession passes the vault members and owner tier through to the store", async () => { const startSharing = vi.fn(async () => "m9"); - const members = [{ team_id: "t1", user_id: "u2", invited_by_display_name: null, joined_at: "", display_name: "Two", public_key: "pk2", role_ids: [] }]; + const members = [{ team_id: "t1", user_id: "u2", invited_by_display_name: null, joined_at: "", handle: "two-lynx-2222", public_key: "pk2", role_ids: [] }]; const p = ports({ state: () => undefined, startSharing, teamMembers: async () => members, ownerTier: () => "business" }); expect(await shareSession(p, { sessionId: "s1", vaultIds: ["t1"], allowedRoles: ["manager"] })) .toEqual({ ok: true, result: { multiplayerSessionId: "m9" } }); @@ -106,7 +106,7 @@ test("listSharedSessions marks which local session each shared session belongs t const rows = await listSharedSessions(ports()); expect(rows).toEqual([{ multiplayerSessionId: "m1", localSessionId: "s1", connectionName: "web-1", isHost: true, - participants: [{ userId: "u2", displayName: "Two" }], controlHolder: "u0", controlRequester: null, + participants: [{ userId: "u2", displayName: "two-lynx-2222" }], controlHolder: "u0", controlRequester: null, }]); }); 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/plugins/domains/team.test.ts b/src/plugins/domains/team.test.ts index 2ef979493..34a69e3a6 100644 --- a/src/plugins/domains/team.test.ts +++ b/src/plugins/domains/team.test.ts @@ -4,7 +4,7 @@ import { listTeams, listMembers, keyStatus, inviteMember, removeMember, setMembe const member = (over: Partial = {}): TeamMember => ({ team_id: "t1", user_id: "u1", invited_by_display_name: null, joined_at: "", - display_name: "One", public_key: "pk1", role_ids: ["r1"], is_online: true, ...over, + handle: "one-heron-1111", public_key: "pk1", role_ids: ["r1"], is_online: true, ...over, }); function ports(over: Partial = {}): TeamPorts { @@ -42,35 +42,35 @@ test("listTeams resolves role ids to names and carries the vault status", async test("listMembers merges pending invitations, tagged by state", async () => { const p = ports({ pendingInvitations: () => [{ - id: "u9", display_name: "Nine", role: "member", - invited_by_display_name: "One", created_at: "", expires_at: "", + id: "u9", display_name: "nine-quail-9999", role: "member", + invited_by_display_name: "one-heron-1111", created_at: "", expires_at: "", }], }); const rows = await listMembers(p, "t1"); expect(rows).toEqual([ - { userId: "u1", displayName: "One", roles: ["manager"], roleIds: ["r1"], isOnline: true, state: "member" }, + { userId: "u1", displayName: "one-heron-1111", roles: ["manager"], roleIds: ["r1"], isOnline: true, state: "member" }, // The pending row carries the INVITATION id, under a name no caller can // mistake for a user id: member_remove(teamId, "u9") would address nothing. - { invitationId: "u9", displayName: "Nine", roles: ["member"], isOnline: false, state: "pending" }, + { invitationId: "u9", displayName: "nine-quail-9999", roles: ["member"], isOnline: false, state: "pending" }, ]); }); test("keyStatus reports a member who can be keyed but has not been — the keyless window", async () => { const p = ports({ - members: () => [member(), member({ user_id: "u2", display_name: "Two", public_key: "pk2" })], + members: () => [member(), member({ user_id: "u2", handle: "two-lynx-2222", public_key: "pk2" })], keyHolders: async () => ["u1"], }); const [status] = await keyStatus(p, "t1"); expect(status.members).toEqual([ - { userId: "u1", displayName: "One", hasPublicKey: true, hasWrappedKey: true }, - { userId: "u2", displayName: "Two", hasPublicKey: true, hasWrappedKey: false }, + { userId: "u1", displayName: "one-heron-1111", hasPublicKey: true, hasWrappedKey: true }, + { userId: "u2", displayName: "two-lynx-2222", hasPublicKey: true, hasWrappedKey: false }, ]); }); test("keyStatus distinguishes a member who has never published a public key", async () => { - const p = ports({ members: () => [member({ user_id: "u3", display_name: "Three", public_key: "" })] }); + const p = ports({ members: () => [member({ user_id: "u3", handle: "three-otter-3333", public_key: "" })] }); const [status] = await keyStatus(p, "t1"); - expect(status.members[0]).toEqual({ userId: "u3", displayName: "Three", hasPublicKey: false, hasWrappedKey: false }); + expect(status.members[0]).toEqual({ userId: "u3", displayName: "three-otter-3333", hasPublicKey: false, hasWrappedKey: false }); }); test("keyStatus reports iHoldKey false when the caller is not a key holder", async () => { @@ -106,13 +106,13 @@ test("inviteMember by email reports invited and returns no key state yet", async test("inviteMember by userId returns that member's key state so the keyless window is visible", async () => { const p = ports({ addMemberById: vi.fn(async () => ({ status: "pending" as const })), - members: () => [member({ user_id: "u2", display_name: "Two", public_key: "pk2" })], + members: () => [member({ user_id: "u2", handle: "two-lynx-2222", public_key: "pk2" })], keyHolders: async () => [], }); const res = await inviteMember(p, { teamId: "t1", userId: "u2" }); expect(res).toEqual({ ok: true, - result: { status: "pending", key: { userId: "u2", displayName: "Two", hasPublicKey: true, hasWrappedKey: false } }, + result: { status: "pending", key: { userId: "u2", displayName: "two-lynx-2222", hasPublicKey: true, hasWrappedKey: false } }, }); }); diff --git a/src/plugins/domains/team.ts b/src/plugins/domains/team.ts index 8849a8633..f8ac95890 100644 --- a/src/plugins/domains/team.ts +++ b/src/plugins/domains/team.ts @@ -80,7 +80,9 @@ export async function listMembers(ports: TeamPorts, teamId: string): Promise ({ userId: m.user_id, - displayName: m.display_name, + // An older server (no migration 035) omits `handle`; the MCP surface still + // needs a non-empty string here. + displayName: m.handle ?? "?", roles: roleNames(ports, teamId, m.role_ids), roleIds: m.role_ids, isOnline: m.is_online ?? false, @@ -115,7 +117,7 @@ export async function keyStatus(ports: TeamPorts, teamId?: string): Promise ({ userId: m.user_id, - displayName: m.display_name, + displayName: m.handle ?? "?", hasPublicKey: Boolean(m.public_key), hasWrappedKey: holders.has(m.user_id), })), diff --git a/src/plugins/toolSurface/tools/sharing.test.ts b/src/plugins/toolSurface/tools/sharing.test.ts index 800cfb31f..86b98b471 100644 --- a/src/plugins/toolSurface/tools/sharing.test.ts +++ b/src/plugins/toolSurface/tools/sharing.test.ts @@ -7,7 +7,7 @@ const SHARED = { localSessionId: "s1", connectionName: "prod", isHost: true, - participants: [{ userId: "u2", displayName: "Bo" }], + participants: [{ userId: "u2", displayName: "brisk-otter-8823" }], controlHolder: "u1", controlRequester: "u2", }; diff --git a/src/plugins/toolSurface/tools/team.test.ts b/src/plugins/toolSurface/tools/team.test.ts index 5757beda1..155d5bfa7 100644 --- a/src/plugins/toolSurface/tools/team.test.ts +++ b/src/plugins/toolSurface/tools/team.test.ts @@ -4,13 +4,13 @@ import type { ToolSurfacePorts } from "../coreTools"; const TEAM = { id: "t1", name: "Ops", ownerTier: "pro", myRoles: ["owner"], myRoleIds: ["r0"], vaultStatus: "ready" }; const MEMBER = { - userId: "u2", displayName: "Bo", roles: ["member"], roleIds: ["r1"], isOnline: true, state: "member" as const, + userId: "u2", displayName: "brisk-otter-8823", roles: ["member"], roleIds: ["r1"], isOnline: true, state: "member" as const, }; const KEY_STATUS = { teamId: "t1", vaultStatus: "ready", iHoldKey: true, - members: [{ userId: "u2", displayName: "Bo", hasPublicKey: true, hasWrappedKey: false }], + members: [{ userId: "u2", displayName: "brisk-otter-8823", hasPublicKey: true, hasWrappedKey: false }], }; function makePorts(overrides: Record = {}, approve = true) { diff --git a/src/services/account.localSession.test.ts b/src/services/account.localSession.test.ts index 98292d82d..e68b34e1d 100644 --- a/src/services/account.localSession.test.ts +++ b/src/services/account.localSession.test.ts @@ -36,7 +36,6 @@ import { createLocalAccount, getAccountMode, getCurrentUserEmail, - getCurrentDisplayName, isServerMode, } from "./account"; @@ -163,13 +162,11 @@ test("createLocalAccount derives the key, sets it, and records local mode", asyn // ─── thin keychain reads ───────────────────────────────────────────────────── -test("getAccountMode / getCurrentUserEmail / getCurrentDisplayName pass through keychain", async () => { +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..3592d70e3 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,24 +389,21 @@ export async function getMe(): Promise { } } -export async function fetchAndCacheDisplayName(): Promise { +/** + * The caller's own handle: keychain-cached first, falling back to the server + * only in "server" mode. An account that signed in before handles existed + * has none cached yet — that is the one case worth a fetch rather than + * leaving the row blank forever; a local-only account has no server to ask. + * Resolves to "" (never null) on any miss, so a caller can tell "no handle" + * from "still loading" by its own pending state, not by this return value. + */ +export async function getMyHandle(): Promise { + const cached = await keychainGet("handle"); + if (cached) return cached; + const mode = await getAccountMode(); + if (mode !== "server") return ""; 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); + return me?.handle ?? ""; } export async function refreshSession(): Promise { 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", diff --git a/src/services/multiplayerService.directInvite.test.ts b/src/services/multiplayerService.directInvite.test.ts index ab22e3b67..0b12ae187 100644 --- a/src/services/multiplayerService.directInvite.test.ts +++ b/src/services/multiplayerService.directInvite.test.ts @@ -49,7 +49,6 @@ beforeEach(() => { ); h.getUserPublicKey.mockImplementation(async (userId: string) => ({ user_id: userId, - display_name: userId, handle: userId, public_key: `pk-${userId}`, })); @@ -86,7 +85,7 @@ test("wraps the direct-session key to the server's current public key, not the c test("wraps a live-session invite to the server's current public key, not the caller's cached one", async () => { mockAppFetch(null, 204); - h.getUserPublicKey.mockResolvedValue({ user_id: "u3", display_name: "u3", handle: "u3", public_key: "fresh-key" }); + h.getUserPublicKey.mockResolvedValue({ user_id: "u3", handle: "u3", public_key: "fresh-key" }); await inviteUserToSession( "sess-1", { user_id: "u3", team_id: "t1", public_key: "stale-key" } as any, @@ -101,15 +100,15 @@ test("wraps a live-session invite to the server's current public key, not the ca test("invite to a user with no public account (404) throws instead of wrapping to nothing", async () => { h.getUserPublicKey.mockResolvedValue(null); await expect( - inviteUserToSession("sess-1", { user_id: "ghost", display_name: "Ghost" }, new Uint8Array(32)), + inviteUserToSession("sess-1", { user_id: "ghost", handle: "ghost-wren-4004" }, new Uint8Array(32)), ).rejects.toThrow("common.error.userNoLongerAvailable"); expect(h.appFetch).not.toHaveBeenCalled(); }); test("a stranger with no team_id resolves by id rather than through freshPublicKeys", async () => { const fetchMock = mockAppFetch({ session_id: "sess-1" }); - h.getUserPublicKey.mockResolvedValue({ user_id: "stranger", display_name: "S", handle: "s", public_key: "stranger-key" }); - await createDirectSession("web-prod", [{ user_id: "stranger", display_name: "S" } as any]); + h.getUserPublicKey.mockResolvedValue({ user_id: "stranger", handle: "stray-owl-7781", public_key: "stranger-key" }); + await createDirectSession("web-prod", [{ user_id: "stranger", handle: "stray-owl-7781" } as any]); // No team_id on the invitee, so the batched roster lookup has nothing to fetch. expect(h.freshPublicKeys).toHaveBeenCalledWith([]); expect(h.invoke).toHaveBeenCalledWith( diff --git a/src/services/multiplayerService.ts b/src/services/multiplayerService.ts index 75c3eaee8..2cc432427 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 { @@ -151,7 +147,7 @@ async function resolveStrangerPublicKey(userId: string): Promise { * createDirectSession. */ async function prepareWrappedSessionKey( - members: InviteTarget[], + members: { user_id: string; team_id?: string }[], ): Promise<{ sessionKey: SessionKey; sessionKeyBytes: Uint8Array; wrappedKeys: { user_id: string; wrapped_key: string }[] }> { const { publicKey } = await getMyX25519Keypair(); await teamService.updatePublicKey(publicKey); @@ -166,7 +162,7 @@ async function prepareWrappedSessionKey( // Teammates resolve in one batched request per team; a stranger has no // team_id to batch on, so they resolve individually by id. - const teammates = uniqueMembers.filter((m): m is InviteTarget & { team_id: string } => !!m.team_id); + const teammates = uniqueMembers.filter((m): m is { user_id: string; team_id: string } => !!m.team_id); const strangers = uniqueMembers.filter((m) => !m.team_id); const [teamKeys, strangerKeyList] = await Promise.all([ @@ -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..04c3b42d6 100644 --- a/src/services/multiplayerService.ws.test.ts +++ b/src/services/multiplayerService.ws.test.ts @@ -43,35 +43,63 @@ 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; 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"); +}); + 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("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", "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 +110,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 +128,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/permissions.test.ts b/src/services/permissions.test.ts index f270f409e..fd0578844 100644 --- a/src/services/permissions.test.ts +++ b/src/services/permissions.test.ts @@ -8,7 +8,7 @@ function role(id: string, permissions: number, extra: Partial = {}): T } function member(user_id: string, role_ids: string[]): TeamMember { return { - team_id: "t1", user_id, display_name: "", public_key: "", + team_id: "t1", user_id, handle: "", public_key: "", invited_by_display_name: null, joined_at: "", role_ids, }; } diff --git a/src/services/permissions.ts b/src/services/permissions.ts index 3db83205b..100cf2ac2 100644 --- a/src/services/permissions.ts +++ b/src/services/permissions.ts @@ -98,7 +98,7 @@ export function resolveCan( const fakeMember: TeamMember = { team_id: teamId, user_id: snapshot.myUserId, - display_name: "", + handle: "", public_key: "", invited_by_display_name: null, joined_at: "", diff --git a/src/services/teamInbox.test.ts b/src/services/teamInbox.test.ts index 3e6434793..fd3d0284e 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(), @@ -75,7 +73,7 @@ function invite(id: string): MyPendingInvitation { id, team_id: `team-${id}`, team_name: "Acme", - inviter_display_name: "Alice", + inviter_display_name: "amber-lynx-4410", role: "member", created_at: "2026-08-13T00:00:00Z", expires_at: "2026-08-20T00:00:00Z", @@ -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. @@ -419,7 +416,7 @@ function conn(over: Record = {}) { multiplayerSessionId: "mp1", role: "host", myUserId: "me", - participants: [{ user_id: "guest1", display_name: "Bob" }], + participants: [{ user_id: "guest1", handle: "brisk-otter-8823" }], controlHolder: "me", controlRequester: null, connection: {}, diff --git a/src/services/teamInbox.ts b/src/services/teamInbox.ts index c300a7c58..460ce1bcd 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), }); @@ -152,18 +149,19 @@ 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}` : 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 +227,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/teamService.ts b/src/services/teamService.ts index 6a3c91fdc..9aea64b74 100644 --- a/src/services/teamService.ts +++ b/src/services/teamService.ts @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import i18n from "@/i18n"; import { appFetch } from "@/services/http"; import { getJwt, getServerUrl, isJwtExpiredOrExpiring, tryRefreshJwt } from "@/services/authTokens"; @@ -39,13 +38,13 @@ export interface Team { export interface TeamMember { team_id: string; user_id: string; + /** The field name is the alias, the value is not: this holds the inviter's handle. */ invited_by_display_name: string | null; joined_at: string; - display_name: string; public_key: string; role_ids: string[]; is_online?: boolean; - /** Optional: an older server omits it. Never render a bare "@" when absent. */ + /** An older server (no migration 035) omits this. Never render a bare "@" when absent. */ handle?: string; } @@ -251,7 +250,6 @@ export async function deleteRole(teamId: string, roleId: string): Promise export interface UserSearchResult { user_id: string; - display_name: string; handle: string; is_teammate: boolean; } @@ -267,7 +265,6 @@ export async function searchUsers(q: string): Promise { export interface UserKeyLookup { user_id: string; - display_name: string; handle: string; public_key: string; } @@ -372,10 +369,6 @@ export async function getMyUserId(): Promise { } } -export async function getMyEmail(): Promise { - return invoke("keychain_get", { key: "email" }); -} - export async function getServerUrlValue(): Promise { return getServerUrl(); } @@ -384,8 +377,14 @@ export async function getServerUrlValue(): Promise { export interface PendingInvitation { id: string; + /** + * Wire key kept for older clients — the server sends no `handle` beside it. + * The value is the invitee's handle, or their raw email when they have no + * Voltius account yet. + */ display_name: string; role: string; + /** The field name is the alias, the value is not: this holds the inviter's handle. */ invited_by_display_name: string | null; created_at: string; expires_at: string; 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/services/teamSharing.allTeammates.test.ts b/src/services/teamSharing.allTeammates.test.ts index 20389e459..282ede2e2 100644 --- a/src/services/teamSharing.allTeammates.test.ts +++ b/src/services/teamSharing.allTeammates.test.ts @@ -12,7 +12,7 @@ import { useTeamStore } from "@/stores/teamStore"; import { allTeammates, freshPublicKeys, memberHasAccess, memberHasLiveAccess, seatUsage } from "./teamSharing.ts"; const member = (user_id: string, overrides: Partial = {}): TeamMember => ({ - team_id: "t1", user_id, display_name: user_id, public_key: "", + team_id: "t1", user_id, handle: user_id, public_key: "", invited_by_display_name: null, joined_at: "", role_ids: [], ...overrides, }); const team = (id: string): Team => ({ id, name: id, owner_id: "o", owner_tier: "team", created_at: "", role_ids: [] }); @@ -23,12 +23,12 @@ const seedStore = (opts: { teams: string[]; members: Record { localStorage.clear(); diff --git a/src/services/teamSharing.grouping.test.ts b/src/services/teamSharing.grouping.test.ts index 5b6fbe58d..0e7cbd0d2 100644 --- a/src/services/teamSharing.grouping.test.ts +++ b/src/services/teamSharing.grouping.test.ts @@ -1,10 +1,10 @@ import { test, expect } from "vitest"; import { groupPeople } from "./teamSharing"; -const mate = { user_id: "m1", display_name: "Zoe", handle: "quiet-otter-1", teamIds: ["t1"], team_id: "t1" }; -const recent = { user_id: "r1", handle: "kevin-p", display_name: "Kevin", last_invited_at: "2026-08-15T00:00:00.000Z" }; -const strangerHit = { user_id: "s1", display_name: "Sam", handle: "sam-q", is_teammate: false }; -const mateHit = { user_id: "m1", display_name: "Zoe", handle: "quiet-otter-1", is_teammate: true }; +const mate = { user_id: "m1", handle: "quiet-otter-1", teamIds: ["t1"], team_id: "t1" }; +const recent = { user_id: "r1", handle: "kevin-p", last_invited_at: "2026-08-15T00:00:00.000Z" }; +const strangerHit = { user_id: "s1", handle: "sam-q", is_teammate: false }; +const mateHit = { user_id: "m1", handle: "quiet-otter-1", is_teammate: true }; test("with no query, recent and teammates show and strangers do not", () => { const g = groupPeople({ query: "", teammates: [mate], recent: [recent], results: [] }); @@ -21,13 +21,13 @@ test("typing filters recent and teammates locally and adds the stranger group", }); test("a search hit that is already a teammate or already in recent is not repeated as a stranger", () => { - const g = groupPeople({ query: "zo", teammates: [mate], recent: [recent], results: [mateHit] }); + const g = groupPeople({ query: "quiet", teammates: [mate], recent: [recent], results: [mateHit] }); expect(g.teammates.map((p) => p.user_id)).toEqual(["m1"]); expect(g.strangers).toEqual([]); }); test("a person in both Recent and Your teams appears once, under Recent", () => { - const recentMate = { user_id: "m1", handle: "quiet-otter-1", display_name: "Zoe", last_invited_at: "2026-08-15T00:00:00.000Z" }; + const recentMate = { user_id: "m1", handle: "quiet-otter-1", last_invited_at: "2026-08-15T00:00:00.000Z" }; const g = groupPeople({ query: "", teammates: [mate], recent: [recentMate], results: [] }); expect(g.recent.map((p) => p.user_id)).toEqual(["m1"]); expect(g.teammates).toEqual([]); diff --git a/src/services/teamSharing.ts b/src/services/teamSharing.ts index 74ff5cf7f..d6fa1587e 100644 --- a/src/services/teamSharing.ts +++ b/src/services/teamSharing.ts @@ -17,8 +17,7 @@ export function sessionDisplayName(session: { connection_name: string | null }): /** Anyone a session can be granted to: a teammate row, or a stranger from search or Recent. */ export interface InviteTarget { user_id: string; - display_name: string; - handle?: string; + handle: string; /** Present only for teammates; absent for a stranger, who is in none of my teams. */ team_id?: string; } @@ -88,7 +87,7 @@ export async function allTeammates(): Promise { return [...merged.values()].sort((a, b) => { if (!!a.is_online !== !!b.is_online) return a.is_online ? -1 : 1; - return a.display_name.localeCompare(b.display_name); + return (a.handle ?? "").localeCompare(b.handle ?? ""); }); } @@ -166,21 +165,20 @@ export function memberHasAccess( * adds Elsewhere on Voltius from the server's results. A person is listed once — * the most specific group wins. */ -export function groupPeople(input: { +export function groupPeople(input: { query: string; teammates: T[]; recent: RecentPerson[]; results: UserSearchResult[]; }): { recent: RecentPerson[]; teammates: T[]; strangers: UserSearchResult[] } { const q = input.query.trim().toLowerCase(); - const matches = (...fields: (string | undefined)[]) => - !q || fields.some((f) => (f ?? "").toLowerCase().includes(q)); + const matches = (handle: string | undefined) => !q || !!handle?.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. - const teammates = input.teammates.filter((p) => !recentIds.has(p.user_id) && matches(p.display_name, p.handle)); + const teammates = input.teammates.filter((p) => !recentIds.has(p.user_id) && matches(p.handle)); const claimed = new Set([...recentIds, ...teammates.map((p) => p.user_id)]); const strangers = q ? input.results.filter((r) => !claimed.has(r.user_id) && !r.is_teammate) : []; return { recent, teammates, strangers }; diff --git a/src/stores/recentPeopleStore.rehydrate.test.ts b/src/stores/recentPeopleStore.rehydrate.test.ts new file mode 100644 index 000000000..e8fab1f21 --- /dev/null +++ b/src/stores/recentPeopleStore.rehydrate.test.ts @@ -0,0 +1,39 @@ +import { test, expect, beforeEach, vi } from "vitest"; + +const STORAGE_KEY = "voltius-recent-people"; + +// zustand's `persist` runs `hydrate()` synchronously inside `create()` when +// storage is sync (localStorage): `toThenable` resolves a non-Promise result +// by calling `.then` inline rather than deferring to a microtask, so the +// entire hydrate chain — including any `onRehydrateStorage` callback — runs +// to completion before the module's own top-level `const` assignment +// finishes. A callback that closes over that `const` (as the old +// `onRehydrateStorage` implementation did) hits the temporal dead zone, +// throws, and is silently swallowed by `hydrate()`'s own `.catch`. This test +// exercises that exact path — real module-load hydration, not a manual +// `persist.rehydrate()` call after the module is already initialized, which +// is the one arrangement that cannot observe the bug. +beforeEach(() => { + localStorage.clear(); + vi.resetModules(); +}); + +test("module-load hydration drops a legacy empty-handle row before first read", async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + state: { + recent: [ + { user_id: "legacy", handle: "", last_invited_at: "2026-01-01T00:00:00.000Z" }, + { user_id: "ok", handle: "merry-quartz-2597", last_invited_at: "2026-01-02T00:00:00.000Z" }, + ], + recentUpdatedAt: "2026-01-02T00:00:00.000Z", + }, + // The pre-bump persisted shape: no explicit `version` option meant + // zustand wrote `version: 0` on every real save. + version: 0, + })); + + const { useRecentPeopleStore } = await import("./recentPeopleStore"); + + const { recent } = useRecentPeopleStore.getState(); + expect(recent.map((p) => p.user_id)).toEqual(["ok"]); +}); diff --git a/src/stores/recentPeopleStore.test.ts b/src/stores/recentPeopleStore.test.ts index 78606b69f..86b5a8f11 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,36 @@ 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); +}); + +// A peer device's synced list or an old export file can carry a pre-handle +// row with handle: "". replaceAll must filter it out, not just migrate(). +test("replaceAll drops a row with an empty handle", () => { + useRecentPeopleStore.getState().replaceAll([ + { user_id: "u1", handle: "", last_invited_at: "2026-08-15T00:00:00.000Z" }, + person("u2"), + ]); + const { recent } = useRecentPeopleStore.getState(); + expect(recent.map((p) => p.user_id)).toEqual(["u2"]); }); test("replaceAll caps the list and rejects a non-array", () => { @@ -70,3 +99,8 @@ test("a remotely applied list adopts the remote timestamp", async () => { }); expect(useRecentPeopleStore.getState().recentUpdatedAt).toBe(remoteAt); }); + +// The rehydration-drops-a-legacy-row case now lives in +// recentPeopleStore.rehydrate.test.ts, which exercises real module-load +// hydration rather than a manual persist.rehydrate() call after the module +// is already initialized — see that file for why the distinction matters. diff --git a/src/stores/recentPeopleStore.ts b/src/stores/recentPeopleStore.ts index d2fafc8b6..d0802edb6 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, }; } @@ -69,9 +67,25 @@ export const useRecentPeopleStore = create()( set(() => { const recentUpdatedAt = settingsStamp(); pushSettingsChange(); - return { recent: Array.isArray(list) ? list.slice(0, MAX_RECENT).map(project) : [], recentUpdatedAt }; + return { recent: Array.isArray(list) ? list.filter((p) => p.handle).slice(0, MAX_RECENT).map(project) : [], recentUpdatedAt }; }), }), - { name: "voltius-recent-people" }, + { + name: "voltius-recent-people", + // Bumped from the unversioned (pre-0.26) shape: those rows can carry + // `handle: ""` (written before handles existed), and `project()` never + // runs on rehydration. `migrate` runs synchronously inside `create()`, + // before this module's own top-level bindings exist — unlike + // `onRehydrateStorage`, which closes over `useRecentPeopleStore` and so + // silently no-ops at real app startup (ReferenceError, swallowed). + version: 1, + migrate: (persistedState) => { + const state = persistedState as { recent?: RecentPerson[]; recentUpdatedAt?: string } | undefined; + return { + recent: (state?.recent ?? []).filter((p) => p.handle), + recentUpdatedAt: state?.recentUpdatedAt ?? new Date(0).toISOString(), + }; + }, + }, ), ); diff --git a/src/stores/teamSessionStore.directInvite.test.ts b/src/stores/teamSessionStore.directInvite.test.ts index 605419d0b..c0b8b2d94 100644 --- a/src/stores/teamSessionStore.directInvite.test.ts +++ b/src/stores/teamSessionStore.directInvite.test.ts @@ -16,14 +16,16 @@ 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"; -const member = (userId: string): TeamMember => ({ - team_id: "t1", user_id: userId, invited_by_display_name: null, joined_at: "", display_name: userId, public_key: "pk", role_ids: [], -}); +// `satisfies` (not `: TeamMember`) keeps `handle` narrowed to `string` in the +// inferred type, so these fixtures also satisfy `InviteTarget` at call sites +// that pass them directly — TeamMember's `handle` is optional for a pre-035 server. +const member = (userId: string) => ({ + team_id: "t1", user_id: userId, invited_by_display_name: null, joined_at: "", handle: userId, public_key: "pk", role_ids: [], +}) satisfies TeamMember; beforeEach(() => { Object.values(mp).forEach((f) => f.mockClear()); diff --git a/src/stores/teamSessionStore.test.ts b/src/stores/teamSessionStore.test.ts index 00bda0a52..3455d7a7d 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"; @@ -25,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: {} }); @@ -51,9 +65,14 @@ 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(); }); + // 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", "Guest", () => {}); + const localId = await get().joinSession("m1", () => {}); expect(get().connections[localId]).toMatchObject({ role: "guest", multiplayerSessionId: "m1" }); cb.onParticipantList([{ user_id: "u1" }, { user_id: "u2" }]); @@ -74,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"]); +}); 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); diff --git a/src/stores/teamStore.test.ts b/src/stores/teamStore.test.ts index 295ad67bb..be48ce5d0 100644 --- a/src/stores/teamStore.test.ts +++ b/src/stores/teamStore.test.ts @@ -17,7 +17,7 @@ import { useTeamStore } from "./teamStore.ts"; const team = (id: string, role_ids: string[] = []): Team => ({ id, name: id, owner_id: "o", owner_tier: "team", created_at: "", role_ids }); const member = (user_id: string, role_ids: string[] = []): TeamMember => - ({ team_id: "t1", user_id, display_name: "", public_key: "", invited_by_display_name: null, joined_at: "", role_ids }); + ({ team_id: "t1", user_id, handle: "", public_key: "", invited_by_display_name: null, joined_at: "", role_ids }); const role = (id: string, permissions = 0): TeamRole => ({ id, team_id: "t1", name: id, permissions, is_builtin: false, position: 0 } as TeamRole);