From 2303b7e96e84a99806118e478b8050721a23bc70 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 03:19:09 +0000 Subject: [PATCH 01/24] feat(invites): add the handle, preference, decline and un-invite client API --- src/components/members/MembersPage.tsx | 14 ++-- .../settings/sections/VaultsSection.tsx | 10 ++- .../shared/UserSearchField.test.tsx | 4 +- src/hooks/useUserSearch.ts | 4 +- src/services/multiplayerService.ts | 3 +- src/services/teamService.invites.test.ts | 39 +++++++++++ src/services/teamService.ts | 66 ++++++++++++++++++- 7 files changed, 120 insertions(+), 20 deletions(-) create mode 100644 src/services/teamService.invites.test.ts diff --git a/src/components/members/MembersPage.tsx b/src/components/members/MembersPage.tsx index 32b20888f..e1052a6bd 100644 --- a/src/components/members/MembersPage.tsx +++ b/src/components/members/MembersPage.tsx @@ -38,7 +38,7 @@ import { seatAvailability } from "@/services/seatMath"; import { guestCapFor, inviteSessionOf, memberHasAccess, seatUsage } from "@/services/teamSharing"; import { SeatsMeter } from "@/components/members/SeatsMeter"; import { ROLE_META, RoleToggleChip } from "@/components/members/roleChips"; -import { useUserSearch } from "@/hooks/useUserSearch"; +import { useUserSearch, type UserSearchResult } from "@/hooks/useUserSearch"; function RoleChip({ role }: { role: TeamRole }) { const { t } = useTranslation(); @@ -661,8 +661,6 @@ export function PendingInviteCard({ // ─── Invite panel ───────────────────────────────────────────────────────────── -interface SearchResult { user_id: string; display_name: string; public_key: string; } - function isValidEmail(s: string) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); } @@ -687,7 +685,7 @@ export function InvitePanel({ teamId, existingIds, teamRoles, onClose, onMemberA const [sendingInvite, setSendingInvite] = useState(false); const [success, setSuccess] = useState(""); const [error, setError] = useState(""); - const [buySeatsFor, setBuySeatsFor] = useState(undefined); + const [buySeatsFor, setBuySeatsFor] = useState(undefined); const { atLimit: isAtSeatLimit } = seatAvailability(usedSeats, totalSeats); @@ -724,7 +722,7 @@ export function InvitePanel({ teamId, existingIds, teamRoles, onClose, onMemberA useEffect(() => { void reloadSubscription(); }, []); useEffect(() => { inputRef.current?.focus(); }, []); - const handleAdd = async (user: SearchResult) => { + const handleAdd = async (user: UserSearchResult) => { if (isAtSeatLimit) { setBuySeatsFor(user); setOpen(false); return; } setAdding(user.user_id); setError(""); setSuccess(""); try { @@ -880,7 +878,7 @@ function PrivateVaultInvitePanel({ }: { query: string; onQueryChange: (v: string) => void; - results: { user_id: string; display_name: string; public_key: string }[]; + results: UserSearchResult[]; searching: boolean; open: boolean; setOpen: (v: boolean) => void; @@ -888,7 +886,7 @@ function PrivateVaultInvitePanel({ error: string; inputRef: React.RefObject; dropdownRef: React.RefObject; - onAdd: (user: { user_id: string; display_name: string; public_key: string }, roleName: string) => void; + onAdd: (user: UserSearchResult, roleName: string) => void; onClose: () => void; }) { const { t } = useTranslation(); @@ -1105,7 +1103,7 @@ export default function MembersPage() { loadPendingInvitations(teamId).catch(() => {}); }, [teamId, canManageMembers, loadPendingInvitations]); - const handlePrivateAdd = async (user: { user_id: string; display_name: string; public_key: string }, roleName: string) => { + const handlePrivateAdd = async (user: UserSearchResult, roleName: string) => { if (!localVault || !primaryVaultId) return; setPrivateAdding(user.user_id); setPrivateError(""); try { diff --git a/src/components/settings/sections/VaultsSection.tsx b/src/components/settings/sections/VaultsSection.tsx index bb96a2f5d..39fc7544b 100644 --- a/src/components/settings/sections/VaultsSection.tsx +++ b/src/components/settings/sections/VaultsSection.tsx @@ -23,7 +23,7 @@ import { runTeamAction } from "@/services/teamActionFeedback"; import { markTeamVaultLoadedAfterLocalActivation } from "@/services/teamVaultActivation"; import { openBillingCheckout } from "@/services/billingCheckout"; import { useTeamVaultStateStore } from "@/stores/teamVaultStateStore"; -import { useUserSearch } from "@/hooks/useUserSearch"; +import { useUserSearch, type UserSearchResult } from "@/hooks/useUserSearch"; // ─── Vault migration helpers ────────────────────────────────────────────────── @@ -155,8 +155,6 @@ function MemberRoleBadges({ member, roles }: { member: TeamMember; roles: TeamRo // ─── Invite search bar ──────────────────────────────────────────────────────── -interface SearchResult { user_id: string; display_name: string; public_key: string; } - function isValidEmail(s: string) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); } @@ -179,7 +177,7 @@ function InviteBar({ teamId, existingIds, roles, canInvite, onMemberAdded }: { const [sendingInvite, setSendingInvite] = useState(false); const [success, setSuccess] = useState(""); const [error, setError] = useState(""); - const [buySeatsFor, setBuySeatsFor] = useState(undefined); + const [buySeatsFor, setBuySeatsFor] = useState(undefined); const isAtSeatLimit = seatAvailability(usedSeats, totalSeats).atLimit; @@ -204,7 +202,7 @@ function InviteBar({ teamId, existingIds, roles, canInvite, onMemberAdded }: { if (!canInvite) return null; - const handleAdd = async (user: SearchResult) => { + const handleAdd = async (user: UserSearchResult) => { if (isAtSeatLimit) { setBuySeatsFor(user); setOpen(false); return; } setAdding(user.user_id); setError(""); setSuccess(""); @@ -739,7 +737,7 @@ export function PrivateVaultMembersPanel({ const [adding, setAdding] = useState(null); const [error, setError] = useState(""); - const handleAdd = async (user: SearchResult) => { + const handleAdd = async (user: UserSearchResult) => { setAdding(user.user_id); setError(""); try { diff --git a/src/components/shared/UserSearchField.test.tsx b/src/components/shared/UserSearchField.test.tsx index 6bdfaeaae..526ad8c4f 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", public_key: "pk1" }; -const ada = { user_id: "u2", display_name: "Ada", public_key: "pk2" }; +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 }; function renderField(overrides: Partial> = {}) { const props: React.ComponentProps = { diff --git a/src/hooks/useUserSearch.ts b/src/hooks/useUserSearch.ts index 7f5abb87d..d0994a5f2 100644 --- a/src/hooks/useUserSearch.ts +++ b/src/hooks/useUserSearch.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; -import { searchUsers } from "@/services/teamService"; +import { searchUsers, type UserSearchResult } from "@/services/teamService"; -export interface UserSearchResult { user_id: string; display_name: string; public_key: string; } +export type { UserSearchResult }; const MIN_QUERY_LENGTH = 2; const DEBOUNCE_MS = 250; diff --git a/src/services/multiplayerService.ts b/src/services/multiplayerService.ts index 408686fde..aab390233 100644 --- a/src/services/multiplayerService.ts +++ b/src/services/multiplayerService.ts @@ -10,7 +10,8 @@ import { openXChaCha20Poly1305, sealXChaCha20Poly1305 } from "@/services/crypto/ export interface ActiveSession { id: string; - connection_name: string; + /** Null for a stranger who hasn't accepted yet — the host name is withheld until then. */ + connection_name: string | null; host_user_id: string; host_public_key: string; visibility: string; diff --git a/src/services/teamService.invites.test.ts b/src/services/teamService.invites.test.ts new file mode 100644 index 000000000..75b8c21f3 --- /dev/null +++ b/src/services/teamService.invites.test.ts @@ -0,0 +1,39 @@ +import { test, expect, vi, beforeEach } from "vitest"; + +const h = vi.hoisted(() => ({ appFetch: vi.fn() })); +vi.mock("@/services/http", () => ({ appFetch: h.appFetch })); +vi.mock("@/services/authTokens", () => ({ + getJwt: async () => "jwt", + getServerUrl: async () => "https://srv.test", + isJwtExpiredOrExpiring: () => false, + tryRefreshJwt: async () => "jwt", +})); + +import { declineSessionInvite, getUserPublicKey, uninviteFromSession } from "./teamService"; + +beforeEach(() => h.appFetch.mockReset()); + +test("decline hits the me route and asks for no block by default", async () => { + h.appFetch.mockResolvedValue({ ok: true, status: 204 }); + await declineSessionInvite("s1"); + const [url, init] = h.appFetch.mock.calls[0]; + expect(url).toBe("https://srv.test/v1/terminal-sessions/s1/invitees/me"); + expect(init.method).toBe("DELETE"); +}); + +test("a permanent decline carries the query flag", async () => { + h.appFetch.mockResolvedValue({ ok: true, status: 204 }); + await declineSessionInvite("s1", { permanent: true }); + expect(h.appFetch.mock.calls[0][0]).toBe("https://srv.test/v1/terminal-sessions/s1/invitees/me?block=permanent"); +}); + +test("un-invite targets the user id", async () => { + h.appFetch.mockResolvedValue({ ok: true, status: 204 }); + await uninviteFromSession("s1", "u9"); + expect(h.appFetch.mock.calls[0][0]).toBe("https://srv.test/v1/terminal-sessions/s1/invitees/u9"); +}); + +test("a 404 from the key lookup resolves to null so Recent can self-heal", async () => { + h.appFetch.mockResolvedValue({ ok: false, status: 404 }); + expect(await getUserPublicKey("gone")).toBeNull(); +}); diff --git a/src/services/teamService.ts b/src/services/teamService.ts index 79d2fdfd1..c166f9fcd 100644 --- a/src/services/teamService.ts +++ b/src/services/teamService.ts @@ -247,7 +247,14 @@ export async function deleteRole(teamId: string, roleId: string): Promise } } -export async function searchUsers(q: string): Promise<{ user_id: string; display_name: string; public_key: string }[]> { +export interface UserSearchResult { + user_id: string; + display_name: string; + handle: string; + is_teammate: boolean; +} + +export async function searchUsers(q: string): Promise { if (q.length < 2) return []; const serverUrl = await getServerUrl(); if (!serverUrl) return []; @@ -256,6 +263,63 @@ export async function searchUsers(q: string): Promise<{ user_id: string; display return res.json(); } +export interface UserKeyLookup { + user_id: string; + display_name: string; + handle: string; + public_key: string; +} + +/** null when the user no longer resolves — the caller drops the stale row. */ +export async function getUserPublicKey(userId: string): Promise { + const serverUrl = await getServerUrl(); + if (!serverUrl) return null; + const res = await fetchAuth(`${serverUrl}/v1/users/${userId}/public-key`); + if (!res.ok) return null; + return res.json(); +} + +export class HandleClaimError extends Error { + constructor(public status: number) { + super(`handle claim failed: ${status}`); + } +} + +export async function claimHandle(handle: string): Promise { + const serverUrl = await getServerUrl(); + if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); + const res = await fetchAuth(`${serverUrl}/v1/users/me/handle`, { + method: "PUT", + body: JSON.stringify({ handle }), + }); + if (!res.ok) throw new HandleClaimError(res.status); +} + +export async function updateInvitePreferences(allowStrangerInvites: boolean): Promise { + const serverUrl = await getServerUrl(); + if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); + const res = await fetchAuth(`${serverUrl}/v1/users/me/preferences`, { + method: "PUT", + body: JSON.stringify({ allow_stranger_invites: allowStrangerInvites }), + }); + if (!res.ok) throw new Error(i18n.t("common.error.failedToSavePreferences")); +} + +export async function declineSessionInvite(sessionId: string, opts?: { permanent?: boolean }): Promise { + const serverUrl = await getServerUrl(); + if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); + const query = opts?.permanent ? "?block=permanent" : ""; + const res = await fetchAuth(`${serverUrl}/v1/terminal-sessions/${sessionId}/invitees/me${query}`, { method: "DELETE" }); + if (!res.ok) throw new Error(i18n.t("common.error.failedToDecline")); +} + +export async function uninviteFromSession(sessionId: string, userId: string): Promise { + const serverUrl = await getServerUrl(); + if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); + const res = await fetchAuth(`${serverUrl}/v1/terminal-sessions/${sessionId}/invitees/${userId}`, { method: "DELETE" }); + if (!res.ok) throw new Error(i18n.t("common.error.failedToUninvite")); +} + export async function updatePublicKey(publicKey: string): Promise { const serverUrl = await getServerUrl(); if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); From f087740b43c6b56c999ebe454e0ff819887ff3be Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 03:29:36 +0000 Subject: [PATCH 02/24] refactor(invites): extract authedCall helper for the three plain-error client calls --- src/hooks/useUserSearch.test.tsx | 4 ++-- src/services/teamService.ts | 39 ++++++++++++++++++++------------ 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/hooks/useUserSearch.test.tsx b/src/hooks/useUserSearch.test.tsx index 708c08a2a..57739af0a 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", public_key: "pk1" }; -const ada = { user_id: "u2", display_name: "Ada", public_key: "pk2" }; +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 }; beforeEach(() => { h.searchUsers.mockReset(); diff --git a/src/services/teamService.ts b/src/services/teamService.ts index c166f9fcd..45649bf4c 100644 --- a/src/services/teamService.ts +++ b/src/services/teamService.ts @@ -285,6 +285,9 @@ export class HandleClaimError extends Error { } } +// claimHandle throws a status-carrying HandleClaimError (Task 15 branches on it per-status) +// and getUserPublicKey resolves to null instead of throwing — both diverge from the plain +// "throw the keyed i18n error" shape below, so they stay out of authedCall. export async function claimHandle(handle: string): Promise { const serverUrl = await getServerUrl(); if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); @@ -295,29 +298,37 @@ export async function claimHandle(handle: string): Promise { if (!res.ok) throw new HandleClaimError(res.status); } -export async function updateInvitePreferences(allowStrangerInvites: boolean): Promise { +/** Resolves serverUrl, calls fetchAuth, and throws the keyed i18n error on a non-ok response. */ +async function authedCall(path: string, init: RequestInit, errorKey: string): Promise { const serverUrl = await getServerUrl(); if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); - const res = await fetchAuth(`${serverUrl}/v1/users/me/preferences`, { - method: "PUT", - body: JSON.stringify({ allow_stranger_invites: allowStrangerInvites }), - }); - if (!res.ok) throw new Error(i18n.t("common.error.failedToSavePreferences")); + const res = await fetchAuth(`${serverUrl}${path}`, init); + if (!res.ok) throw new Error(i18n.t(errorKey)); +} + +export async function updateInvitePreferences(allowStrangerInvites: boolean): Promise { + await authedCall( + "/v1/users/me/preferences", + { method: "PUT", body: JSON.stringify({ allow_stranger_invites: allowStrangerInvites }) }, + "common.error.failedToSavePreferences", + ); } export async function declineSessionInvite(sessionId: string, opts?: { permanent?: boolean }): Promise { - const serverUrl = await getServerUrl(); - if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); const query = opts?.permanent ? "?block=permanent" : ""; - const res = await fetchAuth(`${serverUrl}/v1/terminal-sessions/${sessionId}/invitees/me${query}`, { method: "DELETE" }); - if (!res.ok) throw new Error(i18n.t("common.error.failedToDecline")); + await authedCall( + `/v1/terminal-sessions/${sessionId}/invitees/me${query}`, + { method: "DELETE" }, + "common.error.failedToDecline", + ); } export async function uninviteFromSession(sessionId: string, userId: string): Promise { - const serverUrl = await getServerUrl(); - if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); - const res = await fetchAuth(`${serverUrl}/v1/terminal-sessions/${sessionId}/invitees/${userId}`, { method: "DELETE" }); - if (!res.ok) throw new Error(i18n.t("common.error.failedToUninvite")); + await authedCall( + `/v1/terminal-sessions/${sessionId}/invitees/${userId}`, + { method: "DELETE" }, + "common.error.failedToUninvite", + ); } export async function updatePublicKey(publicKey: string): Promise { From 0c1c2ea05cec36cd4ab09a1f14e7e2ff0269b409 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 03:43:17 +0000 Subject: [PATCH 03/24] feat(share): wrap session keys for strangers and render a redacted session name once --- src/components/hosts/TeamSessions.tsx | 8 ++- src/components/members/MembersPage.tsx | 4 +- src/components/omni/OmniSearch.tsx | 9 ++-- src/plugins/domains/sharing.ts | 3 +- .../multiplayerService.directInvite.test.ts | 31 +++++++++++- src/services/multiplayerService.ts | 50 ++++++++++++++----- src/services/teamInbox.ts | 8 +-- src/services/teamSharing.allTeammates.test.ts | 3 +- src/services/teamSharing.displayName.test.ts | 13 +++++ src/services/teamSharing.ts | 21 +++++++- 10 files changed, 123 insertions(+), 27 deletions(-) create mode 100644 src/services/teamSharing.displayName.test.ts diff --git a/src/components/hosts/TeamSessions.tsx b/src/components/hosts/TeamSessions.tsx index f0be5961c..d88efc5a1 100644 --- a/src/components/hosts/TeamSessions.tsx +++ b/src/components/hosts/TeamSessions.tsx @@ -13,6 +13,7 @@ import { AvatarTile } from "@/components/shared/AvatarTile"; import { BaseCard } from "@/components/shared/BaseCard"; import { parseInviteCode } from "@/services/inviteCode"; import { joinTeamSessionAndOpenTab } from "@/services/teamSessionJoin"; +import { sessionDisplayName } from "@/services/teamSharing"; function JoinByCodeButton({ onClick }: { onClick: () => void }) { const { t } = useTranslation(); @@ -85,7 +86,10 @@ export function TeamSessions() { await joinTeamSessionAndOpenTab({ sessionId, displayName, - connectionName: activeSessions.find((a) => a.id === sessionId)?.connection_name ?? t("hosts.teamSessions.sharedTerminalFallback"), + // 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, + }), inviteToken, }); }; @@ -260,7 +264,7 @@ export function TeamSessions() {

- {session.connection_name} + {sessionDisplayName(session)}

{/* Avatar stack — where tags sit on host cards */} diff --git a/src/components/members/MembersPage.tsx b/src/components/members/MembersPage.tsx index e1052a6bd..bdd56808c 100644 --- a/src/components/members/MembersPage.tsx +++ b/src/components/members/MembersPage.tsx @@ -35,7 +35,7 @@ import { openBillingCheckout } from "@/services/billingCheckout"; import { useTeamVaultStateStore } from "@/stores/teamVaultStateStore"; import { RoleModal, PERM_META, TeamRolesPanel } from "@/components/settings/sections/RolesSection"; import { seatAvailability } from "@/services/seatMath"; -import { guestCapFor, inviteSessionOf, memberHasAccess, seatUsage } from "@/services/teamSharing"; +import { guestCapFor, inviteSessionOf, memberHasAccess, seatUsage, sessionDisplayName } from "@/services/teamSharing"; import { SeatsMeter } from "@/components/members/SeatsMeter"; import { ROLE_META, RoleToggleChip } from "@/components/members/roleChips"; import { useUserSearch, type UserSearchResult } from "@/hooks/useUserSearch"; @@ -1187,7 +1187,7 @@ export default function MembersPage() { if (!active) return []; const session = inviteSessionOf(c, active); const { atCap } = seatUsage(session, [], guestCapFor(c.vaultOwnerTier ?? tier)); - return [{ localSessionId, connectionName: active.connection_name, session, atCap }]; + return [{ localSessionId, connectionName: sessionDisplayName(active), session, atCap }]; }), [connections, activeSessions, tier]); // Context menu builders diff --git a/src/components/omni/OmniSearch.tsx b/src/components/omni/OmniSearch.tsx index 2e666a132..81fa71b79 100644 --- a/src/components/omni/OmniSearch.tsx +++ b/src/components/omni/OmniSearch.tsx @@ -34,6 +34,7 @@ import { joinTeamSessionAndOpenTab } from "@/services/teamSessionJoin"; import { useToggleSettings } from "@/hooks/useToggleSettings"; import { parseQuickConnect, type QuickConnectIntent } from "@/services/quickConnect"; import { launchHost, launchQuickConnect, launchLocalShell } from "@/services/launch"; +import { sessionDisplayName } from "@/services/teamSharing"; import { isInviteCode, parseInviteCode } from "@/services/inviteCode"; import { computeSectionBoundaries } from "./omniSections"; import { @@ -230,7 +231,7 @@ export default function OmniSearch({ onClose }: OmniSearchProps) { return [{ kind: "join-code", id: "", label: "", icon: "", code: query.trim() }]; } const sessionItems = teamSessions - .filter((s) => !q || s.connection_name.toLowerCase().includes(q)) + .filter((s) => !q || sessionDisplayName(s).toLowerCase().includes(q)) .map((s): OmniItem => ({ kind: "team-session", session: s, alreadyIn: myMpSessionIds.has(s.id) })); return [...sessionItems, { kind: "join-code-prompt", id: "", label: "", icon: "" }]; } @@ -259,7 +260,7 @@ export default function OmniSearch({ onClose }: OmniSearchProps) { // Active team sessions result.push( ...teamSessions - .filter((s) => !q || s.connection_name.toLowerCase().includes(q)) + .filter((s) => !q || sessionDisplayName(s).toLowerCase().includes(q)) .map((s): OmniItem => ({ kind: "team-session", session: s, alreadyIn: myMpSessionIds.has(s.id) })), ); @@ -482,7 +483,7 @@ export default function OmniSearch({ onClose }: OmniSearchProps) { await joinTeamSessionAndOpenTab({ sessionId: session.id, displayName, - connectionName: session.connection_name, + connectionName: sessionDisplayName(session), }); setSidebarOpen(false); })().catch(console.error); @@ -828,7 +829,7 @@ export default function OmniSearch({ onClose }: OmniSearchProps) {
- {session.connection_name} + {sessionDisplayName(session)}
diff --git a/src/plugins/domains/sharing.ts b/src/plugins/domains/sharing.ts index 5ddabc153..ede9b3ad0 100644 --- a/src/plugins/domains/sharing.ts +++ b/src/plugins/domains/sharing.ts @@ -1,6 +1,7 @@ import type { ActiveSession, Participant } from "@/services/multiplayerService"; import type { MultiplayerSessionState } from "@/stores/teamSessionStore"; import type { TeamMember } from "@/services/teamService"; +import { sessionDisplayName } from "@/services/teamSharing"; import { failed, type DomainResult } from "./result"; /** @@ -74,7 +75,7 @@ export async function listSharedSessions(ports: SharingPorts): Promise ({ userId: p.user_id, diff --git a/src/services/multiplayerService.directInvite.test.ts b/src/services/multiplayerService.directInvite.test.ts index 5f1c6b3e3..ab22e3b67 100644 --- a/src/services/multiplayerService.directInvite.test.ts +++ b/src/services/multiplayerService.directInvite.test.ts @@ -8,6 +8,7 @@ const h = vi.hoisted(() => ({ updatePublicKey: vi.fn(), getVaultKey: vi.fn(), freshPublicKeys: vi.fn(), + getUserPublicKey: vi.fn(), })); vi.mock("@tauri-apps/api/core", () => ({ invoke: h.invoke })); vi.mock("@/services/http", () => ({ appFetch: h.appFetch })); @@ -17,6 +18,7 @@ vi.mock("@/services/teamService", () => ({ getServerUrlValue: h.getServerUrlValue, getJwtToken: h.getJwtToken, updatePublicKey: h.updatePublicKey, + getUserPublicKey: h.getUserPublicKey, })); vi.mock("@/services/teamSharing", () => ({ freshPublicKeys: h.freshPublicKeys })); @@ -45,6 +47,12 @@ beforeEach(() => { h.freshPublicKeys.mockImplementation(async (members: { user_id: string; public_key: string }[]) => new Map(members.map((m) => [m.user_id, m.public_key])), ); + h.getUserPublicKey.mockImplementation(async (userId: string) => ({ + user_id: userId, + display_name: userId, + handle: userId, + public_key: `pk-${userId}`, + })); }); test("posts a direct session with one wrapped key per invitee and no vaults", async () => { @@ -78,7 +86,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.freshPublicKeys.mockResolvedValue(new Map([["u3", "fresh-key"]])); + h.getUserPublicKey.mockResolvedValue({ user_id: "u3", display_name: "u3", handle: "u3", public_key: "fresh-key" }); await inviteUserToSession( "sess-1", { user_id: "u3", team_id: "t1", public_key: "stale-key" } as any, @@ -89,3 +97,24 @@ test("wraps a live-session invite to the server's current public key, not the ca expect.objectContaining({ recipientPublicKeyB64: "fresh-key" }), ); }); + +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)), + ).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]); + // No team_id on the invitee, so the batched roster lookup has nothing to fetch. + expect(h.freshPublicKeys).toHaveBeenCalledWith([]); + expect(h.invoke).toHaveBeenCalledWith( + "x25519_wrap_key", + expect.objectContaining({ recipientPublicKeyB64: "stranger-key" }), + ); + expect(JSON.parse(fetchMock.mock.calls[0][1].body).invitees[0].user_id).toBe("stranger"); +}); diff --git a/src/services/multiplayerService.ts b/src/services/multiplayerService.ts index aab390233..aca6595ff 100644 --- a/src/services/multiplayerService.ts +++ b/src/services/multiplayerService.ts @@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import i18n from "@/i18n"; import { getVaultKey } from "@/services/vault"; import * as teamService from "@/services/teamService"; -import { freshPublicKeys } from "@/services/teamSharing"; +import { freshPublicKeys, type InviteTarget } from "@/services/teamSharing"; import { appFetch } from "@/services/http"; import { openXChaCha20Poly1305, sealXChaCha20Poly1305 } from "@/services/crypto/xchacha"; @@ -128,13 +128,24 @@ export async function listActiveSessions(): Promise { return res.json(); } +/** + * A stranger's current public key by id — used when there is no team roster to + * batch through (#unified-invite). Fresh at call time, same as freshPublicKeys: + * wrapping to a stale key fails on the recipient with aead::Error (#66). + */ +async function resolveStrangerPublicKey(userId: string): Promise { + const fresh = await teamService.getUserPublicKey(userId); + if (!fresh) throw new Error(i18n.t("common.error.userNoLongerAvailable")); + return fresh.public_key; +} + /** * Fresh session key plus one wrapped copy per unique member, ready to embed in a * terminal-session create/invite payload. Shared by createVaultSession and * createDirectSession. */ async function prepareWrappedSessionKey( - members: teamService.TeamMember[], + members: InviteTarget[], ): Promise<{ sessionKey: SessionKey; sessionKeyBytes: Uint8Array; wrappedKeys: { user_id: string; wrapped_key: string }[] }> { const { publicKey } = await getMyX25519Keypair(); await teamService.updatePublicKey(publicKey); @@ -147,12 +158,25 @@ async function prepareWrappedSessionKey( new Map(members.map((m) => [m.user_id, m])).values(), ); - const currentKeys = await freshPublicKeys(uniqueMembers); + // 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 strangers = uniqueMembers.filter((m) => !m.team_id); + + const [teamKeys, strangerKeyList] = await Promise.all([ + freshPublicKeys(teammates), + Promise.all(strangers.map((m) => resolveStrangerPublicKey(m.user_id))), + ]); + const strangerKeys = new Map(strangers.map((m, i) => [m.user_id, strangerKeyList[i]])); + const wrappedKeys = await Promise.all( - uniqueMembers.map(async (member) => ({ - user_id: member.user_id, - wrapped_key: await wrapSessionKeyForUser(sessionKeyBytes, currentKeys.get(member.user_id) ?? member.public_key), - })), + uniqueMembers.map(async (member) => { + const publicKey = teamKeys.get(member.user_id) ?? strangerKeys.get(member.user_id); + // Missing only if the server's roster no longer has this teammate — + // treat that the same as an unresolved stranger. + if (!publicKey) throw new Error(i18n.t("common.error.userNoLongerAvailable")); + return { user_id: member.user_id, wrapped_key: await wrapSessionKeyForUser(sessionKeyBytes, publicKey) }; + }), ); return { sessionKey, sessionKeyBytes, wrappedKeys }; @@ -231,14 +255,16 @@ export async function createDirectSession( return { sessionId: session_id, sessionKey, sessionKeyBytes }; } -/** Grant a teammate access to a live session by wrapping the session key for them (#66). */ +/** Grant anyone — teammate or stranger — access to a live session by wrapping the session key for them (#66). */ export async function inviteUserToSession( sessionId: string, - member: teamService.TeamMember, + target: InviteTarget, sessionKeyBytes: Uint8Array, ): Promise { - const currentKeys = await freshPublicKeys([member]); - const wrappedKey = await wrapSessionKeyForUser(sessionKeyBytes, currentKeys.get(member.user_id) ?? member.public_key); + // By user id, not by team roster: a stranger is in none of my teams, so + // freshPublicKeys has nothing to read. Still a fresh read at wrap time — + // wrapping to a cached key fails with aead::Error on the recipient (#66). + const wrappedKey = await wrapSessionKeyForUser(sessionKeyBytes, await resolveStrangerPublicKey(target.user_id)); const serverUrl = await teamService.getServerUrlValue(); if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); @@ -251,7 +277,7 @@ export async function inviteUserToSession( Authorization: `Bearer ${jwt}`, "Content-Type": "application/json", }, - body: JSON.stringify({ user_id: member.user_id, wrapped_key: wrappedKey }), + body: JSON.stringify({ user_id: target.user_id, wrapped_key: wrappedKey }), }); if (!res.ok) throw new Error(i18n.t("common.error.failedToInvite", { status: res.status })); } diff --git a/src/services/teamInbox.ts b/src/services/teamInbox.ts index 3c5f6eead..d650f33b9 100644 --- a/src/services/teamInbox.ts +++ b/src/services/teamInbox.ts @@ -13,6 +13,7 @@ import { getPlatform, isMobileShell } from "@/utils/platform"; import { getMyUserId } from "@/services/teamService"; import type { MyPendingInvitation } from "@/services/teamService"; import type { ActiveSession } from "@/services/multiplayerService"; +import { sessionDisplayName } from "@/services/teamSharing"; const APP_SOURCE = { kind: "app", area: "team" } as const; @@ -86,7 +87,7 @@ async function joinSharedSession(session: ActiveSession): Promise { await joinTeamSessionAndOpenTab({ sessionId: session.id, displayName, - connectionName: session.connection_name, + connectionName: sessionDisplayName(session), }); } @@ -110,12 +111,13 @@ export function reconcileSessions( i18n.t("notifications.inbox.someone")) : ""; const kind: InboxKind = invited ? "sessionInvite" : "sessionShared"; + const name = sessionDisplayName(s); return { id: `session:${s.id}`, kind, message: invited - ? i18n.t("notifications.inbox.sessionInvite.message", { inviter, name: s.connection_name }) - : i18n.t("notifications.inbox.session.message", { name: s.connection_name }), + ? i18n.t("notifications.inbox.sessionInvite.message", { inviter, name }) + : i18n.t("notifications.inbox.session.message", { name }), // Spelled out rather than left undefined: upsertInbox keeps the // previous state when it is omitted, which pinned an entry as // "resolved" — hiding its Join button — after a guest left and the diff --git a/src/services/teamSharing.allTeammates.test.ts b/src/services/teamSharing.allTeammates.test.ts index 648e43bf8..eb0d8f219 100644 --- a/src/services/teamSharing.allTeammates.test.ts +++ b/src/services/teamSharing.allTeammates.test.ts @@ -81,7 +81,8 @@ test("freshPublicKeys returns the server's current key, ignoring a stale public_ // would return after a teammate joined post-cache-fill, #66). freshPublicKeys // must fetch listMembers directly rather than trust the field on the input. api.listMembers.mockResolvedValue([{ ...alice, public_key: "current-key" }]); - const keys = await freshPublicKeys([{ ...alice, public_key: "stale-cached-key" }]); + const staleCachedMember = { ...alice, public_key: "stale-cached-key" }; + const keys = await freshPublicKeys([staleCachedMember]); expect(keys.get(alice.user_id)).toBe("current-key"); expect(api.listMembers).toHaveBeenCalledWith(alice.team_id); }); diff --git a/src/services/teamSharing.displayName.test.ts b/src/services/teamSharing.displayName.test.ts new file mode 100644 index 000000000..80bdc7c9e --- /dev/null +++ b/src/services/teamSharing.displayName.test.ts @@ -0,0 +1,13 @@ +import { test, expect, vi } from "vitest"; + +vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); + +import { sessionDisplayName } from "./teamSharing"; + +test("a redacted session falls back to the generic label", () => { + expect(sessionDisplayName({ connection_name: null })).toBe("hosts.teamSessions.sharedTerminalFallback"); +}); + +test("a visible session shows its own name", () => { + expect(sessionDisplayName({ connection_name: "prod-db" })).toBe("prod-db"); +}); diff --git a/src/services/teamSharing.ts b/src/services/teamSharing.ts index ed8d25588..52b446aea 100644 --- a/src/services/teamSharing.ts +++ b/src/services/teamSharing.ts @@ -1,8 +1,27 @@ +import i18n from "@/i18n"; import { useTeamStore } from "@/stores/teamStore"; import { getMyUserId, listMembers } from "@/services/teamService"; import type { TeamMember } from "@/services/teamService"; import type { Tier } from "@/stores/subscriptionTier"; +/** + * The name to show for a session. `connection_name` is null when the server has + * redacted it — an unaccepted stranger knock leaks a handle, never a hostname — + * so every display site goes through here rather than spelling out a fallback. + */ +export function sessionDisplayName(session: { connection_name: string | null }): string { + return session.connection_name ?? i18n.t("hosts.teamSessions.sharedTerminalFallback"); +} + +/** 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; + /** Present only for teammates; absent for a stranger, who is in none of my teams. */ + team_id?: string; +} + /** Account tier as used across the share flow (ShareMenu, InvitePeopleSection, ParticipantsRatioNotice). */ export type ShareTier = Tier; @@ -40,7 +59,7 @@ export async function membersOfTeams(teamIds: string[]): Promise { * can't unwrap. Used at the point of wrapping by both the direct-invite and * vault-share paths; the cached roster remains fine for display. */ -export async function freshPublicKeys(members: TeamMember[]): Promise> { +export async function freshPublicKeys(members: { team_id: string; user_id: string }[]): Promise> { const teamIds = [...new Set(members.map((m) => m.team_id))]; const fresh = (await Promise.all(teamIds.map((id) => listMembers(id)))).flat(); return new Map(fresh.map((m) => [m.user_id, m.public_key])); From f99f6bb1a4fd91866a23d74a0ee5bbdbc4d43aad Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 03:50:06 +0000 Subject: [PATCH 04/24] fix(invites): distinguish a 404 no-such-user from a transport failure in getUserPublicKey --- src/services/teamService.invites.test.ts | 5 +++++ src/services/teamService.ts | 12 +++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/services/teamService.invites.test.ts b/src/services/teamService.invites.test.ts index 75b8c21f3..cc964d049 100644 --- a/src/services/teamService.invites.test.ts +++ b/src/services/teamService.invites.test.ts @@ -37,3 +37,8 @@ test("a 404 from the key lookup resolves to null so Recent can self-heal", async h.appFetch.mockResolvedValue({ ok: false, status: 404 }); expect(await getUserPublicKey("gone")).toBeNull(); }); + +test("a 500 from the key lookup throws instead of masquerading as a missing user", async () => { + h.appFetch.mockResolvedValue({ ok: false, status: 500 }); + await expect(getUserPublicKey("u1")).rejects.toThrow("common.error.failedToFetchPublicKey"); +}); diff --git a/src/services/teamService.ts b/src/services/teamService.ts index 45649bf4c..62bd68357 100644 --- a/src/services/teamService.ts +++ b/src/services/teamService.ts @@ -270,12 +270,18 @@ export interface UserKeyLookup { public_key: string; } -/** null when the user no longer resolves — the caller drops the stale row. */ +/** + * `null` only for a genuine 404 — the user no longer resolves and the caller + * drops the stale row. Any other failure (5xx, 401, rate limit, no server url) + * throws instead: collapsing those into `null` would tell an invite flow "this + * person is gone" on what was really a transient network blip. + */ export async function getUserPublicKey(userId: string): Promise { const serverUrl = await getServerUrl(); - if (!serverUrl) return null; + if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); const res = await fetchAuth(`${serverUrl}/v1/users/${userId}/public-key`); - if (!res.ok) return null; + if (res.status === 404) return null; + if (!res.ok) throw new Error(i18n.t("common.error.failedToFetchPublicKey", { status: res.status })); return res.json(); } From 91c6be19810aea026bbc1c0675fdee994e87c615 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 03:56:49 +0000 Subject: [PATCH 05/24] feat(invites): remember recently invited people in the encrypted user-data blob --- .../user-data/handlers/recentPeople.ts | 32 +++++++++++ src/services/user-data/registry.ts | 2 + src/stores/recentPeopleStore.test.ts | 38 +++++++++++++ src/stores/recentPeopleStore.ts | 57 +++++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 src/services/user-data/handlers/recentPeople.ts create mode 100644 src/stores/recentPeopleStore.test.ts create mode 100644 src/stores/recentPeopleStore.ts diff --git a/src/services/user-data/handlers/recentPeople.ts b/src/services/user-data/handlers/recentPeople.ts new file mode 100644 index 000000000..2055c123c --- /dev/null +++ b/src/services/user-data/handlers/recentPeople.ts @@ -0,0 +1,32 @@ +import i18n from "@/i18n"; +import { useRecentPeopleStore, type RecentPerson } from "@/stores/recentPeopleStore"; +import { lastWriteWins, type UserDataHandler } from "../handler"; + +export const recentPeopleHandler: UserDataHandler = { + key: "recentPeople", + label: "Recent People", + icon: "lucide:users-round", + + export(): RecentPerson[] { + return useRecentPeopleStore.getState().recent; + }, + + async import(data: unknown): Promise { + useRecentPeopleStore.getState().replaceAll((data as RecentPerson[]) ?? []); + }, + + // LWW, like every other section: two devices inviting simultaneously means the + // later list wins whole. A union merge would need per-row timestamps the sync + // blob does not carry, for a list that self-heals through use. + merge: lastWriteWins, + + getTimestamp(): string { + return useRecentPeopleStore.getState().recentUpdatedAt; + }, + + describe(): string { + return i18n.t("importExport.userData.describe.recentPeople", { + count: useRecentPeopleStore.getState().recent.length, + }); + }, +}; diff --git a/src/services/user-data/registry.ts b/src/services/user-data/registry.ts index d0beccd04..bca551fd8 100644 --- a/src/services/user-data/registry.ts +++ b/src/services/user-data/registry.ts @@ -5,6 +5,7 @@ import { themesHandler } from "./handlers/themes"; import { uiPreferencesHandler } from "./handlers/uiPreferences"; import { shortcutsHandler } from "./handlers/shortcuts"; import { appSettingsHandler } from "./handlers/appSettings"; +import { recentPeopleHandler } from "./handlers/recentPeople"; // ─── Handler registry ───────────────────────────────────────────────────────── // Order matters for UI rendering. Adding a new settings domain: @@ -16,6 +17,7 @@ export const USER_DATA_HANDLERS: UserDataHandler[] = [ uiPreferencesHandler, shortcutsHandler, appSettingsHandler, + recentPeopleHandler, ]; // ─── Build ──────────────────────────────────────────────────────────────────── diff --git a/src/stores/recentPeopleStore.test.ts b/src/stores/recentPeopleStore.test.ts new file mode 100644 index 000000000..5d2406ff4 --- /dev/null +++ b/src/stores/recentPeopleStore.test.ts @@ -0,0 +1,38 @@ +import { test, expect, beforeEach } from "vitest"; +import { useRecentPeopleStore, MAX_RECENT } from "./recentPeopleStore"; + +const person = (id: string, at = "2026-08-15T00:00:00.000Z") => ({ + user_id: id, handle: `h-${id}`, display_name: id, last_invited_at: at, +}); + +beforeEach(() => useRecentPeopleStore.setState({ recent: [], recentUpdatedAt: new Date(0).toISOString() })); + +test("remember puts the newest first and dedupes by user_id", () => { + const s = useRecentPeopleStore.getState(); + s.remember(person("a", "2026-08-15T00:00:00.000Z")); + s.remember(person("b", "2026-08-15T00:01:00.000Z")); + s.remember(person("a", "2026-08-15T00:02:00.000Z")); + const { recent } = useRecentPeopleStore.getState(); + expect(recent.map((p) => p.user_id)).toEqual(["a", "b"]); + expect(recent[0].last_invited_at).toBe("2026-08-15T00:02:00.000Z"); +}); + +test("the list is capped", () => { + const s = useRecentPeopleStore.getState(); + for (let i = 0; i < MAX_RECENT + 5; i++) s.remember(person(`u${i}`, `2026-08-15T00:${String(i).padStart(2, "0")}:00.000Z`)); + expect(useRecentPeopleStore.getState().recent.length).toBe(MAX_RECENT); +}); + +test("forget removes one row and stamps the timestamp", () => { + const s = useRecentPeopleStore.getState(); + s.remember(person("a")); + const before = useRecentPeopleStore.getState().recentUpdatedAt; + useRecentPeopleStore.getState().forget("a"); + expect(useRecentPeopleStore.getState().recent).toEqual([]); + expect(useRecentPeopleStore.getState().recentUpdatedAt >= before).toBe(true); +}); + +test("no key material is ever stored", () => { + useRecentPeopleStore.getState().remember({ ...person("a"), public_key: "leak" } as never); + expect(JSON.stringify(useRecentPeopleStore.getState().recent)).not.toContain("leak"); +}); diff --git a/src/stores/recentPeopleStore.ts b/src/stores/recentPeopleStore.ts new file mode 100644 index 000000000..d606aa749 --- /dev/null +++ b/src/stores/recentPeopleStore.ts @@ -0,0 +1,57 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { pushSettingsChange, settingsStamp } from "./remoteApplyGuard"; + +export const MAX_RECENT = 20; + +/** + * A person worth one tap next time. No `public_key`: key material is read fresh + * at wrap time, and a key sitting in a synced blob is a trap, not a shortcut. + */ +export interface RecentPerson { + user_id: string; + handle: string; + display_name: string; + last_invited_at: string; +} + +interface RecentPeopleStore { + recent: RecentPerson[]; + recentUpdatedAt: string; + remember: (person: RecentPerson) => void; + forget: (userId: string) => void; + replaceAll: (list: RecentPerson[]) => void; +} + +export const useRecentPeopleStore = create()( + persist( + (set) => ({ + recent: [], + recentUpdatedAt: new Date(0).toISOString(), + + remember: (person) => + set((s) => { + const clean: RecentPerson = { + user_id: person.user_id, + handle: person.handle, + display_name: person.display_name, + last_invited_at: person.last_invited_at, + }; + const recent = [clean, ...s.recent.filter((p) => p.user_id !== clean.user_id)].slice(0, MAX_RECENT); + const recentUpdatedAt = settingsStamp(); + pushSettingsChange(); + return { recent, recentUpdatedAt }; + }), + + forget: (userId) => + set((s) => { + const recentUpdatedAt = settingsStamp(); + pushSettingsChange(); + return { recent: s.recent.filter((p) => p.user_id !== userId), recentUpdatedAt }; + }), + + replaceAll: (list) => set({ recent: list.slice(0, MAX_RECENT) }), + }), + { name: "voltius-recent-people" }, + ), +); From 63372ad42d3ad0378025ecd3268e0f135376e183 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 04:08:48 +0000 Subject: [PATCH 06/24] feat(share): add the People tab with grouped results and the resolution-rule empty state --- src/components/terminal/PeopleTab.test.tsx | 77 ++++++ src/components/terminal/PeopleTab.tsx | 270 +++++++++++++++++++++ src/services/teamSharing.grouping.test.ts | 27 +++ src/services/teamSharing.ts | 33 ++- 4 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 src/components/terminal/PeopleTab.test.tsx create mode 100644 src/components/terminal/PeopleTab.tsx create mode 100644 src/services/teamSharing.grouping.test.ts diff --git a/src/components/terminal/PeopleTab.test.tsx b/src/components/terminal/PeopleTab.test.tsx new file mode 100644 index 000000000..83920b287 --- /dev/null +++ b/src/components/terminal/PeopleTab.test.tsx @@ -0,0 +1,77 @@ +import { test, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); +vi.mock("@iconify/react", () => ({ Icon: () => null })); + +const h = vi.hoisted(() => ({ allTeammates: vi.fn(), searchUsers: vi.fn() })); +vi.mock("@/services/teamSharing", async () => { + const actual = await vi.importActual("@/services/teamSharing"); + return { ...actual, allTeammates: h.allTeammates }; +}); +vi.mock("@/services/teamService", async () => { + const actual = await vi.importActual("@/services/teamService"); + return { ...actual, searchUsers: h.searchUsers }; +}); + +import { PeopleTab } from "./PeopleTab"; +import { useRecentPeopleStore } from "@/stores/recentPeopleStore"; +import { useTeamStore } from "@/stores/teamStore"; + +const base = { + session: { vaultIds: [], participantIds: [], invitedIds: [] }, + invitedThisSession: new Set(), + guestCap: 10, + tier: "teams" as const, + onUpgrade: vi.fn(), + onInvite: vi.fn().mockResolvedValue(undefined), +}; + +beforeEach(() => { + h.allTeammates.mockReset().mockResolvedValue([]); + h.searchUsers.mockReset().mockResolvedValue([]); + useTeamStore.setState({ teams: [] }); + useRecentPeopleStore.setState({ recent: [], recentUpdatedAt: "" }); +}); +afterEach(() => cleanup()); + +test("teaches the resolution rule when nothing matches", async () => { + h.allTeammates.mockResolvedValue([]); + h.searchUsers.mockResolvedValue([]); + render(); + await userEvent.type(screen.getByRole("textbox"), "kev"); + expect(await screen.findByText("terminal.share.peopleNoMatch")).toBeTruthy(); + expect(screen.getByText("terminal.share.peopleFindRule")).toBeTruthy(); +}); + +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 }]); + render(); + await userEvent.type(screen.getByRole("textbox"), "sam-q"); + const row = await screen.findByRole("button", { name: /sam/i }); + expect(within(row).getByText("terminal.share.notInYourTeams")).toBeTruthy(); + expect(within(row).getByText("@sam-q")).toBeTruthy(); +}); + +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: "" }); + const onInvite = vi.fn(); + render(); + const row = await screen.findByRole("button", { name: /kevin/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 }]); + 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")); +}); diff --git a/src/components/terminal/PeopleTab.tsx b/src/components/terminal/PeopleTab.tsx new file mode 100644 index 000000000..1d313cb8a --- /dev/null +++ b/src/components/terminal/PeopleTab.tsx @@ -0,0 +1,270 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Icon } from "@iconify/react"; +import { useTeamStore } from "@/stores/teamStore"; +import { + allTeammates, + groupPeople, + memberHasAccess, + seatUsage, + type InviteSession, + type InviteTarget, + type ShareTier, + type Teammate, +} from "@/services/teamSharing"; +import { useUserSearch } from "@/hooks/useUserSearch"; +import { useRecentPeopleStore } from "@/stores/recentPeopleStore"; +import { ParticipantsRatioNotice } from "./ParticipantsRatioNotice"; +import { ContextMenu } from "@/components/shared/ContextMenu"; + +interface PeopleTabProps { + session: InviteSession; + /** Owned by ShareMenu — see InvitePeopleSection for why this outlives a single mount. */ + invitedThisSession: ReadonlySet; + guestCap: number; + tier: ShareTier; + onUpgrade: () => void; + onInvite: (target: InviteTarget) => Promise; +} + +/** A normalized row: whichever group it came from, the row itself doesn't care. */ +interface RowEntry { + target: InviteTarget; + /** Teammate group memberships, for `memberHasAccess`. Empty for Recent/stranger rows. */ + teamIds: string[]; + isStranger: boolean; + onContextMenu?: (e: React.MouseEvent) => void; +} + +function PersonRow({ + entry, + hasAccess, + inFlight, + invited, + capBlocked, + onInvite, + t, +}: { + entry: RowEntry; + hasAccess: boolean; + inFlight: boolean; + invited: boolean; + capBlocked: boolean; + onInvite: () => void; + t: (key: string, opts?: Record) => string; +}) { + const { target, isStranger, onContextMenu } = entry; + return ( + + ); +} + +export function PeopleTab({ session, invitedThisSession, guestCap, tier, onUpgrade, onInvite }: PeopleTabProps) { + const { t } = useTranslation(); + const teams = useTeamStore((s) => s.teams); + const recent = useRecentPeopleStore((s) => s.recent); + const forget = useRecentPeopleStore((s) => s.forget); + const search = useUserSearch(); + + const [teammates, setTeammates] = useState([]); + const [inviting, setInviting] = useState>(new Set()); + const [error, setError] = useState(null); + const [menu, setMenu] = useState<{ userId: string; pos: { x: number; y: number } } | null>(null); + + // Reload whenever the team list changes — ShareMenu kicks off `loadTeams()` fire-and-forget + // on open, so on a fresh install/first sign-in the roster isn't populated yet at mount. + useEffect(() => { + let cancelled = false; + allTeammates().then((m) => { if (!cancelled) setTeammates(m); }).catch(() => { if (!cancelled) setTeammates([]); }); + return () => { cancelled = true; }; + }, [teams]); + + const { committedSeats, atCap } = seatUsage(session, invitedThisSession, guestCap); + + const setInFlight = (userId: string, active: boolean) => + setInviting((prev) => { + const next = new Set(prev); + if (active) next.add(userId); else next.delete(userId); + return next; + }); + + const handleInvite = async (target: InviteTarget) => { + setError(null); + setInFlight(target.user_id, true); + try { + await onInvite(target); + // Recent is written on a successful invite — the signal is "I chose this person", + // not that they later accepted. + useRecentPeopleStore.getState().remember({ + user_id: target.user_id, + handle: target.handle ?? "", + display_name: target.display_name, + last_invited_at: new Date().toISOString(), + }); + } catch { + setError(t("terminal.share.inviteFailed", { name: target.display_name })); + } finally { + setInFlight(target.user_id, false); + } + }; + + const groups = groupPeople({ query: search.query, teammates, recent, results: search.results }); + + const recentEntries: RowEntry[] = groups.recent.map((p) => ({ + target: { user_id: p.user_id, display_name: p.display_name, handle: p.handle }, + teamIds: [], + isStranger: false, + onContextMenu: (e) => { e.preventDefault(); setMenu({ userId: p.user_id, pos: { x: e.clientX, y: e.clientY } }); }, + })); + const teammateEntries: RowEntry[] = groups.teammates.map((m) => ({ + target: { user_id: m.user_id, display_name: m.display_name, handle: m.handle, team_id: m.teamIds[0] }, + teamIds: m.teamIds, + isStranger: false, + })); + const strangerEntries: RowEntry[] = groups.strangers.map((s) => ({ + target: { user_id: s.user_id, display_name: s.display_name, handle: s.handle }, + teamIds: [], + isStranger: true, + })); + + const renderRow = (entry: RowEntry) => { + const hasAccess = memberHasAccess({ user_id: entry.target.user_id, teamIds: entry.teamIds }, session); + const inFlight = inviting.has(entry.target.user_id); + const invited = invitedThisSession.has(entry.target.user_id); + // A row this session just invited keeps showing "Invited", not the cap notice. + const capBlocked = atCap && !hasAccess && !invited; + return ( + handleInvite(entry.target)} + t={t} + /> + ); + }; + + const searchedEmpty = + search.query.trim().length > 0 && recentEntries.length === 0 && teammateEntries.length === 0 && strangerEntries.length === 0; + + return ( +
+
+ + search.setQuery(e.target.value)} + className="flex-1 bg-transparent outline-hidden text-sm" + style={{ color: "var(--t-text-primary)" }} + /> +
+ + + + {error && ( +
+ {error} +
+ )} + + {searchedEmpty ? ( +
+

{t("terminal.share.peopleNoMatch", { query: search.query.trim() })}

+

{t("terminal.share.peopleFindRule")}

+
+ ) : ( +
+
+

+ {t("terminal.share.recentLabel")} +

+ {recentEntries.length > 0 ? ( +
{recentEntries.map(renderRow)}
+ ) : ( +

+ {t("terminal.share.recentEmpty")} +

+ )} +
+ + {teammateEntries.length > 0 && ( +
+

+ {t("terminal.share.yourTeamsLabel")} +

+
{teammateEntries.map(renderRow)}
+
+ )} + + {strangerEntries.length > 0 && ( +
+

+ {t("terminal.share.elsewhereLabel")} +

+
{strangerEntries.map(renderRow)}
+
+ )} +
+ )} + + {menu && ( + setMenu(null)} + items={[{ label: t("terminal.share.forgetPerson"), danger: true, onClick: () => forget(menu.userId) }]} + /> + )} +
+ ); +} diff --git a/src/services/teamSharing.grouping.test.ts b/src/services/teamSharing.grouping.test.ts new file mode 100644 index 000000000..3e3faab9e --- /dev/null +++ b/src/services/teamSharing.grouping.test.ts @@ -0,0 +1,27 @@ +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 }; + +test("with no query, recent and teammates show and strangers do not", () => { + const g = groupPeople({ query: "", teammates: [mate], recent: [recent], results: [] }); + expect(g.recent.map((p) => p.user_id)).toEqual(["r1"]); + expect(g.teammates.map((p) => p.user_id)).toEqual(["m1"]); + expect(g.strangers).toEqual([]); +}); + +test("typing filters recent and teammates locally and adds the stranger group", () => { + const g = groupPeople({ query: "sam", teammates: [mate], recent: [recent], results: [strangerHit, mateHit] }); + expect(g.recent).toEqual([]); + expect(g.teammates).toEqual([]); + expect(g.strangers.map((p) => p.user_id)).toEqual(["s1"]); +}); + +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] }); + expect(g.teammates.map((p) => p.user_id)).toEqual(["m1"]); + expect(g.strangers).toEqual([]); +}); diff --git a/src/services/teamSharing.ts b/src/services/teamSharing.ts index 52b446aea..28d2f71bb 100644 --- a/src/services/teamSharing.ts +++ b/src/services/teamSharing.ts @@ -1,8 +1,9 @@ import i18n from "@/i18n"; import { useTeamStore } from "@/stores/teamStore"; import { getMyUserId, listMembers } from "@/services/teamService"; -import type { TeamMember } from "@/services/teamService"; +import type { TeamMember, UserSearchResult } from "@/services/teamService"; import type { Tier } from "@/stores/subscriptionTier"; +import type { RecentPerson } from "@/stores/recentPeopleStore"; /** * The name to show for a session. `connection_name` is null when the server has @@ -65,8 +66,12 @@ export async function freshPublicKeys(members: { team_id: string; user_id: strin return new Map(fresh.map((m) => [m.user_id, m.public_key])); } -/** A teammate merged across every team they share with the caller. */ -export type Teammate = TeamMember & { teamIds: string[] }; +/** + * A teammate merged across every team they share with the caller. `handle` is + * optional: the member roster does not carry it today, so it is left undefined + * rather than adding it to `/members` in this pass. + */ +export type Teammate = TeamMember & { teamIds: string[]; handle?: string }; /** * Every teammate across all of the caller's teams, merged by user_id (with the @@ -143,3 +148,25 @@ export function memberHasAccess( session.invitedIds.includes(member.user_id) ); } + +/** + * Groups are labels, not modes: typing filters Recent and Your teams locally and + * adds Elsewhere on Voltius from the server's results. A person is listed once — + * the most specific group wins. + */ +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 recent = input.recent.filter((p) => matches(p.display_name, p.handle)); + const teammates = input.teammates.filter((p) => matches(p.display_name, p.handle)); + const claimed = new Set([...recent, ...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 }; +} From ac1216998af613bb22a7cc8ca16b84c43d1fa149 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 04:19:25 +0000 Subject: [PATCH 07/24] fix(share): drop the dangling handle line and dedupe Recent teammates in the People tab --- src/components/terminal/PeopleTab.test.tsx | 17 +++++++++++++++++ src/components/terminal/PeopleTab.tsx | 20 ++++++++++++++++---- src/services/teamSharing.grouping.test.ts | 7 +++++++ src/services/teamSharing.ts | 7 +++++-- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/components/terminal/PeopleTab.test.tsx b/src/components/terminal/PeopleTab.test.tsx index 83920b287..e8f2b1940 100644 --- a/src/components/terminal/PeopleTab.test.tsx +++ b/src/components/terminal/PeopleTab.test.tsx @@ -75,3 +75,20 @@ test("inviting remembers the person", async () => { await userEvent.click(await screen.findByRole("button", { name: /sam/i })); await waitFor(() => expect(useRecentPeopleStore.getState().recent[0].user_id).toBe("s1")); }); + +// The member roster carries no handle today (server follow-up), 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"] }]); + render(); + await screen.findByRole("button", { name: /alice/i }); + expect(screen.getByText("terminal.share.recentEmpty")).toBeTruthy(); +}); diff --git a/src/components/terminal/PeopleTab.tsx b/src/components/terminal/PeopleTab.tsx index 1d313cb8a..46b64143f 100644 --- a/src/components/terminal/PeopleTab.tsx +++ b/src/components/terminal/PeopleTab.tsx @@ -33,6 +33,8 @@ interface RowEntry { /** Teammate group memberships, for `memberHasAccess`. Empty for Recent/stranger rows. */ teamIds: string[]; isStranger: boolean; + /** Only teammates carry live presence; undefined omits the dot entirely. */ + isOnline?: boolean; onContextMenu?: (e: React.MouseEvent) => void; } @@ -53,7 +55,7 @@ function PersonRow({ onInvite: () => void; t: (key: string, opts?: Record) => string; }) { - const { target, isStranger, onContextMenu } = entry; + const { target, isStranger, isOnline, onContextMenu } = entry; return ( - ); - })} -
- )} - - ); -} diff --git a/src/components/terminal/PeopleTab.test.tsx b/src/components/terminal/PeopleTab.test.tsx index e8f2b1940..3edf5ef36 100644 --- a/src/components/terminal/PeopleTab.test.tsx +++ b/src/components/terminal/PeopleTab.test.tsx @@ -1,5 +1,6 @@ import { test, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup, waitFor, within } from "@testing-library/react"; +import { useState } from "react"; +import { render, screen, cleanup, waitFor, within, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; vi.mock("react-i18next", () => ({ @@ -76,8 +77,8 @@ test("inviting remembers the person", async () => { await waitFor(() => expect(useRecentPeopleStore.getState().recent[0].user_id).toBe("s1")); }); -// The member roster carries no handle today (server follow-up), so a teammate row -// must never render a dangling "@" with nothing after it — it shipped once already. +// 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(); @@ -92,3 +93,159 @@ test("Recent's own empty state stands alone even while Your teams has results", await screen.findByRole("button", { name: /alice/i }); expect(screen.getByText("terminal.share.recentEmpty")).toBeTruthy(); }); + +// ─── Moved from InvitePeopleSection.test.tsx (that component was deleted; PeopleTab +// 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"] }, +]; + +type TabProps = Parameters[0]; + +/** + * Mirrors ShareMenu's ownership of `invitedThisSession`: it lives in the parent + * because it has to outlive any single PeopleTab instance (the first invite on + * an unshared terminal flips the setup view to the active view, remounting it). + */ +function Harness({ onInvite, ...props }: Omit) { + const [invited, setInvited] = useState>(new Set()); + const handleInvite = async (target: Parameters[0]) => { + await onInvite(target); + setInvited((prev) => new Set(prev).add(target.user_id)); + }; + return ; +} + +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"] }]); + render(); + const row = await screen.findByRole("button", { name: /alice/i }); + expect(within(row).getByText("@alice-h")).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; + expect(row.disabled).toBe(true); + expect(within(row).getByText("terminal.share.inviteHasAccess")).toBeTruthy(); + await userEvent.click(row); + expect(onInvite).not.toHaveBeenCalled(); +}); + +test("disables the row while an invite is in flight and shows Invited after", async () => { + h.allTeammates.mockResolvedValue(roster); + let resolve: () => void; + const onInvite = vi.fn(() => new Promise((r) => { resolve = r; })); + render(); + const row = (await screen.findByRole("button", { name: /alice/i })) as HTMLButtonElement; + await userEvent.click(row); + expect(row.disabled).toBe(true); + await userEvent.click(row); + expect(onInvite).toHaveBeenCalledTimes(1); + resolve!(); + expect(await screen.findByText("terminal.share.inviteSent")).toBeTruthy(); +}); + +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; + await userEvent.click(row); + expect(await screen.findByText("terminal.share.inviteFailed")).toBeTruthy(); + expect(row.disabled).toBe(false); +}); + +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 }); + expect(h.allTeammates).toHaveBeenCalledTimes(1); + + act(() => { + useTeamStore.setState({ teams: [{ id: "t1", name: "Team", owner_tier: "teams" } as never] }); + }); + + await waitFor(() => expect(h.allTeammates).toHaveBeenCalledTimes(2)); +}); + +// ─── Guest cap (#66 follow-up: the cap was invisible in the direct-invite roster) ── + +test("a Pro host at cap 1 with one participant disables every not-already-covered row and shows the cap notice", async () => { + h.allTeammates.mockResolvedValue(roster); + const onInvite = vi.fn(); + render( + , + ); + const alice = (await screen.findByRole("button", { name: /alice/i })) as HTMLButtonElement; + const bob = (await screen.findByRole("button", { name: /bob/i })) as HTMLButtonElement; + expect(alice.disabled).toBe(true); + expect(bob.disabled).toBe(true); + expect(screen.getAllByText("terminal.share.inviteCapReached").length).toBe(2); + expect(screen.getByText("terminal.share.guestsRatio")).toBeTruthy(); + expect(screen.queryByText("terminal.share.participantsRatio")).toBeNull(); + + await userEvent.click(alice); + expect(onInvite).not.toHaveBeenCalled(); +}); + +test("a Teams host at cap 10 with two participants leaves rows tappable", async () => { + h.allTeammates.mockResolvedValue(roster); + render( + , + ); + const alice = (await screen.findByRole("button", { name: /alice/i })) as HTMLButtonElement; + expect(alice.disabled).toBe(false); + expect(screen.queryByText("terminal.share.inviteCapReached")).toBeNull(); +}); + +test("a teammate in both participantIds and invitedIds counts once, not twice", async () => { + h.allTeammates.mockResolvedValue(roster); + render( + , + ); + // 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; + expect(alice.disabled).toBe(false); +}); + +test("after inviting one teammate at cap 1 with no participants, the remaining rows go non-tappable", async () => { + 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; + expect(bob.disabled).toBe(false); + + await userEvent.click(alice); + await screen.findByText("terminal.share.inviteSent"); + + expect(bob.disabled).toBe(true); + expect(screen.getByText("terminal.share.inviteCapReached")).toBeTruthy(); +}); + +test("an already-invited row cannot be tapped a second time", async () => { + // Cap high enough that the cap guard is not what blocks the row — at cap 1 the + // invited row is deliberately exempt from `capBlocked`, which is exactly why it + // needs its own guard. + h.allTeammates.mockResolvedValue(roster); + const onInvite = vi.fn().mockResolvedValue(undefined); + render(); + const alice = (await screen.findByRole("button", { name: /alice/i })) as HTMLButtonElement; + await userEvent.click(alice); + await screen.findByText("terminal.share.inviteSent"); + + expect(alice.disabled).toBe(true); + await userEvent.click(alice); + expect(onInvite).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/terminal/PeopleTab.tsx b/src/components/terminal/PeopleTab.tsx index 46b64143f..c2651c4b5 100644 --- a/src/components/terminal/PeopleTab.tsx +++ b/src/components/terminal/PeopleTab.tsx @@ -19,7 +19,11 @@ import { ContextMenu } from "@/components/shared/ContextMenu"; interface PeopleTabProps { session: InviteSession; - /** Owned by ShareMenu — see InvitePeopleSection for why this outlives a single mount. */ + /** + * Owned by ShareMenu, not here: the first invite on an unshared terminal creates + * the session, which flips the setup view to the active view and remounts this + * component. Local state would be lost exactly when the cap most needs it. + */ invitedThisSession: ReadonlySet; guestCap: number; tier: ShareTier; @@ -72,7 +76,7 @@ function PersonRow({ )} {target.display_name} - {/* handle is optional: teammates carry none until the server adds it to /members */} + {/* handle is optional: an older server omits it from /members */} {target.handle && ( @{target.handle} diff --git a/src/components/terminal/ShareMenu.invitePeople.test.tsx b/src/components/terminal/ShareMenu.invitePeople.test.tsx index 2c164572a..79a714595 100644 --- a/src/components/terminal/ShareMenu.invitePeople.test.tsx +++ b/src/components/terminal/ShareMenu.invitePeople.test.tsx @@ -4,13 +4,11 @@ import userEvent from "@testing-library/user-event"; import { createRef } from "react"; /** - * `InvitePeopleSection` renders null until its `allTeammates()` roster promise - * resolves, so a synchronous `queryByText(...)).toBeNull()` right after `render` - * passes trivially — with or without a hiding fix — because the section hasn't - * rendered its content yet either way. Flush the already-resolved mock promise's - * microtasks (and the resulting effect/state-update) before asserting absence, - * so a regression that lets the section mount would actually have painted by - * the time we check. + * `PeopleTab` renders its search box synchronously, but its `allTeammates()` + * roster promise resolving is what could reveal a regression (e.g. a row that + * shouldn't be there). Flush the already-resolved mock promise's microtasks + * (and the resulting effect/state-update) before asserting, so a regression + * that lets the tab mount would actually have painted by the time we check. */ async function flushRoster() { await act(async () => { @@ -137,11 +135,11 @@ test("an already-invited teammate renders as non-tappable Has access", async () expect(row.textContent).toContain("terminal.share.inviteHasAccess"); }); -test("hides the invite section in the active view when no session key is retained (invite_link)", async () => { +test("hides the People tab's content in the active view when no session key is retained (invite_link)", async () => { mpState.connections = hostConnection({ sessionKeyBytes: undefined }); render(shareMenuElement()); await flushRoster(); - expect(screen.queryByText("terminal.share.invitePeople")).toBeNull(); + expect(screen.queryByPlaceholderText("terminal.share.peopleSearchPlaceholder")).toBeNull(); }); test("the active view shows exactly one seats-vs-cap line, with and without a retained session key", async () => { @@ -159,7 +157,7 @@ test("the active view shows exactly one seats-vs-cap line, with and without a re expect(ratioLines().length).toBe(1); }); -// ─── Guest cap wired through both InvitePeopleSection render sites (#66 follow-up) ── +// ─── Guest cap wired through both PeopleTab render sites (#66 follow-up) ── test("setup view: a Pro host (cap 1) cannot tap a second teammate after the first invite lands", async () => { // Needs two teammates so there's a "remaining" row left to prove is now blocked. @@ -169,8 +167,8 @@ test("setup view: a Pro host (cap 1) cannot tap a second teammate after the firs ]); // The real startSharingDirect creates the session and writes `connections`, which // flips ShareMenu from the setup branch to ActiveSharingView — a *different* - // InvitePeopleSection instance. `invitee_ids` stays empty on purpose: the server - // list round-trip that fills it is fire-and-forget and has not landed yet. + // PeopleTab instance. `invitee_ids` stays empty on purpose: the server list + // round-trip that fills it is fire-and-forget and has not landed yet. startSharingDirect.mockImplementation(async () => { mpState.connections = hostConnection(); mpState.activeSessions = [{ id: "mp-1", invitee_ids: [] }]; @@ -199,9 +197,10 @@ test("active view: a Pro host (cap 1) already at cap shows the remaining rows as expect(screen.getByText("terminal.share.inviteCapReached")).toBeTruthy(); }); -test("hides the invite section in setup view for free tier", async () => { +test("hides the People tab (and its own tab button) in setup view for free tier", async () => { teamState.teams = [{ id: "vault-1", name: "Vault", owner_id: "u0", owner_tier: "teams", created_at: "", role_ids: [] }]; render(shareMenuElement({ tier: "free", connectionVaultId: "vault-1" })); await flushRoster(); - expect(screen.queryByText("terminal.share.invitePeople")).toBeNull(); + expect(screen.queryByText("terminal.share.tabPeople")).toBeNull(); + expect(screen.queryByPlaceholderText("terminal.share.peopleSearchPlaceholder")).toBeNull(); }); diff --git a/src/components/terminal/ShareMenu.test.tsx b/src/components/terminal/ShareMenu.test.tsx index f09eb30a1..9274df6a0 100644 --- a/src/components/terminal/ShareMenu.test.tsx +++ b/src/components/terminal/ShareMenu.test.tsx @@ -58,8 +58,8 @@ afterEach(() => cleanup()); function renderMenu() { const anchorRef = createRef(); - // tier="pro" with a personal (non-qualifying) vault means the invite-link tab - // is the only tab, so it renders directly without a tab click. + // tier="pro" with a personal (non-qualifying) vault means People and Link are + // the only tabs (no qualifying vault for Team), with People selected by default. return render( expect(mpState.startSharingInviteLink).toHaveBeenCalled()); } diff --git a/src/components/terminal/ShareMenu.tsx b/src/components/terminal/ShareMenu.tsx index 3c94b2342..59f824760 100644 --- a/src/components/terminal/ShareMenu.tsx +++ b/src/components/terminal/ShareMenu.tsx @@ -6,12 +6,14 @@ import { Icon } from "@iconify/react"; import { useTeamStore } from "@/stores/teamStore"; import { useTeamSessionStore } from "@/stores/teamSessionStore"; import { buildInviteCode } from "@/services/inviteCode"; -import { guestCapFor, highestOwnerTier, inviteSessionOf, membersOfTeams, seatUsage, type InviteSession, type ShareTier } from "@/services/teamSharing"; -import type { TeamMember } from "@/services/teamService"; +import { guestCapFor, highestOwnerTier, inviteSessionOf, membersOfTeams, seatUsage, type InviteSession, type InviteTarget, type ShareTier } from "@/services/teamSharing"; +import { useDelayedUnmount } from "@/hooks/useDelayedUnmount"; import { InviteCodeField } from "./InviteCodeField"; -import { InvitePeopleSection } from "./InvitePeopleSection"; +import { PeopleTab } from "./PeopleTab"; import { ParticipantsRatioNotice } from "./ParticipantsRatioNotice"; +const EXIT_MS = 140; + const ROLES = ["owner", "manager", "editor", "member"] as const; interface ShareMenuProps { @@ -30,8 +32,9 @@ interface ShareMenuProps { export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectionName, connectionVaultId, isLoggedIn, tier, onSignIn, onUpgrade }: ShareMenuProps) { const { t } = useTranslation(); const menuRef = useRef(null); - const [pos, setPos] = useState({ top: 0, left: 0 }); - const [tab, setTab] = useState<"team" | "invite">("team"); + const mounted = useDelayedUnmount(open, EXIT_MS); + const [pos, setPos] = useState({ top: 0, left: 0, originX: 140 }); + const [tab, setTab] = useState<"people" | "invite" | "team">("people"); const [sessionName, setSessionName] = useState(connectionName); const [selectedVaultIds, setSelectedVaultIds] = useState>(new Set()); const [vaultRoles, setVaultRoles] = useState>>({}); @@ -39,8 +42,8 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio const [error, setError] = useState(null); const [inviteLinkToken, setInviteLinkToken] = useState(null); const [autoCopied, setAutoCopied] = useState(false); - // Held here rather than in InvitePeopleSection: the first direct invite creates the - // session, which swaps the setup view for the active view and remounts the section. + // Held here rather than in PeopleTab: the first direct invite creates the + // session, which swaps the setup view for the active view and remounts the tab. const [invitedThisSession, setInvitedThisSession] = useState>(new Set()); const { teams, loading: teamsLoading, loadTeams } = useTeamStore(); @@ -75,21 +78,26 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio // Effective cap for the active session: use vault owner's tier when available const guestCap = guestCapFor(activeMp?.vaultOwnerTier ?? tier); - // Tab availability: - // free → team only, but only when connection is in a qualifying vault - // pro → invite always; team only when connection is in a qualifying vault - // teams/business → both tabs always - const availableTabs = - tier === "free" ? (["team"] as const) - : (tier === "pro" && !connectionInQualifyingVault) ? (["invite"] as const) - : (["team", "invite"] as const); + // Tab availability — People and Link both need Pro+ (host_tier_session_limit + // rejects free with 402, so gate here rather than round-trip a raw error). + // Team vault: for Pro it also needs the connection in a qualifying vault (the + // anti-piggyback rule above); for free/teams/business it's ungated here — free + // is gated by the outer upgrade wall instead, teams/business own their vaults + // outright. People leads whenever it's available: it fits "invite a specific + // person" best. + const teamTabAvailable = tier === "pro" ? connectionInQualifyingVault : true; + const availableTabs: readonly ("people" | "invite" | "team")[] = [ + ...(tier !== "free" ? (["people", "invite"] as const) : []), + ...(teamTabAvailable ? (["team"] as const) : []), + ]; // Position + load teams on open useEffect(() => { if (!open) return; if (anchorRef.current) { const rect = anchorRef.current.getBoundingClientRect(); - setPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 - 140 }); + const left = rect.left + rect.width / 2 - 140; + setPos({ top: rect.bottom + 4, left, originX: rect.left + rect.width / 2 - left }); } loadTeams().catch(() => {}); setSessionName(connectionName); @@ -182,10 +190,10 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio } }; - const handleInvite = async (member: TeamMember) => { - if (isSharing) await inviteToActiveSession(activeSessionId, member); - else await startSharingDirect(activeSessionId, sessionName || connectionName, [member]); - setInvitedThisSession((prev) => new Set(prev).add(member.user_id)); + const handleInvite = async (target: InviteTarget) => { + if (isSharing) await inviteToActiveSession(activeSessionId, target); + else await startSharingDirect(activeSessionId, sessionName || connectionName, [target]); + setInvitedThisSession((prev) => new Set(prev).add(target.user_id)); }; const handleStopSharing = async () => { @@ -198,16 +206,17 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio } }; - if (!open) return null; + if (!mounted) return null; return createPortal(
e.stopPropagation()} > @@ -313,7 +322,8 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio />
- {/* Tabs — Team tab hidden for Pro (no team vaults) */} + {/* Tabs — omitted entirely when only one is available (e.g. a Pro host + whose connection isn't in a qualifying vault sees no Team tab). */} {availableTabs.length > 1 && (
{availableTabs.map((tabId) => ( @@ -327,7 +337,7 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio }} onClick={() => setTab(tabId)} > - {tabId === "team" ? t("terminal.share.tabTeam") : t("terminal.share.tabInviteLink")} + {tabId === "people" ? t("terminal.share.tabPeople") : tabId === "team" ? t("terminal.share.tabTeam") : t("terminal.share.tabInviteLink")} ))}
@@ -340,7 +350,16 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio )} {/* Tab content */} - {tab === "team" ? ( + {tab === "people" ? ( + + ) : tab === "team" ? ( )} - - {/* Direct invites need at least Pro (host_tier_session_limit rejects free with 402) — - gate here rather than let the request round-trip into a raw inline error. */} - {tier !== "free" && ( - - )} )} , @@ -407,7 +413,7 @@ function ActiveSharingView({ tier: ShareTier; inviteSession: InviteSession; invitedThisSession: ReadonlySet; - onInvite: (member: TeamMember) => Promise; + onInvite: (target: InviteTarget) => Promise; onStop: () => void; onUpgrade: () => void; }) { @@ -473,7 +479,7 @@ function ActiveSharingView({ )} {canInviteDirectly && ( - { + vi.useFakeTimers(); + const { result, rerender } = renderHook(({ open }) => useDelayedUnmount(open, 120), { initialProps: { open: true } }); + expect(result.current).toBe(true); + rerender({ open: false }); + expect(result.current).toBe(true); + act(() => { vi.advanceTimersByTime(119); }); + expect(result.current).toBe(true); + act(() => { vi.advanceTimersByTime(2); }); + expect(result.current).toBe(false); + vi.useRealTimers(); +}); + +test("re-opening during the exit cancels the unmount", async () => { + vi.useFakeTimers(); + const { result, rerender } = renderHook(({ open }) => useDelayedUnmount(open, 120), { initialProps: { open: true } }); + rerender({ open: false }); + act(() => { vi.advanceTimersByTime(60); }); + rerender({ open: true }); + act(() => { vi.advanceTimersByTime(200); }); + expect(result.current).toBe(true); + vi.useRealTimers(); +}); diff --git a/src/hooks/useDelayedUnmount.ts b/src/hooks/useDelayedUnmount.ts new file mode 100644 index 000000000..5f1c6bc03 --- /dev/null +++ b/src/hooks/useDelayedUnmount.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from "react"; + +/** + * Keeps a portal mounted for the length of its exit animation. `if (!open) return null` + * cannot animate out — the node is gone before a transition can run. + */ +export function useDelayedUnmount(open: boolean, ms: number): boolean { + const [mounted, setMounted] = useState(open); + useEffect(() => { + if (open) { + setMounted(true); + return; + } + const timer = setTimeout(() => setMounted(false), ms); + return () => clearTimeout(timer); + }, [open, ms]); + return mounted; +} diff --git a/src/i18n/locales/en/terminal.json b/src/i18n/locales/en/terminal.json index 9dc217ac1..762eb292f 100644 --- a/src/i18n/locales/en/terminal.json +++ b/src/i18n/locales/en/terminal.json @@ -116,8 +116,9 @@ "upgradeToPro": "Upgrade to Pro", "shareTerminal": "Share terminal", "sessionNamePlaceholder": "Session name…", - "tabTeam": "Team", - "tabInviteLink": "Invite Link", + "tabPeople": "People", + "tabTeam": "Team vault", + "tabInviteLink": "Link", "sessionLimitReached": "Session limit reached for your plan.", "failedToShare": "Failed to share", "failedToGenerateLink": "Failed to generate link", @@ -142,13 +143,21 @@ "upgradeToBusiness": "Upgrade to Business", "hasControl": "Has control", "stopSharing": "Stop sharing", - "invitePeople": "Invite people", "inviteHasAccess": "Has access", "inviteSent": "Invited", "inviteCapReached": "Cap reached", "inviteFailed": "Could not invite {{name}}", "inviteNoTeammates": "No teammates yet", - "inviteLoadFailed": "Could not load teammates" + "inviteLoadFailed": "Could not load teammates", + "peopleSearchPlaceholder": "Search by name, @handle, or email…", + "peopleNoMatch": "No one found for \"{{query}}\"", + "peopleFindRule": "Search by @handle or full email to find anyone on Voltius.", + "recentLabel": "Recent", + "recentEmpty": "No one invited yet", + "yourTeamsLabel": "Your teams", + "elsewhereLabel": "Elsewhere on Voltius", + "notInYourTeams": "Not in your teams", + "forgetPerson": "Remove from recent" }, "snippetVariableModal": { "on": "On", diff --git a/src/i18n/locales/fr/terminal.json b/src/i18n/locales/fr/terminal.json index bcad2e781..f55d6b0da 100644 --- a/src/i18n/locales/fr/terminal.json +++ b/src/i18n/locales/fr/terminal.json @@ -116,8 +116,9 @@ "upgradeToPro": "Passer à Pro", "shareTerminal": "Partager le terminal", "sessionNamePlaceholder": "Nom de la session…", - "tabTeam": "Équipe", - "tabInviteLink": "Lien d'invitation", + "tabPeople": "Personnes", + "tabTeam": "Coffre d'équipe", + "tabInviteLink": "Lien", "sessionLimitReached": "Limite de sessions atteinte pour votre offre.", "failedToShare": "Échec du partage", "failedToGenerateLink": "Échec de la génération du lien", @@ -142,13 +143,21 @@ "upgradeToBusiness": "Passer à Business", "hasControl": "A le contrôle", "stopSharing": "Arrêter le partage", - "invitePeople": "Inviter des personnes", "inviteHasAccess": "A déjà accès", "inviteSent": "Invité", "inviteCapReached": "Limite atteinte", "inviteFailed": "Impossible d'inviter {{name}}", "inviteNoTeammates": "Aucun coéquipier pour l'instant", - "inviteLoadFailed": "Impossible de charger les coéquipiers" + "inviteLoadFailed": "Impossible de charger les coéquipiers", + "peopleSearchPlaceholder": "Rechercher par nom, @pseudo ou e-mail…", + "peopleNoMatch": "Aucun résultat pour « {{query}} »", + "peopleFindRule": "Recherchez par @pseudo ou e-mail complet pour trouver n'importe qui sur Voltius.", + "recentLabel": "Récent", + "recentEmpty": "Aucune invitation récente", + "yourTeamsLabel": "Vos équipes", + "elsewhereLabel": "Ailleurs sur Voltius", + "notInYourTeams": "Pas dans vos équipes", + "forgetPerson": "Retirer des récents" }, "snippetVariableModal": { "on": "Activé", diff --git a/src/i18n/locales/ru/terminal.json b/src/i18n/locales/ru/terminal.json index 3bc890f44..094767a19 100644 --- a/src/i18n/locales/ru/terminal.json +++ b/src/i18n/locales/ru/terminal.json @@ -118,8 +118,9 @@ "upgradeToPro": "Перейти на Pro", "shareTerminal": "Поделиться терминалом", "sessionNamePlaceholder": "Название сессии…", - "tabTeam": "Команда", - "tabInviteLink": "Ссылка-приглашение", + "tabPeople": "Люди", + "tabTeam": "Хранилище команды", + "tabInviteLink": "Ссылка", "sessionLimitReached": "Достигнут лимит сессий для вашего тарифа.", "failedToShare": "Не удалось предоставить доступ", "failedToGenerateLink": "Не удалось создать ссылку", @@ -152,13 +153,21 @@ "upgradeToBusiness": "Перейти на Business", "hasControl": "Управляет", "stopSharing": "Остановить совместный доступ", - "invitePeople": "Пригласить людей", "inviteHasAccess": "Уже есть доступ", "inviteSent": "Приглашён", "inviteCapReached": "Лимит достигнут", "inviteFailed": "Не удалось пригласить {{name}}", "inviteNoTeammates": "Пока нет коллег по команде", - "inviteLoadFailed": "Не удалось загрузить список коллег" + "inviteLoadFailed": "Не удалось загрузить список коллег", + "peopleSearchPlaceholder": "Поиск по имени, @псевдониму или e-mail…", + "peopleNoMatch": "Ничего не найдено по запросу «{{query}}»", + "peopleFindRule": "Ищите по @псевдониму или полному e-mail, чтобы найти любого пользователя Voltius.", + "recentLabel": "Недавние", + "recentEmpty": "Пока нет приглашений", + "yourTeamsLabel": "Ваши команды", + "elsewhereLabel": "Другие пользователи Voltius", + "notInYourTeams": "Не в ваших командах", + "forgetPerson": "Убрать из недавних" }, "snippetVariableModal": { "on": "Вкл", diff --git a/src/i18n/locales/zh/terminal.json b/src/i18n/locales/zh/terminal.json index d0cbc7a4c..fdf3c456b 100644 --- a/src/i18n/locales/zh/terminal.json +++ b/src/i18n/locales/zh/terminal.json @@ -116,8 +116,9 @@ "upgradeToPro": "升级到 Pro", "shareTerminal": "共享终端", "sessionNamePlaceholder": "会话名称…", - "tabTeam": "团队", - "tabInviteLink": "邀请链接", + "tabPeople": "人员", + "tabTeam": "团队保险库", + "tabInviteLink": "链接", "sessionLimitReached": "已达到套餐的会话限制。", "failedToShare": "共享失败", "failedToGenerateLink": "生成链接失败", @@ -142,13 +143,21 @@ "upgradeToBusiness": "升级到 Business", "hasControl": "拥有控制权", "stopSharing": "停止共享", - "invitePeople": "邀请队友", "inviteHasAccess": "已有访问权限", "inviteSent": "已邀请", "inviteCapReached": "已达上限", "inviteFailed": "无法邀请 {{name}}", "inviteNoTeammates": "暂无队友", - "inviteLoadFailed": "无法加载队友列表" + "inviteLoadFailed": "无法加载队友列表", + "peopleSearchPlaceholder": "按姓名、@handle 或邮箱搜索…", + "peopleNoMatch": "未找到与“{{query}}”匹配的人", + "peopleFindRule": "按 @handle 或完整邮箱搜索,可找到 Voltius 上的任何人。", + "recentLabel": "最近", + "recentEmpty": "尚未邀请任何人", + "yourTeamsLabel": "您的团队", + "elsewhereLabel": "Voltius 上的其他人", + "notInYourTeams": "不在您的团队中", + "forgetPerson": "从最近记录中移除" }, "snippetVariableModal": { "on": "开", diff --git a/src/services/multiplayerService.ts b/src/services/multiplayerService.ts index aca6595ff..cfceae5b2 100644 --- a/src/services/multiplayerService.ts +++ b/src/services/multiplayerService.ts @@ -225,7 +225,7 @@ export async function createVaultSession( */ export async function createDirectSession( connectionName: string, - invitees: teamService.TeamMember[], + invitees: InviteTarget[], ): Promise<{ sessionId: string; sessionKey: SessionKey; sessionKeyBytes: Uint8Array }> { const { sessionKey, sessionKeyBytes, wrappedKeys } = await prepareWrappedSessionKey(invitees); diff --git a/src/services/teamService.ts b/src/services/teamService.ts index 62bd68357..a8e21f214 100644 --- a/src/services/teamService.ts +++ b/src/services/teamService.ts @@ -45,6 +45,8 @@ export interface TeamMember { public_key: string; role_ids: string[]; is_online?: boolean; + /** Optional: an older server omits it. Never render a bare "@" when absent. */ + handle?: string; } export interface TeamRole { diff --git a/src/services/teamSharing.ts b/src/services/teamSharing.ts index 9cf9b1833..e7c6d21d0 100644 --- a/src/services/teamSharing.ts +++ b/src/services/teamSharing.ts @@ -23,7 +23,7 @@ export interface InviteTarget { team_id?: string; } -/** Account tier as used across the share flow (ShareMenu, InvitePeopleSection, ParticipantsRatioNotice). */ +/** Account tier as used across the share flow (ShareMenu, PeopleTab, ParticipantsRatioNotice). */ export type ShareTier = Tier; /** Guests a shared session may hold, from the tier whose plan the session runs on. */ @@ -66,12 +66,8 @@ export async function freshPublicKeys(members: { team_id: string; user_id: strin return new Map(fresh.map((m) => [m.user_id, m.public_key])); } -/** - * A teammate merged across every team they share with the caller. `handle` is - * optional: the member roster does not carry it today, so it is left undefined - * rather than adding it to `/members` in this pass. - */ -export type Teammate = TeamMember & { teamIds: string[]; handle?: string }; +/** A teammate merged across every team they share with the caller. */ +export type Teammate = TeamMember & { teamIds: string[] }; /** * Every teammate across all of the caller's teams, merged by user_id (with the diff --git a/src/stores/teamSessionStore.ts b/src/stores/teamSessionStore.ts index e4ff9b0a1..5d3dbdbc7 100644 --- a/src/stores/teamSessionStore.ts +++ b/src/stores/teamSessionStore.ts @@ -4,6 +4,7 @@ import * as mp from "@/services/multiplayerService"; import type { ActiveSession, Participant, MultiplayerConnection, SessionKey } from "@/services/multiplayerService"; import { sshSendInput } from "@/services/ssh"; import type { TeamMember } from "@/services/teamService"; +import type { InviteTarget } from "@/services/teamSharing"; export type { ActiveSession, Participant }; interface TeamSessionStore { @@ -22,7 +23,7 @@ interface TeamSessionStore { vaultIds: string[], allowedRoles: string[], connectionName: string, - members: import("@/services/teamService").TeamMember[], + members: TeamMember[], vaultOwnerTier?: string, ) => Promise; // returns multiplayerSessionId @@ -37,15 +38,17 @@ interface TeamSessionStore { /** * Host: create a direct session (no vault scope, E2EE per-invitee key wrapping) (#66). + * Invitees are `InviteTarget`, not `TeamMember`: any Voltius user can be invited + * directly, not only a teammate (#unified-invite). */ startSharingDirect: ( localSessionId: string, connectionName: string, - invitees: TeamMember[], + invitees: InviteTarget[], ) => Promise; // returns multiplayerSessionId - /** Host: grant an already-live session to another teammate by wrapping the retained session key for them (#66). */ - inviteToActiveSession: (localSessionId: string, member: TeamMember) => Promise; + /** Host: grant an already-live session to anyone — teammate or stranger — by wrapping the retained session key for them (#66, #unified-invite). */ + inviteToActiveSession: (localSessionId: string, target: InviteTarget) => Promise; joinSession: ( multiplayerSessionId: string, @@ -190,10 +193,10 @@ export const useTeamSessionStore = create((set, get) => ({ return sessionId; }, - inviteToActiveSession: async (localSessionId, member) => { + inviteToActiveSession: async (localSessionId, target) => { const state = get().connections[localSessionId]; if (!state?.sessionKeyBytes) throw new Error(i18n.t("common.error.cannotInviteWithoutSessionKey")); - await mp.inviteUserToSession(state.multiplayerSessionId, member, state.sessionKeyBytes); + await mp.inviteUserToSession(state.multiplayerSessionId, target, state.sessionKeyBytes); get().fetchActiveSessions().catch(() => {}); }, diff --git a/src/styles/globals.css b/src/styles/globals.css index 9d957d833..f81611947 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -622,4 +622,16 @@ button svg *, .clipboard-pill-enter { animation: fadeIn 120ms ease-out forwards; } .clipboard-pill-exit { animation: none; opacity: 0; } .clipboard-count-pop { animation: none; } + + /* Redeclaring the same keyframe name here overrides it only while the media + query matches — every `.animate-fadeIn` / `.animate-fadeOut` user (e.g. the + ShareMenu panel) drops the translate and keeps just the opacity fade. */ + @keyframes fadeIn { + 0% { opacity: 0; } + 100% { opacity: 1; } + } + @keyframes fadeOut { + 0% { opacity: 1; } + 100% { opacity: 0; } + } } From 9a82c6d8f5d6dde344e16b3d6569ba682f3c9888 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 05:08:14 +0000 Subject: [PATCH 09/24] fix(share): distinguish a failed roster load, pin the fade timing, cover the 3-tab matrix Review findings on the People-tab restructure: - PeopleTab folded a failed allTeammates() fetch into the same state as an empty roster; give it its own inviteLoadFailed banner, same as the deleted InvitePeopleSection had. - Cover the teams/business and pro-with-qualifying-vault branches of the tab availability matrix, which no existing test reached. - Replace the Tailwind arbitrary animation-duration override (ambiguous against the animate-fadeIn/fadeOut shorthand tokens) with an inline style that always wins the cascade. - Move the reduced-motion keyframe override next to the keyframes it guards. --- src/components/terminal/PeopleTab.test.tsx | 18 +++++++++++ src/components/terminal/PeopleTab.tsx | 37 ++++++++++++++-------- src/components/terminal/ShareMenu.test.tsx | 31 +++++++++++++++--- src/components/terminal/ShareMenu.tsx | 7 +++- src/styles/globals.css | 26 ++++++++------- 5 files changed, 88 insertions(+), 31 deletions(-) diff --git a/src/components/terminal/PeopleTab.test.tsx b/src/components/terminal/PeopleTab.test.tsx index 3edf5ef36..2024d180b 100644 --- a/src/components/terminal/PeopleTab.test.tsx +++ b/src/components/terminal/PeopleTab.test.tsx @@ -160,6 +160,24 @@ test("surfaces a failed invite inline and re-enables the row", async () => { expect(row.disabled).toBe(false); }); +// ─── Roster load failure (moved from InvitePeopleSection.test.tsx: a failed fetch +// must never read as "this team has no one to invite") ───────────────────────── + +test("a failed roster load shows a distinct error, not the empty-roster message", async () => { + h.allTeammates.mockRejectedValue(new Error("network")); + render(); + await screen.findByText("terminal.share.inviteLoadFailed"); + // "Your teams" only renders when there are teammate rows, so with the load + // failed it's simply absent — the failure banner is the only signal shown. + expect(screen.queryByText("terminal.share.yourTeamsLabel")).toBeNull(); +}); + +test("does not show a load-failure message while the roster request is still pending", () => { + h.allTeammates.mockReturnValue(new Promise(() => {})); // never resolves + render(); + expect(screen.queryByText("terminal.share.inviteLoadFailed")).toBeNull(); +}); + test("reloads the roster when the team list changes (ShareMenu's loadTeams races the mount effect)", async () => { h.allTeammates.mockResolvedValue(roster); render(); diff --git a/src/components/terminal/PeopleTab.tsx b/src/components/terminal/PeopleTab.tsx index c2651c4b5..a3fe54165 100644 --- a/src/components/terminal/PeopleTab.tsx +++ b/src/components/terminal/PeopleTab.tsx @@ -42,6 +42,21 @@ interface RowEntry { onContextMenu?: (e: React.MouseEvent) => void; } +function ErrorBanner({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + function PersonRow({ entry, hasAccess, @@ -118,6 +133,9 @@ export function PeopleTab({ session, invitedThisSession, guestCap, tier, onUpgra const search = useUserSearch(); const [teammates, setTeammates] = useState([]); + // Distinct from "loaded, zero teammates": a fetch failure must not read as an + // empty team, which is exactly the failure mode a teaching empty state hides. + const [teammatesLoadFailed, setTeammatesLoadFailed] = useState(false); const [inviting, setInviting] = useState>(new Set()); const [error, setError] = useState(null); const [menu, setMenu] = useState<{ userId: string; pos: { x: number; y: number } } | null>(null); @@ -126,7 +144,9 @@ export function PeopleTab({ session, invitedThisSession, guestCap, tier, onUpgra // on open, so on a fresh install/first sign-in the roster isn't populated yet at mount. useEffect(() => { let cancelled = false; - allTeammates().then((m) => { if (!cancelled) setTeammates(m); }).catch(() => { if (!cancelled) setTeammates([]); }); + allTeammates() + .then((m) => { if (!cancelled) { setTeammates(m); setTeammatesLoadFailed(false); } }) + .catch(() => { if (!cancelled) { setTeammates([]); setTeammatesLoadFailed(true); } }); return () => { cancelled = true; }; }, [teams]); @@ -221,18 +241,9 @@ export function PeopleTab({ session, invitedThisSession, guestCap, tier, onUpgra - {error && ( -
- {error} -
- )} + {error && {error}} + + {teammatesLoadFailed && {t("terminal.share.inviteLoadFailed")}} {searchedEmpty ? (
diff --git a/src/components/terminal/ShareMenu.test.tsx b/src/components/terminal/ShareMenu.test.tsx index 9274df6a0..a16c7081c 100644 --- a/src/components/terminal/ShareMenu.test.tsx +++ b/src/components/terminal/ShareMenu.test.tsx @@ -56,10 +56,10 @@ beforeEach(() => { }); afterEach(() => cleanup()); -function renderMenu() { +function renderMenu(overrides: { tier?: "pro" | "teams" | "business"; connectionVaultId?: string } = {}) { const anchorRef = createRef(); - // tier="pro" with a personal (non-qualifying) vault means People and Link are - // the only tabs (no qualifying vault for Team), with People selected by default. + // Default: tier="pro" with a personal (non-qualifying) vault means People and Link + // are the only tabs (no qualifying vault for Team), with People selected by default. return render( {}} activeSessionId="local-1" connectionName="Prod DB" - connectionVaultId="personal" + connectionVaultId={overrides.connectionVaultId ?? "personal"} isLoggedIn - tier="pro" + tier={overrides.tier ?? "pro"} onSignIn={() => {}} onUpgrade={() => {}} />, @@ -132,3 +132,24 @@ test("a rejecting writeClipboard leaves the share successful and the field uncop expect(screen.getByText("common.action.copy")).toBeTruthy(); expect(screen.queryByText("terminal.shared.copied")).toBeNull(); }); + +// ─── Tab-availability matrix (the branches `renderMenu()`'s pro-without-vault +// default never exercises) ────────────────────────────────────────────────── + +test("a Teams host sees all three tabs, People first, regardless of the connection's own vault", () => { + renderMenu({ tier: "teams", connectionVaultId: "personal" }); + expect(screen.getByText("terminal.share.tabPeople")).toBeTruthy(); + expect(screen.getByText("terminal.share.tabInviteLink")).toBeTruthy(); + expect(screen.getByText("terminal.share.tabTeam")).toBeTruthy(); + // People is the default/active tab — its content is already on screen. + expect(screen.getByPlaceholderText("terminal.share.peopleSearchPlaceholder")).toBeTruthy(); +}); + +test("a Pro host whose connection is in a qualifying vault sees all three tabs too", () => { + teamState.teams = [{ id: "vault-1", name: "Vault", owner_id: "u0", owner_tier: "teams", created_at: "", role_ids: [] }]; + renderMenu({ tier: "pro", connectionVaultId: "vault-1" }); + expect(screen.getByText("terminal.share.tabPeople")).toBeTruthy(); + expect(screen.getByText("terminal.share.tabInviteLink")).toBeTruthy(); + expect(screen.getByText("terminal.share.tabTeam")).toBeTruthy(); + expect(screen.getByPlaceholderText("terminal.share.peopleSearchPlaceholder")).toBeTruthy(); +}); diff --git a/src/components/terminal/ShareMenu.tsx b/src/components/terminal/ShareMenu.tsx index 59f824760..ff97b9162 100644 --- a/src/components/terminal/ShareMenu.tsx +++ b/src/components/terminal/ShareMenu.tsx @@ -211,12 +211,17 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio return createPortal(
e.stopPropagation()} > diff --git a/src/styles/globals.css b/src/styles/globals.css index f81611947..d880e16cd 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -79,6 +79,20 @@ } } +/* Redeclaring fadeIn/fadeOut here overrides them only while the media query + matches — every `.animate-fadeIn` / `.animate-fadeOut` user (e.g. the + ShareMenu panel) drops the translate and keeps just the opacity fade. */ +@media (prefers-reduced-motion: reduce) { + @keyframes fadeIn { + 0% { opacity: 0; } + 100% { opacity: 1; } + } + @keyframes fadeOut { + 0% { opacity: 1; } + 100% { opacity: 0; } + } +} + /* Derived UI surfaces — computed from the runtime `--t-*` theme vars so they adapt to every built-in and custom theme without per-theme edits. @@ -622,16 +636,4 @@ button svg *, .clipboard-pill-enter { animation: fadeIn 120ms ease-out forwards; } .clipboard-pill-exit { animation: none; opacity: 0; } .clipboard-count-pop { animation: none; } - - /* Redeclaring the same keyframe name here overrides it only while the media - query matches — every `.animate-fadeIn` / `.animate-fadeOut` user (e.g. the - ShareMenu panel) drops the translate and keeps just the opacity fade. */ - @keyframes fadeIn { - 0% { opacity: 0; } - 100% { opacity: 1; } - } - @keyframes fadeOut { - 0% { opacity: 1; } - 100% { opacity: 0; } - } } From be368312fe3d0ae1e8f0166d243f6857f7832694 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 05:14:54 +0000 Subject: [PATCH 10/24] feat(inbox): render a stranger knock with join, decline and permanent block --- src/services/teamInbox.test.ts | 60 +++++++++++++++++++++++++++++++-- src/services/teamInbox.ts | 57 ++++++++++++++++++++++++------- src/stores/notificationStore.ts | 1 + 3 files changed, 103 insertions(+), 15 deletions(-) diff --git a/src/services/teamInbox.test.ts b/src/services/teamInbox.test.ts index cbff3c5db..09ef3db2c 100644 --- a/src/services/teamInbox.test.ts +++ b/src/services/teamInbox.test.ts @@ -11,10 +11,12 @@ const h = vi.hoisted(() => { const useUIStore = { getState: () => uiState }; const joinSession = vi.fn(async () => "local-99"); const grantControl = vi.fn(); - const useTeamSessionStore = { getState: () => ({ joinSession, grantControl }) }; + const fetchActiveSessions = vi.fn(async () => {}); + const useTeamSessionStore = { getState: () => ({ joinSession, grantControl, fetchActiveSessions }) }; return { 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, @@ -23,6 +25,7 @@ const h = vi.hoisted(() => { useUIStore, joinSession, grantControl, + fetchActiveSessions, useTeamSessionStore, }; }); @@ -30,6 +33,10 @@ vi.mock("@/services/invitationActions", () => ({ acceptInvitation: h.accept, declineInvitation: h.decline, })); +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", @@ -39,7 +46,7 @@ vi.mock("@/stores/sessionStore", () => ({ useSessionStore: h.useSessionStore })) vi.mock("@/stores/uiStore", () => ({ useUIStore: h.useUIStore })); vi.mock("@/stores/teamSessionStore", () => ({ useTeamSessionStore: h.useTeamSessionStore })); vi.mock("@/i18n", () => ({ - default: { t: (k: string, o?: Record) => `${k}:${JSON.stringify(o ?? {})}` }, + default: { t: (k: string, o?: Record) => (o === undefined ? k : `${k}:${JSON.stringify(o)}`) }, })); import { useNotificationStore } from "@/stores/notificationStore"; @@ -76,6 +83,8 @@ beforeEach(() => { h.isMobileShell.mockClear().mockReturnValue(false); h.joinSession.mockClear().mockResolvedValue("local-99"); h.grantControl.mockClear(); + h.declineSessionInvite.mockClear(); + h.fetchActiveSessions.mockClear().mockResolvedValue(undefined); h.uiState.setActiveNav.mockClear(); h.sessionState.sessions = []; h.sessionState.activeSessionId = null; @@ -120,8 +129,9 @@ test("Decline runs the extracted decline action", async () => { expect(h.decline).toHaveBeenCalledWith("a"); }); -function session(overrides: Partial & { id: string }): ActiveSession { +function session(overrides: Partial = {}): ActiveSession { return { + id: "mp-1", connection_name: "web-prod", host_user_id: "host1", host_public_key: "pk", @@ -241,6 +251,50 @@ test("uses the inviter's display name from participants when available", () => { expect(entry?.message).toContain("\"inviter\":\"Alice\""); }); +test("a redacted invite renders as a knock from the inviter alone", () => { + reconcileSessions( + [ + session({ + connection_name: null, + invited_by: "u-stranger", + participants: [{ user_id: "u-stranger", display_name: "@kevin-p" }], + }), + ], + new Set(), + "me", + ); + const entry = get().inbox.find((e) => e.kind === "sessionKnock")!; + expect(entry.message).toContain("@kevin-p"); + expect(entry.message).not.toContain("web-prod"); + expect(entry.actions.map((a) => a.label)).toEqual([ + "notifications.inbox.sessionKnock.join", + "notifications.inbox.sessionKnock.decline", + "notifications.inbox.sessionKnock.blockPermanently", + ]); +}); + +test("decline calls the server and retracts the entry", async () => { + h.declineSessionInvite.mockResolvedValue(undefined); + reconcileSessions([session({ connection_name: null, invited_by: "u-stranger" })], new Set(), "me"); + const entry = get().inbox.find((e) => e.kind === "sessionKnock")!; + await entry.actions[1].run(); + expect(h.declineSessionInvite).toHaveBeenCalledWith("mp-1", { permanent: false }); + expect(get().inbox.find((e) => e.id === entry.id)).toBeUndefined(); +}); + +test("block permanently passes the flag", async () => { + h.declineSessionInvite.mockResolvedValue(undefined); + reconcileSessions([session({ connection_name: null, invited_by: "u-stranger" })], new Set(), "me"); + const entry = get().inbox.find((e) => e.kind === "sessionKnock")!; + await entry.actions[2].run(); + expect(h.declineSessionInvite).toHaveBeenCalledWith("mp-1", { permanent: true }); +}); + +test("a teammate invite is unchanged", () => { + reconcileSessions([session({ connection_name: "web-prod", invited_by: "u-mate" })], new Set(), "me"); + expect(get().inbox.find((e) => e.kind === "sessionInvite")).toBeTruthy(); +}); + test("running the inbox Join action opens a session tab, not just a websocket", async () => { reconcileSessions([session({ id: "s1" })], new Set(), "me"); await get().runInboxAction("session:s1", 0); diff --git a/src/services/teamInbox.ts b/src/services/teamInbox.ts index d650f33b9..7071da0d6 100644 --- a/src/services/teamInbox.ts +++ b/src/services/teamInbox.ts @@ -10,7 +10,7 @@ import { acceptInvitation, declineInvitation } from "@/services/invitationAction import { getCurrentUserEmail } from "@/services/account"; import { joinTeamSessionAndOpenTab } from "@/services/teamSessionJoin"; import { getPlatform, isMobileShell } from "@/utils/platform"; -import { getMyUserId } from "@/services/teamService"; +import { declineSessionInvite, getMyUserId } from "@/services/teamService"; import type { MyPendingInvitation } from "@/services/teamService"; import type { ActiveSession } from "@/services/multiplayerService"; import { sessionDisplayName } from "@/services/teamSharing"; @@ -91,6 +91,17 @@ async function joinSharedSession(session: ActiveSession): Promise { }); } +/** + * Decline retracts locally as well as server-side: the grant row is gone, so the + * next reconcile would not re-derive the entry anyway — but the user tapped + * Decline and the entry must go now, not on the next poll. + */ +async function declineKnock(sessionId: string, permanent: boolean): Promise { + await declineSessionInvite(sessionId, { permanent }); + useNotificationStore.getState().retractInbox(`session:${sessionId}`); + useTeamSessionStore.getState().fetchActiveSessions().catch(() => {}); +} + export function reconcileSessions( sessions: ActiveSession[], joinedSessionIds: Set, @@ -104,20 +115,27 @@ export function reconcileSessions( .map((s) => { const joined = joinedSessionIds.has(s.id); // A session reached through an individual invite (#66) knocks with - // inviter-specific wording rather than the generic broadcast share. + // inviter-specific wording rather than the generic broadcast share. A + // null connection_name means the server has redacted the session — the + // inviter is a stranger the recipient hasn't accepted yet — so that + // case knocks as "sessionKnock" instead, built from the inviter's + // identity alone and never from sessionDisplayName. const invited = !!s.invited_by && s.invited_by !== myUserId; + const knock = invited && s.connection_name === null; const inviter = invited ? (s.participants?.find((p) => p.user_id === s.invited_by)?.display_name ?? i18n.t("notifications.inbox.someone")) : ""; - const kind: InboxKind = invited ? "sessionInvite" : "sessionShared"; + const kind: InboxKind = knock ? "sessionKnock" : invited ? "sessionInvite" : "sessionShared"; const name = sessionDisplayName(s); return { id: `session:${s.id}`, kind, - message: invited - ? i18n.t("notifications.inbox.sessionInvite.message", { inviter, name }) - : i18n.t("notifications.inbox.session.message", { name }), + message: knock + ? i18n.t("notifications.inbox.sessionKnock.message", { inviter }) + : invited + ? i18n.t("notifications.inbox.sessionInvite.message", { inviter, name }) + : i18n.t("notifications.inbox.session.message", { name }), // Spelled out rather than left undefined: upsertInbox keeps the // previous state when it is omitted, which pinned an entry as // "resolved" — hiding its Join button — after a guest left and the @@ -131,20 +149,35 @@ export function reconcileSessions( actions: joined || isMobileShell() ? [] - : [{ label: i18n.t("notifications.inbox.session.join"), run: () => joinSharedSession(s) }], + : knock + ? [ + { label: i18n.t("notifications.inbox.sessionKnock.join"), run: () => joinSharedSession(s) }, + { + label: i18n.t("notifications.inbox.sessionKnock.decline"), + run: () => declineKnock(s.id, false), + }, + { + label: i18n.t("notifications.inbox.sessionKnock.blockPermanently"), + run: () => declineKnock(s.id, true), + }, + ] + : [{ label: i18n.t("notifications.inbox.session.join"), run: () => joinSharedSession(s) }], }; }); - // Toast only for invites not already in the inbox, so repeated reconciles - // stay silent and a broadcast share never toasts at all. + // Toast only for invites and knocks not already in the inbox, so repeated + // reconciles stay silent and a broadcast share never toasts at all. const known = new Set( - useNotificationStore.getState().inbox.filter((e) => e.kind === "sessionInvite").map((e) => e.id), + useNotificationStore + .getState() + .inbox.filter((e) => e.kind === "sessionInvite" || e.kind === "sessionKnock") + .map((e) => e.id), ); for (const e of entries) { - if (e.kind === "sessionInvite" && !known.has(e.id)) toast(e.message, 8000); + if ((e.kind === "sessionInvite" || e.kind === "sessionKnock") && !known.has(e.id)) toast(e.message, 8000); } - reconcile(["sessionShared", "sessionInvite"], entries); + reconcile(["sessionShared", "sessionInvite", "sessionKnock"], entries); } export function reconcileControlRequests(connections: Record): void { diff --git a/src/stores/notificationStore.ts b/src/stores/notificationStore.ts index a0d2641f4..418280c5c 100644 --- a/src/stores/notificationStore.ts +++ b/src/stores/notificationStore.ts @@ -50,6 +50,7 @@ export type InboxKind = | "invite" | "sessionShared" | "sessionInvite" + | "sessionKnock" | "controlRequest" | "controlGranted" | "awaitingKey"; From ee5a48b6a74ddde3b1a6002da747190084361949 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 05:27:26 +0000 Subject: [PATCH 11/24] feat(settings): show, claim and rename a handle, and toggle stranger invites --- .../sections/AccountSection.handle.test.tsx | 116 +++++++++ .../settings/sections/AccountSection.tsx | 233 +++++++++++++++--- src/components/settings/sections/shared.tsx | 4 +- src/components/shared/Toggle.tsx | 4 +- src/services/account.ts | 24 +- 5 files changed, 343 insertions(+), 38 deletions(-) create mode 100644 src/components/settings/sections/AccountSection.handle.test.tsx diff --git a/src/components/settings/sections/AccountSection.handle.test.tsx b/src/components/settings/sections/AccountSection.handle.test.tsx new file mode 100644 index 000000000..c10ad8c1a --- /dev/null +++ b/src/components/settings/sections/AccountSection.handle.test.tsx @@ -0,0 +1,116 @@ +import { test, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { MeResponse } from "@/services/account"; + +const h = vi.hoisted(() => ({ + getMe: vi.fn(async (): Promise => null), + claimHandle: vi.fn(), + updateInvitePreferences: vi.fn(async () => {}), +})); + +class HandleClaimError extends Error { + constructor(public status: number) { + super(`handle claim failed: ${status}`); + } +} + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); +vi.mock("@iconify/react", () => ({ Icon: () => null })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async () => null) })); +vi.mock("@/services/vault", () => ({ resetVault: vi.fn(async () => {}) })); +vi.mock("@/stores/securityStore", () => ({ + useSecurityStore: (selector: (s: unknown) => unknown) => + selector({ sessionTimeoutMinutes: null, setSessionTimeoutMinutes: vi.fn() }), +})); +vi.mock("@/stores/subscriptionStore", () => ({ + useSubscriptionStore: () => ({ + tier: "free", trialEndsAt: null, isTrialActive: false, isPro: false, isTeams: false, isBusiness: false, + usedSeats: null, totalSeats: null, subscriptionStatus: null, subscriptionCancelled: false, renewsAt: null, endsAt: null, + }), +})); +vi.mock("@/utils/billing", () => ({ openPortal: vi.fn() })); +vi.mock("@/services/billingCheckout", () => ({ openBillingCheckout: vi.fn(async () => {}) })); +vi.mock("./EditEmailModal", () => ({ default: () => null })); +vi.mock("./ChangeMasterPasswordModal", () => ({ default: () => null })); +vi.mock("@/services/account", async () => { + const actual = await vi.importActual("@/services/account"); + return { + ...actual, + getAccountMode: vi.fn(async () => "server"), + getCurrentUserEmail: vi.fn(async () => "ada@example.com"), + getMe: h.getMe, + updateDisplayName: vi.fn(async () => {}), + setMasterPassword: vi.fn(async () => {}), + logout: vi.fn(async () => {}), + lockVaultSession: vi.fn(async () => {}), + }; +}); +vi.mock("@/services/teamService", () => ({ + claimHandle: h.claimHandle, + updateInvitePreferences: h.updateInvitePreferences, + HandleClaimError, +})); + +const { default: AccountSection } = await import("./AccountSection"); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +test("a free account sees its generated handle, a copy button and the upsell — no claim form", async () => { + h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "free", allow_stranger_invites: true }); + render(); + expect(await screen.findByText("@swift-otter-4821")).toBeTruthy(); + expect(screen.getByText("settings.account.handle.upsell")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "settings.account.handle.save" })).toBeNull(); +}); + +test("a pro account can claim and the taken case is explained, not swallowed", async () => { + h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); + h.claimHandle.mockRejectedValue(new HandleClaimError(409)); + render(); + await userEvent.click(await screen.findByRole("button", { name: "settings.account.handle.choose" })); + await userEvent.type(screen.getByRole("textbox", { name: /handle/i }), "kevin-p"); + await userEvent.click(screen.getByRole("button", { name: "settings.account.handle.save" })); + expect(await screen.findByText("settings.account.handle.errorTaken")).toBeTruthy(); +}); + +test("the stranger-invite toggle persists", async () => { + h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); + render(); + await userEvent.click(await screen.findByRole("switch", { name: "settings.account.strangerInvites.label" })); + expect(h.updateInvitePreferences).toHaveBeenCalledWith(false); +}); + +test("the stranger-invite toggle reverts on failure", async () => { + h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); + h.updateInvitePreferences.mockRejectedValueOnce(new Error("network error")); + render(); + const toggle = await screen.findByRole("switch", { name: "settings.account.strangerInvites.label" }); + await userEvent.click(toggle); + await screen.findByText("network error"); + expect(toggle.getAttribute("aria-checked")).toBe("true"); +}); + +test("a pro account sees distinct copy for each claim-failure status", async () => { + h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); + const cases: [number, string][] = [ + [402, "settings.account.handle.errorTierRequired"], + [422, "settings.account.handle.errorInvalid"], + [429, "settings.account.handle.errorCooldown"], + ]; + for (const [status, key] of cases) { + h.claimHandle.mockRejectedValueOnce(new HandleClaimError(status)); + render(); + await userEvent.click(await screen.findByRole("button", { name: "settings.account.handle.choose" })); + await userEvent.type(screen.getByRole("textbox", { name: /handle/i }), "kevin-p"); + await userEvent.click(screen.getByRole("button", { name: "settings.account.handle.save" })); + expect(await screen.findByText(key)).toBeTruthy(); + cleanup(); + } +}); diff --git a/src/components/settings/sections/AccountSection.tsx b/src/components/settings/sections/AccountSection.tsx index d14bdb0ec..288582f1e 100644 --- a/src/components/settings/sections/AccountSection.tsx +++ b/src/components/settings/sections/AccountSection.tsx @@ -1,18 +1,66 @@ import { useEffect, useState, type FormEvent } from "react"; import { Icon } from "@iconify/react"; import { useTranslation } from "react-i18next"; -import { getAccountMode, getCurrentUserEmail, fetchAndCacheDisplayName, updateDisplayName, setMasterPassword, logout, lockVaultSession } from "@/services/account"; +import { getAccountMode, getCurrentUserEmail, getMe, updateDisplayName, setMasterPassword, logout, lockVaultSession } from "@/services/account"; import { resetVault } from "@/services/vault"; import { useSecurityStore } from "@/stores/securityStore"; import { ActionItem, FormButtons, SettingsInput } from "./shared"; import { useSubscriptionStore } from "@/stores/subscriptionStore"; import { openPortal } from "@/utils/billing"; import { openBillingCheckout } from "@/services/billingCheckout"; +import { claimHandle, updateInvitePreferences, HandleClaimError } from "@/services/teamService"; +import { Toggle } from "@/components/shared/Toggle"; +import { writeClipboard } from "@/utils/clipboard"; import EditEmailModal from "./EditEmailModal"; import ChangeMasterPasswordModal from "./ChangeMasterPasswordModal"; type AccountStep = "idle" | "set-password" | "loading" | "confirm-wipe"; +/** + * Shared idle → editing → submitting → error cycle behind both the display-name + * row and the handle-claim row below — same shape, different save/validate fns. + */ +function useEditableField( + save: (value: string) => Promise, + onSaved: (value: string) => void, +) { + const [editing, setEditing] = useState(false); + const [input, setInput] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const start = (initial: string) => { + setInput(initial); + setError(""); + setEditing(true); + }; + const cancel = () => { + setEditing(false); + setError(""); + }; + const submit = async (validate?: (value: string) => string | null) => { + const trimmed = input.trim(); + const validationError = validate?.(trimmed); + if (validationError) { + setError(validationError); + return; + } + setLoading(true); + setError(""); + try { + await save(trimmed); + onSaved(trimmed); + setEditing(false); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }; + + return { editing, input, setInput, error, loading, start, cancel, submit }; +} + const PLAN_FEATURES = [ { id: "localVault", free: true, pro: true, teams: true, business: true }, { id: "auditLogs", free: true, pro: true, teams: true, business: true }, @@ -29,6 +77,21 @@ async function openCheckout(plan: "pro" | "teams") { await openBillingCheckout(plan); } +/** Maps claimHandle's status-carrying error to the copy the server's per-status + * contract calls for — each status needs a distinct next step, not one generic message. */ +function mapHandleClaimError(e: unknown, t: (key: string) => string): Error { + if (e instanceof HandleClaimError) { + const key = + e.status === 402 ? "settings.account.handle.errorTierRequired" : + e.status === 409 ? "settings.account.handle.errorTaken" : + e.status === 422 ? "settings.account.handle.errorInvalid" : + e.status === 429 ? "settings.account.handle.errorCooldown" : + "settings.account.handle.errorGeneric"; + return new Error(t(key)); + } + return e instanceof Error ? e : new Error(String(e)); +} + export default function AccountSection() { const { t } = useTranslation(); const [mode, setMode] = useState(null); @@ -37,17 +100,54 @@ export default function AccountSection() { const [confirm, setConfirm] = useState(""); const [currentEmail, setCurrentEmail] = useState(null); const [displayName, setDisplayName] = useState(null); - const [editingDisplayName, setEditingDisplayName] = useState(false); - const [displayNameInput, setDisplayNameInput] = useState(""); - const [displayNameError, setDisplayNameError] = useState(""); - const [displayNameLoading, setDisplayNameLoading] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const [showEditEmail, setShowEditEmail] = useState(false); const [showChangePassword, setShowChangePassword] = useState(false); + const [handle, setHandle] = useState(null); + const [handleIsCustom, setHandleIsCustom] = useState(false); + const [meTier, setMeTier] = useState(undefined); + const [handleCopied, setHandleCopied] = useState(false); + const [allowStrangerInvites, setAllowStrangerInvites] = useState(true); + const [strangerInvitesError, setStrangerInvitesError] = useState(""); const sessionTimeoutMinutes = useSecurityStore((s) => s.sessionTimeoutMinutes); const setSessionTimeoutMinutes = useSecurityStore((s) => s.setSessionTimeoutMinutes); + const displayNameField = useEditableField( + (value) => updateDisplayName(value), + (value) => setDisplayName(value), + ); + const handleField = useEditableField( + async (value) => { + try { + await claimHandle(value); + } catch (e) { + throw mapHandleClaimError(e, t); + } + }, + (value) => { setHandle(value); setHandleIsCustom(true); }, + ); + const isFreeTier = !meTier || meTier === "free"; + + const handleCopyHandle = () => { + if (!handle) return; + writeClipboard(`@${handle}`).then(() => { + setHandleCopied(true); + setTimeout(() => setHandleCopied(false), 1500); + }).catch(() => {}); + }; + + const toggleStrangerInvites = async (next: boolean) => { + setAllowStrangerInvites(next); + setStrangerInvitesError(""); + try { + await updateInvitePreferences(next); + } catch (e) { + setAllowStrangerInvites(!next); // revert — the toggle can't silently drift from the server's stored value + setStrangerInvitesError(e instanceof Error ? e.message : String(e)); + } + }; + const SESSION_TIMEOUT_OPTIONS = [ { label: t("settings.account.sessionSecurity.timeout.never"), value: "never" }, { label: t("settings.account.sessionSecurity.timeout.5min"), value: "5" }, @@ -60,7 +160,14 @@ export default function AccountSection() { useEffect(() => { getAccountMode().then(setMode).catch(() => setMode(null)); getCurrentUserEmail().then(setCurrentEmail).catch(() => {}); - fetchAndCacheDisplayName().then((n) => { if (n) setDisplayName(n); }).catch(() => {}); + getMe().then((me) => { + if (!me) return; + if (me.display_name) setDisplayName(me.display_name); + if (me.handle) setHandle(me.handle); + setHandleIsCustom(!!me.handle_is_custom); + setMeTier(me.tier); + if (typeof me.allow_stranger_invites === "boolean") setAllowStrangerInvites(me.allow_stranger_invites); + }).catch(() => {}); setStep("idle"); setError(""); setSuccess(""); @@ -132,6 +239,82 @@ export default function AccountSection() {
+ {mode === "server" && ( +
+

+ {t("settings.account.handle.title")} +

+
+
+ + {handle ? `@${handle}` : "—"} + + +
+ + {isFreeTier ? ( +
+

{t("settings.account.handle.upsell")}

+

{t("settings.account.handle.reachableNote")}

+
+ ) : handleField.editing ? ( +
{ + e.preventDefault(); + handleField.submit((value) => (value ? null : t("settings.account.handle.errorInvalid"))); + }} + className="space-y-2" + > + + {handleField.error &&

{handleField.error}

} + + + ) : ( +
+ +

{t("settings.account.handle.chooseSub")}

+
+ )} +
+ +
+
+

{t("settings.account.strangerInvites.label")}

+

{t("settings.account.strangerInvites.desc")}

+ {strangerInvitesError &&

{strangerInvitesError}

} +
+ +
+
+ )} + {mode === "server" && }
@@ -184,7 +367,7 @@ export default function AccountSection() { /> )} {mode === "server" && ( - editingDisplayName ? ( + displayNameField.editing ? (
{ setDisplayNameInput(e.target.value); setDisplayNameError(""); }} + onChange={(e) => displayNameField.setInput(e.target.value)} className="rounded-lg px-3 py-1.5 text-sm outline-hidden" style={{ background: "var(--t-bg-input)", border: "1px solid var(--t-border)", color: "var(--t-text-primary)" }} /> - {displayNameError &&

{displayNameError}

} + {displayNameField.error &&

{displayNameField.error}

}
@@ -237,11 +408,7 @@ export default function AccountSection() { icon="lucide:user" label={t("settings.account.displayName.title")} sub={displayName ?? "—"} - onClick={() => { - setDisplayNameInput(displayName ?? ""); - setDisplayNameError(""); - setEditingDisplayName(true); - }} + onClick={() => displayNameField.start(displayName ?? "")} /> ) )} diff --git a/src/components/settings/sections/shared.tsx b/src/components/settings/sections/shared.tsx index 8fd5af4f5..0a400a6e8 100644 --- a/src/components/settings/sections/shared.tsx +++ b/src/components/settings/sections/shared.tsx @@ -117,12 +117,13 @@ export function ActionItem({ icon, label, sub, danger, disabled, onClick }: { ); } -export function SettingsInput({ type = "text", placeholder, value, onChange, autoFocus }: { +export function SettingsInput({ type = "text", placeholder, value, onChange, autoFocus, "aria-label": ariaLabel }: { type?: string; placeholder: string; value: string; onChange: (v: string) => void; autoFocus?: boolean; + "aria-label"?: string; }) { return ( onChange(e.target.value)} className="form-input w-full px-3 py-2 rounded-lg text-sm outline-hidden bg-(--t-bg-input) border border-(--t-border) text-(--t-text-primary)" /> diff --git a/src/components/shared/Toggle.tsx b/src/components/shared/Toggle.tsx index f0c486c36..cb60aa202 100644 --- a/src/components/shared/Toggle.tsx +++ b/src/components/shared/Toggle.tsx @@ -2,14 +2,16 @@ interface ToggleProps { checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; + "aria-label"?: string; } -export function Toggle({ checked, onChange, disabled }: ToggleProps) { +export function Toggle({ checked, onChange, disabled, "aria-label": ariaLabel }: ToggleProps) { return (
- {isFreeTier ? ( + {!tierKnown ? null : isLapsedCustom ? ( +
+

{t("settings.account.handle.lapsedKeepsHandle")}

+

{t("settings.account.handle.lapsedRenameLocked")}

+
+ ) : isFreeTier ? (

{t("settings.account.handle.upsell")}

{t("settings.account.handle.reachableNote")}

@@ -309,6 +325,7 @@ export default function AccountSection() {
From ef86960d19c7a5c46a53c86d320d730d83926e54 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 05:49:13 +0000 Subject: [PATCH 13/24] i18n: add unified invite flow strings in en, fr, ru and zh --- src/i18n/locales/en/common.json | 5 +++++ src/i18n/locales/en/importExport.json | 7 +++++-- src/i18n/locales/en/notifications.json | 6 ++++++ src/i18n/locales/en/settings.json | 24 ++++++++++++++++++++++++ src/i18n/locales/en/terminal.json | 6 +++--- src/i18n/locales/fr/common.json | 5 +++++ src/i18n/locales/fr/importExport.json | 7 +++++-- src/i18n/locales/fr/notifications.json | 6 ++++++ src/i18n/locales/fr/settings.json | 24 ++++++++++++++++++++++++ src/i18n/locales/fr/terminal.json | 6 +++--- src/i18n/locales/ru/common.json | 5 +++++ src/i18n/locales/ru/importExport.json | 9 +++++++-- src/i18n/locales/ru/notifications.json | 6 ++++++ src/i18n/locales/ru/settings.json | 24 ++++++++++++++++++++++++ src/i18n/locales/ru/terminal.json | 6 +++--- src/i18n/locales/zh/common.json | 5 +++++ src/i18n/locales/zh/importExport.json | 7 +++++-- src/i18n/locales/zh/notifications.json | 6 ++++++ src/i18n/locales/zh/settings.json | 24 ++++++++++++++++++++++++ src/i18n/locales/zh/terminal.json | 6 +++--- 20 files changed, 174 insertions(+), 20 deletions(-) diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 8d38696cd..a9d6dcc79 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -124,6 +124,11 @@ "failedToRevokeInvitation": "Failed to revoke invitation: {{status}}", "failedToAcceptInvitation": "Failed to accept invitation: {{status}}", "failedToDeclineInvitation": "Failed to decline invitation: {{status}}", + "failedToSavePreferences": "Failed to save preferences", + "failedToDecline": "Failed to decline", + "failedToUninvite": "Failed to remove person", + "userNoLongerAvailable": "This person is no longer available", + "failedToFetchPublicKey": "Failed to fetch public key: {{status}}", "noPermissionTeamVaultOp": "You do not have permission for this team vault operation", "teamVaultRequiresSubscription": "Team vault requires an active Teams or Business subscription", "failedToListTeamObjects": "Failed to list team objects: {{status}}", diff --git a/src/i18n/locales/en/importExport.json b/src/i18n/locales/en/importExport.json index f802c52a6..7c72d5171 100644 --- a/src/i18n/locales/en/importExport.json +++ b/src/i18n/locales/en/importExport.json @@ -107,7 +107,8 @@ "themes": { "label": "Themes" }, "uiPreferences": { "label": "UI Preferences" }, "shortcuts": { "label": "Shortcuts" }, - "appSettings": { "label": "App Settings" } + "appSettings": { "label": "App Settings" }, + "recentPeople": { "label": "Recent People" } }, "describe": { "themes_one": "{{count}} custom theme", @@ -117,7 +118,9 @@ "shortcutsOverrides_other": "{{count}} overrides", "shortcutsDefaults": "Defaults", "appSettingsShell": "shell: {{shell}}", - "appSettingsDefault": "Default settings" + "appSettingsDefault": "Default settings", + "recentPeople_one": "{{count}} recent person", + "recentPeople_other": "{{count}} recent people" } } } diff --git a/src/i18n/locales/en/notifications.json b/src/i18n/locales/en/notifications.json index f37c20052..7d805b3d5 100644 --- a/src/i18n/locales/en/notifications.json +++ b/src/i18n/locales/en/notifications.json @@ -55,6 +55,12 @@ "sessionInvite": { "message": "{{inviter}} invited you to {{name}}" }, + "sessionKnock": { + "message": "{{inviter}} wants to share a terminal", + "join": "Join", + "decline": "Decline", + "blockPermanently": "Block permanently" + }, "control": { "request": "{{requester}} is requesting control", "grant": "Grant", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 7b9f28c07..8c8940726 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -251,6 +251,30 @@ "saving": "Saving…", "save": "Save" }, + "handle": { + "title": "Handle", + "copy": "Copy", + "copied": "Copied", + "choose": "Choose a handle", + "change": "Change handle", + "chooseSub": "Lets people invite you by @handle instead of your email.", + "placeholder": "your-handle", + "inputLabel": "Handle", + "save": "Save", + "upsell": "Custom handles require Pro or higher.", + "reachableNote": "People can still reach you by your full email address.", + "lapsedKeepsHandle": "Your plan lapsed, but you keep your existing handle.", + "lapsedRenameLocked": "Renaming is locked until you resubscribe.", + "errorInvalid": "Handles can only contain letters, numbers, and hyphens.", + "errorTaken": "That handle is already taken.", + "errorTierRequired": "Custom handles require Pro or higher.", + "errorCooldown": "You can change your handle again soon.", + "errorGeneric": "Could not update your handle." + }, + "strangerInvites": { + "label": "Allow invites from anyone", + "desc": "Let people outside your teams invite you to a shared terminal by @handle or email." + }, "changeMasterPassword": { "label": "Change master password", "sub": "Update your password without re-encrypting your vault", diff --git a/src/i18n/locales/en/terminal.json b/src/i18n/locales/en/terminal.json index 762eb292f..675367b4f 100644 --- a/src/i18n/locales/en/terminal.json +++ b/src/i18n/locales/en/terminal.json @@ -150,10 +150,10 @@ "inviteNoTeammates": "No teammates yet", "inviteLoadFailed": "Could not load teammates", "peopleSearchPlaceholder": "Search by name, @handle, or email…", - "peopleNoMatch": "No one found for \"{{query}}\"", - "peopleFindRule": "Search by @handle or full email to find anyone on Voltius.", + "peopleNoMatch": "No one in your teams matches \"{{query}}\".", + "peopleFindRule": "People outside your teams are found by their @handle or their full email address.", "recentLabel": "Recent", - "recentEmpty": "No one invited yet", + "recentEmpty": "People you invite will show up here.", "yourTeamsLabel": "Your teams", "elsewhereLabel": "Elsewhere on Voltius", "notInYourTeams": "Not in your teams", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index b3a5a8ad1..04f0db65f 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -124,6 +124,11 @@ "failedToRevokeInvitation": "Échec de la révocation de l'invitation : {{status}}", "failedToAcceptInvitation": "Échec de l'acceptation de l'invitation : {{status}}", "failedToDeclineInvitation": "Échec du refus de l'invitation : {{status}}", + "failedToSavePreferences": "Échec de l'enregistrement des préférences", + "failedToDecline": "Échec du refus", + "failedToUninvite": "Échec de la suppression de la personne", + "userNoLongerAvailable": "Cette personne n'est plus disponible", + "failedToFetchPublicKey": "Échec de la récupération de la clé publique : {{status}}", "noPermissionTeamVaultOp": "Vous n'avez pas la permission d'effectuer cette opération sur le coffre d'équipe", "teamVaultRequiresSubscription": "Le coffre d'équipe nécessite un abonnement Teams ou Business actif", "failedToListTeamObjects": "Échec du chargement des objets d'équipe : {{status}}", diff --git a/src/i18n/locales/fr/importExport.json b/src/i18n/locales/fr/importExport.json index 75e3a5456..8311c1a39 100644 --- a/src/i18n/locales/fr/importExport.json +++ b/src/i18n/locales/fr/importExport.json @@ -107,7 +107,8 @@ "themes": { "label": "Thèmes" }, "uiPreferences": { "label": "Préférences d'interface" }, "shortcuts": { "label": "Raccourcis" }, - "appSettings": { "label": "Paramètres de l'application" } + "appSettings": { "label": "Paramètres de l'application" }, + "recentPeople": { "label": "Personnes récentes" } }, "describe": { "themes_one": "{{count}} thème personnalisé", @@ -117,7 +118,9 @@ "shortcutsOverrides_other": "{{count}} personnalisations", "shortcutsDefaults": "Par défaut", "appSettingsShell": "shell : {{shell}}", - "appSettingsDefault": "Paramètres par défaut" + "appSettingsDefault": "Paramètres par défaut", + "recentPeople_one": "{{count}} personne récente", + "recentPeople_other": "{{count}} personnes récentes" } } } diff --git a/src/i18n/locales/fr/notifications.json b/src/i18n/locales/fr/notifications.json index 2dd60de82..da6e22fcf 100644 --- a/src/i18n/locales/fr/notifications.json +++ b/src/i18n/locales/fr/notifications.json @@ -55,6 +55,12 @@ "sessionInvite": { "message": "{{inviter}} vous a invité à {{name}}" }, + "sessionKnock": { + "message": "{{inviter}} souhaite partager un terminal", + "join": "Rejoindre", + "decline": "Refuser", + "blockPermanently": "Bloquer définitivement" + }, "control": { "request": "{{requester}} demande le contrôle", "grant": "Accorder", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index f729dc90d..5b7a0ba9f 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -251,6 +251,30 @@ "saving": "Enregistrement…", "save": "Enregistrer" }, + "handle": { + "title": "Pseudo", + "copy": "Copier", + "copied": "Copié", + "choose": "Choisir un pseudo", + "change": "Changer de pseudo", + "chooseSub": "Permet aux autres de vous inviter par @pseudo plutôt que par e-mail.", + "placeholder": "votre-pseudo", + "inputLabel": "Pseudo", + "save": "Enregistrer", + "upsell": "Les pseudos personnalisés nécessitent l'offre Pro ou supérieure.", + "reachableNote": "Vous restez joignable par votre adresse e-mail complète.", + "lapsedKeepsHandle": "Votre offre a expiré, mais vous conservez votre pseudo actuel.", + "lapsedRenameLocked": "Le changement de pseudo est verrouillé jusqu'à votre réabonnement.", + "errorInvalid": "Un pseudo ne peut contenir que des lettres, des chiffres et des tirets.", + "errorTaken": "Ce pseudo est déjà pris.", + "errorTierRequired": "Les pseudos personnalisés nécessitent l'offre Pro ou supérieure.", + "errorCooldown": "Vous pourrez de nouveau changer de pseudo bientôt.", + "errorGeneric": "Impossible de mettre à jour votre pseudo." + }, + "strangerInvites": { + "label": "Autoriser les invitations de tout le monde", + "desc": "Permet aux personnes en dehors de vos équipes de vous inviter dans un terminal partagé par @pseudo ou e-mail." + }, "changeMasterPassword": { "label": "Changer le mot de passe maître", "sub": "Mettez à jour votre mot de passe sans rechiffrer votre coffre-fort", diff --git a/src/i18n/locales/fr/terminal.json b/src/i18n/locales/fr/terminal.json index f55d6b0da..6562b9be8 100644 --- a/src/i18n/locales/fr/terminal.json +++ b/src/i18n/locales/fr/terminal.json @@ -150,10 +150,10 @@ "inviteNoTeammates": "Aucun coéquipier pour l'instant", "inviteLoadFailed": "Impossible de charger les coéquipiers", "peopleSearchPlaceholder": "Rechercher par nom, @pseudo ou e-mail…", - "peopleNoMatch": "Aucun résultat pour « {{query}} »", - "peopleFindRule": "Recherchez par @pseudo ou e-mail complet pour trouver n'importe qui sur Voltius.", + "peopleNoMatch": "Personne dans vos équipes ne correspond à « {{query}} ».", + "peopleFindRule": "En dehors de vos équipes, une personne se trouve par son @pseudo ou son adresse e-mail complète.", "recentLabel": "Récent", - "recentEmpty": "Aucune invitation récente", + "recentEmpty": "Les personnes que vous invitez apparaîtront ici.", "yourTeamsLabel": "Vos équipes", "elsewhereLabel": "Ailleurs sur Voltius", "notInYourTeams": "Pas dans vos équipes", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index b2914800f..9d4d1ecdf 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -124,6 +124,11 @@ "failedToRevokeInvitation": "Не удалось отозвать приглашение: {{status}}", "failedToAcceptInvitation": "Не удалось принять приглашение: {{status}}", "failedToDeclineInvitation": "Не удалось отклонить приглашение: {{status}}", + "failedToSavePreferences": "Не удалось сохранить настройки", + "failedToDecline": "Не удалось отклонить", + "failedToUninvite": "Не удалось удалить пользователя", + "userNoLongerAvailable": "Этот пользователь больше недоступен", + "failedToFetchPublicKey": "Не удалось получить публичный ключ: {{status}}", "noPermissionTeamVaultOp": "У вас нет прав на эту операцию с командным хранилищем", "teamVaultRequiresSubscription": "Для командного хранилища требуется активная подписка Teams или Business", "failedToListTeamObjects": "Не удалось получить список объектов команды: {{status}}", diff --git a/src/i18n/locales/ru/importExport.json b/src/i18n/locales/ru/importExport.json index 7bc920aba..cfd6e9b24 100644 --- a/src/i18n/locales/ru/importExport.json +++ b/src/i18n/locales/ru/importExport.json @@ -135,7 +135,8 @@ "themes": { "label": "Темы" }, "uiPreferences": { "label": "Настройки интерфейса" }, "shortcuts": { "label": "Сочетания клавиш" }, - "appSettings": { "label": "Настройки приложения" } + "appSettings": { "label": "Настройки приложения" }, + "recentPeople": { "label": "Недавние люди" } }, "describe": { "themes_one": "{{count}} пользовательская тема", @@ -149,7 +150,11 @@ "shortcutsOverrides_other": "{{count}} переопределений", "shortcutsDefaults": "По умолчанию", "appSettingsShell": "оболочка: {{shell}}", - "appSettingsDefault": "Настройки по умолчанию" + "appSettingsDefault": "Настройки по умолчанию", + "recentPeople_one": "{{count}} недавний контакт", + "recentPeople_few": "{{count}} недавних контакта", + "recentPeople_many": "{{count}} недавних контактов", + "recentPeople_other": "{{count}} недавних контактов" } } } diff --git a/src/i18n/locales/ru/notifications.json b/src/i18n/locales/ru/notifications.json index acbc78e29..5577ab1f5 100644 --- a/src/i18n/locales/ru/notifications.json +++ b/src/i18n/locales/ru/notifications.json @@ -55,6 +55,12 @@ "sessionInvite": { "message": "{{inviter}} пригласил(а) вас в {{name}}" }, + "sessionKnock": { + "message": "{{inviter}} хочет поделиться терминалом", + "join": "Присоединиться", + "decline": "Отклонить", + "blockPermanently": "Заблокировать навсегда" + }, "control": { "request": "{{requester}} запрашивает управление", "grant": "Разрешить", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 3bed98703..351fbe3f5 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -251,6 +251,30 @@ "saving": "Сохранение…", "save": "Сохранить" }, + "handle": { + "title": "Псевдоним", + "copy": "Копировать", + "copied": "Скопировано", + "choose": "Выбрать псевдоним", + "change": "Изменить псевдоним", + "chooseSub": "Позволяет приглашать вас по @псевдониму вместо e-mail.", + "placeholder": "ваш-псевдоним", + "inputLabel": "Псевдоним", + "save": "Сохранить", + "upsell": "Пользовательские псевдонимы требуют тариф Pro или выше.", + "reachableNote": "Вас всё равно можно найти по полному адресу e-mail.", + "lapsedKeepsHandle": "Ваш тариф истёк, но текущий псевдоним сохраняется за вами.", + "lapsedRenameLocked": "Переименование заблокировано до возобновления подписки.", + "errorInvalid": "Псевдоним может содержать только буквы, цифры и дефисы.", + "errorTaken": "Этот псевдоним уже занят.", + "errorTierRequired": "Пользовательские псевдонимы требуют тариф Pro или выше.", + "errorCooldown": "Вы сможете снова сменить псевдоним чуть позже.", + "errorGeneric": "Не удалось обновить псевдоним." + }, + "strangerInvites": { + "label": "Разрешить приглашения от кого угодно", + "desc": "Позволяет людям вне ваших команд приглашать вас в общий терминал по @псевдониму или e-mail." + }, "changeMasterPassword": { "label": "Изменить мастер-пароль", "sub": "Обновите пароль без повторного шифрования хранилища", diff --git a/src/i18n/locales/ru/terminal.json b/src/i18n/locales/ru/terminal.json index 094767a19..b8b86ab81 100644 --- a/src/i18n/locales/ru/terminal.json +++ b/src/i18n/locales/ru/terminal.json @@ -160,10 +160,10 @@ "inviteNoTeammates": "Пока нет коллег по команде", "inviteLoadFailed": "Не удалось загрузить список коллег", "peopleSearchPlaceholder": "Поиск по имени, @псевдониму или e-mail…", - "peopleNoMatch": "Ничего не найдено по запросу «{{query}}»", - "peopleFindRule": "Ищите по @псевдониму или полному e-mail, чтобы найти любого пользователя Voltius.", + "peopleNoMatch": "В ваших командах никто не соответствует «{{query}}».", + "peopleFindRule": "Людей вне ваших команд можно найти по их @псевдониму или полному адресу e-mail.", "recentLabel": "Недавние", - "recentEmpty": "Пока нет приглашений", + "recentEmpty": "Приглашённые вами люди появятся здесь.", "yourTeamsLabel": "Ваши команды", "elsewhereLabel": "Другие пользователи Voltius", "notInYourTeams": "Не в ваших командах", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index bc1380780..80d65005c 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -124,6 +124,11 @@ "failedToRevokeInvitation": "撤销邀请失败:{{status}}", "failedToAcceptInvitation": "接受邀请失败:{{status}}", "failedToDeclineInvitation": "拒绝邀请失败:{{status}}", + "failedToSavePreferences": "保存偏好设置失败", + "failedToDecline": "拒绝失败", + "failedToUninvite": "移除该用户失败", + "userNoLongerAvailable": "该用户已不可用", + "failedToFetchPublicKey": "获取公钥失败:{{status}}", "noPermissionTeamVaultOp": "您没有权限执行此团队保险库操作", "teamVaultRequiresSubscription": "团队保险库需要有效的 Teams 或 Business 订阅", "failedToListTeamObjects": "获取团队对象列表失败:{{status}}", diff --git a/src/i18n/locales/zh/importExport.json b/src/i18n/locales/zh/importExport.json index 3640932f0..835585140 100644 --- a/src/i18n/locales/zh/importExport.json +++ b/src/i18n/locales/zh/importExport.json @@ -107,7 +107,8 @@ "themes": { "label": "主题" }, "uiPreferences": { "label": "UI 偏好" }, "shortcuts": { "label": "快捷键" }, - "appSettings": { "label": "应用设置" } + "appSettings": { "label": "应用设置" }, + "recentPeople": { "label": "最近的联系人" } }, "describe": { "themes_one": "{{count}} 个自定义主题", @@ -117,7 +118,9 @@ "shortcutsOverrides_other": "{{count}} 个覆盖", "shortcutsDefaults": "默认值", "appSettingsShell": "shell: {{shell}}", - "appSettingsDefault": "默认设置" + "appSettingsDefault": "默认设置", + "recentPeople_one": "{{count}} 位最近联系人", + "recentPeople_other": "{{count}} 位最近联系人" } } } diff --git a/src/i18n/locales/zh/notifications.json b/src/i18n/locales/zh/notifications.json index b663d3b59..817e69676 100644 --- a/src/i18n/locales/zh/notifications.json +++ b/src/i18n/locales/zh/notifications.json @@ -55,6 +55,12 @@ "sessionInvite": { "message": "{{inviter}} 邀请您加入 {{name}}" }, + "sessionKnock": { + "message": "{{inviter}} 想要与您共享终端", + "join": "加入", + "decline": "拒绝", + "blockPermanently": "永久屏蔽" + }, "control": { "request": "{{requester}} 正在请求控制权", "grant": "授予", diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 6522b5ab2..0862aff11 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -138,6 +138,30 @@ "saving": "正在保存…", "save": "保存" }, + "handle": { + "title": "Handle", + "copy": "复制", + "copied": "已复制", + "choose": "选择 handle", + "change": "更改 handle", + "chooseSub": "让其他人可以通过 @handle 而不是邮箱邀请您。", + "placeholder": "your-handle", + "inputLabel": "Handle", + "save": "保存", + "upsell": "自定义 handle 需要 Pro 或更高套餐。", + "reachableNote": "他人仍可通过您的完整邮箱地址联系您。", + "lapsedKeepsHandle": "您的套餐已过期,但仍保留现有 handle。", + "lapsedRenameLocked": "重新订阅前无法更改 handle。", + "errorInvalid": "Handle 只能包含字母、数字和连字符。", + "errorTaken": "该 handle 已被占用。", + "errorTierRequired": "自定义 handle 需要 Pro 或更高套餐。", + "errorCooldown": "您很快就可以再次更改 handle。", + "errorGeneric": "无法更新您的 handle。" + }, + "strangerInvites": { + "label": "允许任何人邀请", + "desc": "允许团队之外的人通过 @handle 或邮箱邀请您加入共享终端。" + }, "changeMasterPassword": { "label": "更改主密码", "sub": "更新密码而无需重新加密保险库", diff --git a/src/i18n/locales/zh/terminal.json b/src/i18n/locales/zh/terminal.json index fdf3c456b..d3d3f184f 100644 --- a/src/i18n/locales/zh/terminal.json +++ b/src/i18n/locales/zh/terminal.json @@ -150,10 +150,10 @@ "inviteNoTeammates": "暂无队友", "inviteLoadFailed": "无法加载队友列表", "peopleSearchPlaceholder": "按姓名、@handle 或邮箱搜索…", - "peopleNoMatch": "未找到与“{{query}}”匹配的人", - "peopleFindRule": "按 @handle 或完整邮箱搜索,可找到 Voltius 上的任何人。", + "peopleNoMatch": "您的团队中没有人与“{{query}}”匹配。", + "peopleFindRule": "团队之外的用户可通过其 @handle 或完整邮箱地址找到。", "recentLabel": "最近", - "recentEmpty": "尚未邀请任何人", + "recentEmpty": "您邀请的人会显示在这里。", "yourTeamsLabel": "您的团队", "elsewhereLabel": "Voltius 上的其他人", "notInYourTeams": "不在您的团队中", From 757a78bb5e2fe12e76a9d92b282959eb7617e7e8 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 06:19:22 +0000 Subject: [PATCH 14/24] test(team): assert on status code, not the raw i18n key The 500-from-key-lookup test asserted on the literal i18n key, which worked only while the key had no translated copy. The strings task added real English text for it, so i18n.t() now resolves the key and the assertion compared against the wrong string. Assert on the status code instead so this survives future copy changes. --- src/services/teamService.invites.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/services/teamService.invites.test.ts b/src/services/teamService.invites.test.ts index cc964d049..0ba44be6f 100644 --- a/src/services/teamService.invites.test.ts +++ b/src/services/teamService.invites.test.ts @@ -40,5 +40,7 @@ test("a 404 from the key lookup resolves to null so Recent can self-heal", async test("a 500 from the key lookup throws instead of masquerading as a missing user", async () => { h.appFetch.mockResolvedValue({ ok: false, status: 500 }); - await expect(getUserPublicKey("u1")).rejects.toThrow("common.error.failedToFetchPublicKey"); + // Assert on the status code, not the full translated message, so this survives + // the copy changing (it already broke once when the i18n key gained real text). + await expect(getUserPublicKey("u1")).rejects.toThrow("500"); }); From b87499ded461fca41d5dc7652fdbdbfd36b14678 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 07:01:49 +0000 Subject: [PATCH 15/24] fix(inbox): render a knock from the server-owned handle, never a supplied name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stranger knock built its inviter from a participant's display_name, which originates in the sender's own WebSocket query string. A sender could connect to their own session as "Voltius Support" and put that name above a Join button granting terminal access — walking straight past the reserved-handle list, which refuses @voltius-support at claim time but never saw this surface. The entry now renders @{invited_by_handle}, the value the server resolves from its own users table, and falls back to "Someone" when it is absent (an older server, or a race). Never to display_name: that is the hole. The teammate sessionInvite and broadcast sessionShared entries are untouched. --- src/services/multiplayerService.ts | 6 ++++ src/services/teamInbox.test.ts | 47 +++++++++++++++++++++++++----- src/services/teamInbox.ts | 18 +++++++++--- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/services/multiplayerService.ts b/src/services/multiplayerService.ts index cfceae5b2..75c3eaee8 100644 --- a/src/services/multiplayerService.ts +++ b/src/services/multiplayerService.ts @@ -23,6 +23,12 @@ 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_handle?: string | null; /** Everyone the host has individually invited (#66). Only set for the host. */ invitee_ids?: string[]; } diff --git a/src/services/teamInbox.test.ts b/src/services/teamInbox.test.ts index 09ef3db2c..f9ebd7fd3 100644 --- a/src/services/teamInbox.test.ts +++ b/src/services/teamInbox.test.ts @@ -253,13 +253,7 @@ test("uses the inviter's display name from participants when available", () => { test("a redacted invite renders as a knock from the inviter alone", () => { reconcileSessions( - [ - session({ - connection_name: null, - invited_by: "u-stranger", - participants: [{ user_id: "u-stranger", display_name: "@kevin-p" }], - }), - ], + [session({ connection_name: null, invited_by: "u-stranger", invited_by_handle: "kevin-p" })], new Set(), "me", ); @@ -273,6 +267,45 @@ 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. +test("a knock renders the server handle and never a participant display name", () => { + reconcileSessions( + [ + session({ + connection_name: null, + invited_by: "u-stranger", + invited_by_handle: "kevin-p", + participants: [{ user_id: "u-stranger", display_name: "Voltius Support" }], + }), + ], + new Set(), + "me", + ); + const entry = get().inbox.find((e) => e.kind === "sessionKnock")!; + expect(entry.message).toContain("@kevin-p"); + expect(entry.message).not.toContain("Voltius Support"); +}); + +test("a knock with no handle falls back to Someone, not to the supplied name", () => { + reconcileSessions( + [ + session({ + connection_name: null, + invited_by: "u-stranger", + participants: [{ user_id: "u-stranger", display_name: "Voltius Support" }], + }), + ], + new Set(), + "me", + ); + const entry = get().inbox.find((e) => e.kind === "sessionKnock")!; + expect(entry.message).toContain("notifications.inbox.someone"); + expect(entry.message).not.toContain("Voltius Support"); +}); + + + test("decline calls the server and retracts the entry", async () => { h.declineSessionInvite.mockResolvedValue(undefined); reconcileSessions([session({ connection_name: null, invited_by: "u-stranger" })], new Set(), "me"); diff --git a/src/services/teamInbox.ts b/src/services/teamInbox.ts index 7071da0d6..771d9e759 100644 --- a/src/services/teamInbox.ts +++ b/src/services/teamInbox.ts @@ -122,10 +122,20 @@ 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; - const inviter = invited - ? (s.participants?.find((p) => p.user_id === s.invited_by)?.display_name ?? - i18n.t("notifications.inbox.someone")) - : ""; + // A knock renders the server-resolved handle and nothing else. Participant + // display names arrive in the sender's own WebSocket query string, 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. + 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 ?? + i18n.t("notifications.inbox.someone")) + : ""; const kind: InboxKind = knock ? "sessionKnock" : invited ? "sessionInvite" : "sessionShared"; const name = sessionDisplayName(s); return { From 386a06275467be0695ef91c76a082a197d7e03b0 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 07:01:49 +0000 Subject: [PATCH 16/24] fix(inbox): rename a joined knock's tab once the server un-redacts it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit joinSharedSession froze the tab title at the redacted placeholder it had at knock time. The server un-redacts on admission, but nothing renamed a multiplayer tab afterwards, so a joined knock read "Shared terminal" forever — against the spec's own live-run criterion that Join reveals the name. Refetch once after the join resolves and patch the session's connectionName. --- src/services/teamInbox.test.ts | 37 ++++++++++++++++++++++++++++++++-- src/services/teamInbox.ts | 25 ++++++++++++++++++++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/services/teamInbox.test.ts b/src/services/teamInbox.test.ts index f9ebd7fd3..c4cd027e9 100644 --- a/src/services/teamInbox.test.ts +++ b/src/services/teamInbox.test.ts @@ -11,8 +11,14 @@ const h = vi.hoisted(() => { const useUIStore = { getState: () => uiState }; const joinSession = vi.fn(async () => "local-99"); const grantControl = vi.fn(); - const fetchActiveSessions = vi.fn(async () => {}); - const useTeamSessionStore = { getState: () => ({ joinSession, grantControl, fetchActiveSessions }) }; + const teamSessionState = { + joinSession, + grantControl, + fetchActiveSessions: vi.fn(async () => {}), + activeSessions: [] as Record[], + }; + const fetchActiveSessions = teamSessionState.fetchActiveSessions; + const useTeamSessionStore = { getState: () => teamSessionState }; return { accept: vi.fn(async () => {}), decline: vi.fn(async () => {}), @@ -26,6 +32,7 @@ const h = vi.hoisted(() => { joinSession, grantControl, fetchActiveSessions, + teamSessionState, useTeamSessionStore, }; }); @@ -85,6 +92,7 @@ beforeEach(() => { h.grantControl.mockClear(); h.declineSessionInvite.mockClear(); h.fetchActiveSessions.mockClear().mockResolvedValue(undefined); + h.teamSessionState.activeSessions = []; h.uiState.setActiveNav.mockClear(); h.sessionState.sessions = []; h.sessionState.activeSessionId = null; @@ -304,7 +312,32 @@ test("a knock with no handle falls back to Someone, not to the supplied name", ( expect(entry.message).not.toContain("Voltius Support"); }); +test("joining a knock renames the tab once the server un-redacts the session", async () => { + h.teamSessionState.activeSessions = [{ id: "mp-1", connection_name: "web-prod" }]; + reconcileSessions( + [session({ connection_name: null, invited_by: "u-stranger", invited_by_handle: "kevin-p" })], + new Set(), + "me", + ); + const entry = get().inbox.find((e) => e.kind === "sessionKnock")!; + await entry.actions[0].run(); + + expect(h.fetchActiveSessions).toHaveBeenCalled(); + expect(h.sessionState.sessions).toHaveLength(1); + expect(h.sessionState.sessions[0].connectionName).toBe("web-prod"); +}); +test("a still-redacted session after join leaves the placeholder alone", async () => { + h.teamSessionState.activeSessions = [{ id: "mp-1", connection_name: null }]; + reconcileSessions( + [session({ connection_name: null, invited_by: "u-stranger", invited_by_handle: "kevin-p" })], + new Set(), + "me", + ); + await get().inbox.find((e) => e.kind === "sessionKnock")!.actions[0].run(); + expect(h.sessionState.sessions[0].connectionName).not.toBe(""); + expect(h.sessionState.sessions[0].connectionName).toBeTruthy(); +}); test("decline calls the server and retracts the entry", async () => { h.declineSessionInvite.mockResolvedValue(undefined); diff --git a/src/services/teamInbox.ts b/src/services/teamInbox.ts index 771d9e759..e9ef8d714 100644 --- a/src/services/teamInbox.ts +++ b/src/services/teamInbox.ts @@ -3,6 +3,7 @@ import { useNotificationStore } from "@/stores/notificationStore"; import type { InboxEntry, InboxKind } from "@/stores/notificationStore"; import { useTeamStore } from "@/stores/teamStore"; import { useTeamSessionStore } from "@/stores/teamSessionStore"; +import { useSessionStore } from "@/stores/sessionStore"; import type { MultiplayerSessionState } from "@/stores/teamSessionStore"; import { useTeamVaultStateStore } from "@/stores/teamVaultStateStore"; import type { TeamVaultStatus } from "@/stores/teamVaultStateStore"; @@ -84,11 +85,33 @@ export function reconcileInvites(invites: MyPendingInvitation[]): void { async function joinSharedSession(session: ActiveSession): Promise { const displayName = (await getCurrentUserEmail()) ?? i18n.t("hosts.teamSessions.meFallback"); - await joinTeamSessionAndOpenTab({ + const localSessionId = await joinTeamSessionAndOpenTab({ sessionId: session.id, displayName, connectionName: sessionDisplayName(session), }); + + // A knock's name is the redacted placeholder; the server un-redacts on + // admission, but the tab title was fixed at join time and nothing else renames + // it. Refetch once and patch, or a joined knock reads "Shared terminal" + // forever. + if (session.connection_name === null) { + try { + await useTeamSessionStore.getState().fetchActiveSessions(); + const revealed = useTeamSessionStore + .getState() + .activeSessions.find((s) => s.id === session.id)?.connection_name; + if (revealed) { + useSessionStore.setState((s) => ({ + sessions: s.sessions.map((sess) => + sess.id === localSessionId ? { ...sess, connectionName: revealed } : sess, + ), + })); + } + } catch { + // The tab keeps the placeholder; not worth failing a successful join over. + } + } } /** From 2ffe9bc1f742f8a39a36cfa53d485479522b00a5 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 07:01:49 +0000 Subject: [PATCH 17/24] fix(share): keep a Recent teammate's vault access in the People tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recent rows were built with teamIds: [], and memberHasAccess tests teamIds against the session's vaults first. Under the "Recent wins over Your teams" dedupe a teammate in Recent therefore rendered as invitable in a session scoped to their own vault; tapping issued a real grant, seatUsage counted it, and a Pro host at cap 1 lost the seat they meant for someone else. Merge the matching teammate entry in — the person still appears once, under Recent. --- src/components/terminal/PeopleTab.test.tsx | 19 +++++++++++++++ src/components/terminal/PeopleTab.tsx | 28 +++++++++++++++++----- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/components/terminal/PeopleTab.test.tsx b/src/components/terminal/PeopleTab.test.tsx index 2024d180b..b0c2e52f0 100644 --- a/src/components/terminal/PeopleTab.test.tsx +++ b/src/components/terminal/PeopleTab.test.tsx @@ -136,6 +136,25 @@ test("marks a covered teammate as having access and does not call onInvite", asy expect(onInvite).not.toHaveBeenCalled(); }); +// Recent wins the dedupe, so the teammate's teamIds only reach memberHasAccess +// if the Recent row carries them. Without that, tapping issues a real grant and +// spends a guest seat on someone who already has access. +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: "" }], + recentUpdatedAt: "", + }); + const onInvite = vi.fn(); + render(); + const rows = await screen.findAllByRole("button", { name: /alice/i }); + expect(rows).toHaveLength(1); + expect((rows[0] as HTMLButtonElement).disabled).toBe(true); + expect(within(rows[0]).getByText("terminal.share.inviteHasAccess")).toBeTruthy(); + await userEvent.click(rows[0]); + expect(onInvite).not.toHaveBeenCalled(); +}); + test("disables the row while an invite is in flight and shows Invited after", async () => { h.allTeammates.mockResolvedValue(roster); let resolve: () => void; diff --git a/src/components/terminal/PeopleTab.tsx b/src/components/terminal/PeopleTab.tsx index a3fe54165..a557fe09e 100644 --- a/src/components/terminal/PeopleTab.tsx +++ b/src/components/terminal/PeopleTab.tsx @@ -181,12 +181,28 @@ export function PeopleTab({ session, invitedThisSession, guestCap, tier, onUpgra const groups = groupPeople({ query: search.query, teammates, recent, results: search.results }); - const recentEntries: RowEntry[] = groups.recent.map((p) => ({ - target: { user_id: p.user_id, display_name: p.display_name, handle: p.handle }, - teamIds: [], - isStranger: false, - onContextMenu: (e) => { e.preventDefault(); setMenu({ userId: p.user_id, pos: { x: e.clientX, y: e.clientY } }); }, - })); + const recentEntries: RowEntry[] = groups.recent.map((p) => { + // Recent wins the dedupe, so a teammate listed here is dropped from the + // teammate group entirely — and with it their `teamIds`, which is what + // `memberHasAccess` tests against the session's vaults first. Without this + // merge the row renders as invitable, and inviting spends a guest seat on + // someone who already has access. + const teammate = teammates.find((m) => m.user_id === p.user_id); + return { + target: { + user_id: p.user_id, + display_name: p.display_name, + handle: p.handle, + team_id: teammate?.teamIds[0], + }, + teamIds: teammate?.teamIds ?? [], + isStranger: false, + onContextMenu: (e: React.MouseEvent) => { + e.preventDefault(); + setMenu({ userId: p.user_id, pos: { x: e.clientX, y: e.clientY } }); + }, + }; + }); const teammateEntries: RowEntry[] = groups.teammates.map((m) => ({ target: { user_id: m.user_id, display_name: m.display_name, handle: m.handle, team_id: m.teamIds[0] }, teamIds: m.teamIds, From 00ac33b2a15929b60d0fb1a4a80ace63cfb2e836 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 07:01:49 +0000 Subject: [PATCH 18/24] fix(recent): project and guard replaceAll, not just remember MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit replaceAll is the path that takes foreign data — the sync blob and the import UI — and it neither checked its input was an array nor stripped unknown fields, so the "Recent never persists a public key" invariant held only on the one path that never sees untrusted input. --- src/stores/recentPeopleStore.test.ts | 19 +++++++++++++++++++ src/stores/recentPeopleStore.ts | 25 ++++++++++++++++++------- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/stores/recentPeopleStore.test.ts b/src/stores/recentPeopleStore.test.ts index 5d2406ff4..de446879f 100644 --- a/src/stores/recentPeopleStore.test.ts +++ b/src/stores/recentPeopleStore.test.ts @@ -36,3 +36,22 @@ test("no key material is ever stored", () => { useRecentPeopleStore.getState().remember({ ...person("a"), public_key: "leak" } as never); expect(JSON.stringify(useRecentPeopleStore.getState().recent)).not.toContain("leak"); }); + +// replaceAll is the path that takes foreign data — the sync blob and the import +// UI — so it needs the projection more than remember does. +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"]); +}); + +test("replaceAll caps the list and rejects a non-array", () => { + useRecentPeopleStore.getState().replaceAll( + Array.from({ length: MAX_RECENT + 5 }, (_, i) => person(`u${i}`)), + ); + expect(useRecentPeopleStore.getState().recent.length).toBe(MAX_RECENT); + + useRecentPeopleStore.getState().replaceAll({ not: "an array" } as never); + expect(useRecentPeopleStore.getState().recent).toEqual([]); +}); diff --git a/src/stores/recentPeopleStore.ts b/src/stores/recentPeopleStore.ts index d606aa749..fbeae23d3 100644 --- a/src/stores/recentPeopleStore.ts +++ b/src/stores/recentPeopleStore.ts @@ -15,6 +15,21 @@ export interface RecentPerson { last_invited_at: string; } +/** + * Keeps exactly the four 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. + */ +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, + }; +} + interface RecentPeopleStore { recent: RecentPerson[]; recentUpdatedAt: string; @@ -31,12 +46,7 @@ export const useRecentPeopleStore = create()( remember: (person) => set((s) => { - const clean: RecentPerson = { - user_id: person.user_id, - handle: person.handle, - display_name: person.display_name, - last_invited_at: person.last_invited_at, - }; + const clean = project(person); const recent = [clean, ...s.recent.filter((p) => p.user_id !== clean.user_id)].slice(0, MAX_RECENT); const recentUpdatedAt = settingsStamp(); pushSettingsChange(); @@ -50,7 +60,8 @@ export const useRecentPeopleStore = create()( return { recent: s.recent.filter((p) => p.user_id !== userId), recentUpdatedAt }; }), - replaceAll: (list) => set({ recent: list.slice(0, MAX_RECENT) }), + replaceAll: (list) => + set({ recent: Array.isArray(list) ? list.slice(0, MAX_RECENT).map(project) : [] }), }), { name: "voltius-recent-people" }, ), From 1bcaf77c29df222d69ba1b169e11c82de3c65041 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 07:01:49 +0000 Subject: [PATCH 19/24] refactor(invites): route updatePublicKey through authedCall The last hand-rolled copy of that shape; it stayed out only because its error message interpolates a status. Give authedCall an optional interpolation argument instead of keeping a third copy of the call sequence. --- src/services/teamService.ts | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/services/teamService.ts b/src/services/teamService.ts index a8e21f214..6a3c91fdc 100644 --- a/src/services/teamService.ts +++ b/src/services/teamService.ts @@ -306,12 +306,21 @@ export async function claimHandle(handle: string): Promise { if (!res.ok) throw new HandleClaimError(res.status); } -/** Resolves serverUrl, calls fetchAuth, and throws the keyed i18n error on a non-ok response. */ -async function authedCall(path: string, init: RequestInit, errorKey: string): Promise { +/** + * Resolves serverUrl, calls fetchAuth, and throws the keyed i18n error on a + * non-ok response. `interpolate` receives the failing status for messages that + * name it. + */ +async function authedCall( + path: string, + init: RequestInit, + errorKey: string, + interpolate?: (status: number) => Record, +): Promise { const serverUrl = await getServerUrl(); if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); const res = await fetchAuth(`${serverUrl}${path}`, init); - if (!res.ok) throw new Error(i18n.t(errorKey)); + if (!res.ok) throw new Error(i18n.t(errorKey, interpolate?.(res.status))); } export async function updateInvitePreferences(allowStrangerInvites: boolean): Promise { @@ -340,13 +349,12 @@ export async function uninviteFromSession(sessionId: string, userId: string): Pr } export async function updatePublicKey(publicKey: string): Promise { - const serverUrl = await getServerUrl(); - if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); - const res = await fetchAuth(`${serverUrl}/v1/auth/public-key`, { - method: "PUT", - body: JSON.stringify({ public_key: publicKey }), - }); - if (!res.ok) throw new Error(i18n.t("common.error.failedToUpdatePublicKey", { status: res.status })); + await authedCall( + "/v1/auth/public-key", + { method: "PUT", body: JSON.stringify({ public_key: publicKey }) }, + "common.error.failedToUpdatePublicKey", + (status) => ({ status }), + ); } export async function getJwtToken(): Promise { From 510734870102cf825929a6c7d5a873ee483cfca6 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 08:28:54 +0000 Subject: [PATCH 20/24] fix(knock): retry the tab rename until the server un-redacts A single refetch after Join raced the un-redaction and lost it on every attempt of a live run, so a joined knock read "Shared Terminal" forever. Poll on a bounded backoff instead, detached from the join so the inbox entry does not sit in its acting state. --- src/services/teamInbox.test.ts | 45 ++++++++++++++++++++++++++----- src/services/teamInbox.ts | 49 +++++++++++++++++++--------------- 2 files changed, 67 insertions(+), 27 deletions(-) diff --git a/src/services/teamInbox.test.ts b/src/services/teamInbox.test.ts index c4cd027e9..3e6434793 100644 --- a/src/services/teamInbox.test.ts +++ b/src/services/teamInbox.test.ts @@ -322,21 +322,54 @@ test("joining a knock renames the tab once the server un-redacts the session", a const entry = get().inbox.find((e) => e.kind === "sessionKnock")!; await entry.actions[0].run(); - expect(h.fetchActiveSessions).toHaveBeenCalled(); - expect(h.sessionState.sessions).toHaveLength(1); - expect(h.sessionState.sessions[0].connectionName).toBe("web-prod"); + await vi.waitFor(() => { + expect(h.fetchActiveSessions).toHaveBeenCalled(); + expect(h.sessionState.sessions).toHaveLength(1); + expect(h.sessionState.sessions[0].connectionName).toBe("web-prod"); + }); }); -test("a still-redacted session after join leaves the placeholder alone", async () => { +// The reveal lands ~100–200ms after the socket is admitted, so the first fetch +// legitimately comes back still-redacted; the retry is what makes the rename +// happen at all. +test("the rename retries until the server un-redacts", async () => { h.teamSessionState.activeSessions = [{ id: "mp-1", connection_name: null }]; + h.fetchActiveSessions.mockImplementation(async () => { + if (h.fetchActiveSessions.mock.calls.length >= 3) { + h.teamSessionState.activeSessions = [{ id: "mp-1", connection_name: "web-prod" }]; + } + }); reconcileSessions( [session({ connection_name: null, invited_by: "u-stranger", invited_by_handle: "kevin-p" })], new Set(), "me", ); await get().inbox.find((e) => e.kind === "sessionKnock")!.actions[0].run(); - expect(h.sessionState.sessions[0].connectionName).not.toBe(""); - expect(h.sessionState.sessions[0].connectionName).toBeTruthy(); + + await vi.waitFor(() => expect(h.sessionState.sessions[0].connectionName).toBe("web-prod")); + expect(h.fetchActiveSessions.mock.calls.length).toBeGreaterThan(1); +}); + +test("a still-redacted session gives up and leaves the placeholder alone", async () => { + vi.useFakeTimers(); + try { + h.teamSessionState.activeSessions = [{ id: "mp-1", connection_name: null }]; + reconcileSessions( + [session({ connection_name: null, invited_by: "u-stranger", invited_by_handle: "kevin-p" })], + new Set(), + "me", + ); + await get().inbox.find((e) => e.kind === "sessionKnock")!.actions[0].run(); + // Well past the whole backoff: the loop must stop, not spin forever. + await vi.advanceTimersByTimeAsync(30_000); + const calls = h.fetchActiveSessions.mock.calls.length; + await vi.advanceTimersByTimeAsync(30_000); + expect(h.fetchActiveSessions.mock.calls.length).toBe(calls); + expect(h.sessionState.sessions[0].connectionName).toBeTruthy(); + expect(h.sessionState.sessions[0].connectionName).not.toBe(""); + } finally { + vi.useRealTimers(); + } }); test("decline calls the server and retracts the entry", async () => { diff --git a/src/services/teamInbox.ts b/src/services/teamInbox.ts index e9ef8d714..c300a7c58 100644 --- a/src/services/teamInbox.ts +++ b/src/services/teamInbox.ts @@ -83,6 +83,31 @@ export function reconcileInvites(invites: MyPendingInvitation[]): void { ); } +// The server un-redacts a knock ~100–200ms after it admits the WebSocket, which +// a single immediate refetch loses to — a live run lost it on every attempt, so +// the tab read "Shared terminal" permanently. Poll on a short backoff instead, +// bounded: a session that legitimately stays redacted must keep the placeholder +// rather than spin forever. +const REVEAL_DELAYS_MS = [0, 150, 300, 600, 1200, 2000]; + +async function revealJoinedSessionName(sessionId: string, localSessionId: string): Promise { + for (const delay of REVEAL_DELAYS_MS) { + if (delay > 0) await new Promise((r) => setTimeout(r, delay)); + // A transient fetch failure costs this attempt, not the remaining ones. + await useTeamSessionStore.getState().fetchActiveSessions().catch(() => {}); + const revealed = useTeamSessionStore + .getState() + .activeSessions.find((s) => s.id === sessionId)?.connection_name; + if (!revealed) continue; + useSessionStore.setState((s) => ({ + sessions: s.sessions.map((sess) => + sess.id === localSessionId ? { ...sess, connectionName: revealed } : sess, + ), + })); + return; + } +} + async function joinSharedSession(session: ActiveSession): Promise { const displayName = (await getCurrentUserEmail()) ?? i18n.t("hosts.teamSessions.meFallback"); const localSessionId = await joinTeamSessionAndOpenTab({ @@ -91,27 +116,9 @@ async function joinSharedSession(session: ActiveSession): Promise { connectionName: sessionDisplayName(session), }); - // A knock's name is the redacted placeholder; the server un-redacts on - // admission, but the tab title was fixed at join time and nothing else renames - // it. Refetch once and patch, or a joined knock reads "Shared terminal" - // forever. - if (session.connection_name === null) { - try { - await useTeamSessionStore.getState().fetchActiveSessions(); - const revealed = useTeamSessionStore - .getState() - .activeSessions.find((s) => s.id === session.id)?.connection_name; - if (revealed) { - useSessionStore.setState((s) => ({ - sessions: s.sessions.map((sess) => - sess.id === localSessionId ? { ...sess, connectionName: revealed } : sess, - ), - })); - } - } catch { - // The tab keeps the placeholder; not worth failing a successful join over. - } - } + // Detached: the join itself is done, and the inbox entry stays in its "acting" + // state for as long as this promise runs. + if (session.connection_name === null) void revealJoinedSessionName(session.id, localSessionId); } /** From b10287f43b65bf6ca312443337e9720f529657d8 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 08:28:54 +0000 Subject: [PATCH 21/24] fix(recent): stamp an imported list instead of dating it 1970 replaceAll set the list without recentUpdatedAt, so a device that received Recent over the sync blob kept the epoch and lost the next last-write-wins merge. It now stamps like remember/forget, which under the remote-apply guard adopts the remote timestamp and pushes nothing. --- src/stores/recentPeopleStore.test.ts | 15 +++++++++++++++ src/stores/recentPeopleStore.ts | 11 ++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/stores/recentPeopleStore.test.ts b/src/stores/recentPeopleStore.test.ts index de446879f..78606b69f 100644 --- a/src/stores/recentPeopleStore.test.ts +++ b/src/stores/recentPeopleStore.test.ts @@ -1,5 +1,6 @@ import { test, expect, beforeEach } from "vitest"; import { useRecentPeopleStore, MAX_RECENT } 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, @@ -55,3 +56,17 @@ test("replaceAll caps the list and rejects a non-array", () => { useRecentPeopleStore.getState().replaceAll({ not: "an array" } as never); expect(useRecentPeopleStore.getState().recent).toEqual([]); }); + +test("an imported list is stamped, not left at the epoch", () => { + const epoch = new Date(0).toISOString(); + useRecentPeopleStore.getState().replaceAll([person("a")]); + expect(useRecentPeopleStore.getState().recentUpdatedAt > epoch).toBe(true); +}); + +test("a remotely applied list adopts the remote timestamp", async () => { + const remoteAt = "2026-08-15T07:51:57.626Z"; + await withRemoteApply(remoteAt, async () => { + useRecentPeopleStore.getState().replaceAll([person("a")]); + }); + expect(useRecentPeopleStore.getState().recentUpdatedAt).toBe(remoteAt); +}); diff --git a/src/stores/recentPeopleStore.ts b/src/stores/recentPeopleStore.ts index fbeae23d3..d2fafc8b6 100644 --- a/src/stores/recentPeopleStore.ts +++ b/src/stores/recentPeopleStore.ts @@ -60,8 +60,17 @@ export const useRecentPeopleStore = create()( return { recent: s.recent.filter((p) => p.user_id !== userId), recentUpdatedAt }; }), + // Stamps like every other write path: a list arriving through the sync + // blob or the import UI must carry a timestamp, or `lastWriteWins` dates + // it at the epoch and the next pull discards what was just applied. Under + // a remote apply `settingsStamp()` adopts the remote section's timestamp + // and `pushSettingsChange()` is a no-op, so this cannot bounce back. replaceAll: (list) => - set({ recent: Array.isArray(list) ? list.slice(0, MAX_RECENT).map(project) : [] }), + set(() => { + const recentUpdatedAt = settingsStamp(); + pushSettingsChange(); + return { recent: Array.isArray(list) ? list.slice(0, MAX_RECENT).map(project) : [], recentUpdatedAt }; + }), }), { name: "voltius-recent-people" }, ), From aa6d09b21591d5b0948aa99c4007afed8024a86b Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 08:29:04 +0000 Subject: [PATCH 22/24] feat(share): let a host withdraw a pending invite, and label it Invited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A standing invite held a guest seat until the session ended with no way to take it back, and rendered as "Has access" — indistinguishable from someone already in the room. The row now says Invited and carries a Withdraw action that calls the un-invite endpoint and refetches, so the seat frees. memberHasAccess keeps counting pending invites for the cap; the new memberHasLiveAccess answers the row's question. --- src/components/terminal/PeopleTab.tsx | 131 ++++++++++++------ .../terminal/ShareMenu.invitePeople.test.tsx | 37 ++++- .../terminal/ShareMenu.testHarness.ts | 2 + src/components/terminal/ShareMenu.tsx | 20 +++ src/i18n/locales/en/terminal.json | 2 + src/i18n/locales/fr/terminal.json | 2 + src/i18n/locales/ru/terminal.json | 2 + src/i18n/locales/zh/terminal.json | 2 + src/services/teamSharing.allTeammates.test.ts | 20 ++- src/services/teamSharing.ts | 24 +++- 10 files changed, 189 insertions(+), 53 deletions(-) diff --git a/src/components/terminal/PeopleTab.tsx b/src/components/terminal/PeopleTab.tsx index a557fe09e..539eef21c 100644 --- a/src/components/terminal/PeopleTab.tsx +++ b/src/components/terminal/PeopleTab.tsx @@ -5,7 +5,7 @@ import { useTeamStore } from "@/stores/teamStore"; import { allTeammates, groupPeople, - memberHasAccess, + memberHasLiveAccess, seatUsage, type InviteSession, type InviteTarget, @@ -29,6 +29,8 @@ interface PeopleTabProps { tier: ShareTier; onUpgrade: () => void; onInvite: (target: InviteTarget) => Promise; + /** Withdraws a standing invite. Absent before the session exists, when no row can be invited yet. */ + onUninvite?: (userId: string) => Promise; } /** A normalized row: whichever group it came from, the row itself doesn't care. */ @@ -64,6 +66,7 @@ function PersonRow({ invited, capBlocked, onInvite, + onUninvite, t, }: { entry: RowEntry; @@ -72,60 +75,74 @@ function PersonRow({ invited: boolean; capBlocked: boolean; onInvite: () => void; + /** Offered only on a standing invite: the seat it holds is the one A6 exists to free. */ + onUninvite?: () => void; t: (key: string, opts?: Record) => string; }) { const { target, isStranger, isOnline, onContextMenu } = entry; return ( - + {invited && onUninvite && ( + )} - {hasAccess ? ( - - {t("terminal.share.inviteHasAccess")} - - ) : inFlight ? ( - - ) : invited ? ( - - {t("terminal.share.inviteSent")} - - ) : capBlocked ? ( - - {t("terminal.share.inviteCapReached")} - - ) : null} - + ); } -export function PeopleTab({ session, invitedThisSession, guestCap, tier, onUpgrade, onInvite }: PeopleTabProps) { +export function PeopleTab({ session, invitedThisSession, guestCap, tier, onUpgrade, onInvite, onUninvite }: PeopleTabProps) { const { t } = useTranslation(); const teams = useTeamStore((s) => s.teams); const recent = useRecentPeopleStore((s) => s.recent); @@ -179,6 +196,21 @@ export function PeopleTab({ session, invitedThisSession, guestCap, tier, onUpgra } }; + // A6: a standing invite holds a guest seat until the session ends, so a Pro + // host at cap 1 whose invitee never arrives has no way forward without this. + const handleUninvite = async (userId: string) => { + if (!onUninvite) return; + setError(null); + setInFlight(userId, true); + try { + await onUninvite(userId); + } catch { + setError(t("terminal.share.uninviteFailed")); + } finally { + setInFlight(userId, false); + } + }; + const groups = groupPeople({ query: search.query, teammates, recent, results: search.results }); const recentEntries: RowEntry[] = groups.recent.map((p) => { @@ -216,9 +248,15 @@ export function PeopleTab({ session, invitedThisSession, guestCap, tier, onUpgra })); const renderRow = (entry: RowEntry) => { - const hasAccess = memberHasAccess({ user_id: entry.target.user_id, teamIds: entry.teamIds }, session); + // "Has access" is being in the room — a shared vault or live participation. + // A grant nobody has accepted yet is "Invited", the state a host may still + // withdraw. It still counts against the cap (`memberHasAccess`), which is + // why the two questions are asked separately. + const hasAccess = memberHasLiveAccess({ user_id: entry.target.user_id, teamIds: entry.teamIds }, session); const inFlight = inviting.has(entry.target.user_id); - const invited = invitedThisSession.has(entry.target.user_id); + const invited = + !hasAccess && + (invitedThisSession.has(entry.target.user_id) || session.invitedIds.includes(entry.target.user_id)); // A row this session just invited keeps showing "Invited", not the cap notice. const capBlocked = atCap && !hasAccess && !invited; return ( @@ -230,6 +268,7 @@ export function PeopleTab({ session, invitedThisSession, guestCap, tier, onUpgra invited={invited} capBlocked={capBlocked} onInvite={() => handleInvite(entry.target)} + onUninvite={onUninvite ? () => handleUninvite(entry.target.user_id) : undefined} t={t} /> ); diff --git a/src/components/terminal/ShareMenu.invitePeople.test.tsx b/src/components/terminal/ShareMenu.invitePeople.test.tsx index 79a714595..3cc69095d 100644 --- a/src/components/terminal/ShareMenu.invitePeople.test.tsx +++ b/src/components/terminal/ShareMenu.invitePeople.test.tsx @@ -24,7 +24,7 @@ vi.mock("@iconify/react", () => ({ Icon: () => null })); const roster = [{ user_id: "alice", team_id: "t1", display_name: "Alice", is_online: true, teamIds: ["t1"] }]; -const h = vi.hoisted(() => ({ allTeammates: vi.fn() })); +const h = vi.hoisted(() => ({ allTeammates: vi.fn(), uninviteFromSession: vi.fn() })); vi.mock("@/services/teamSharing", async () => { const actual = await vi.importActual("@/services/teamSharing"); return { ...actual, allTeammates: h.allTeammates }; @@ -42,6 +42,7 @@ vi.mock("@/stores/teamSessionStore", async () => { return { useTeamSessionStore: asStoreHook(makeMpState()) }; }); vi.mock("@/utils/clipboard", () => ({ writeClipboard: vi.fn(async () => {}) })); +vi.mock("@/services/teamService", () => ({ uninviteFromSession: h.uninviteFromSession })); import { useTeamStore } from "@/stores/teamStore"; import { useTeamSessionStore } from "@/stores/teamSessionStore"; @@ -63,6 +64,8 @@ beforeEach(() => { startSharingDirect.mockReset().mockResolvedValue("mp-1"); inviteToActiveSession.mockReset().mockResolvedValue(undefined); h.allTeammates.mockReset().mockResolvedValue(roster); + h.uninviteFromSession.mockReset().mockResolvedValue(undefined); + mpState.fetchActiveSessions.mockReset().mockResolvedValue(undefined); }); afterEach(() => cleanup()); @@ -126,15 +129,45 @@ test("adds a teammate to the live session when already sharing", async () => { expect(inviteToActiveSession).toHaveBeenCalledWith("local-1", expect.objectContaining({ user_id: "alice" })); }); -test("an already-invited teammate renders as non-tappable Has access", async () => { +// A grant nobody has accepted is "Invited", not "Has access": the first is a +// seat the host can take back, the second is someone already in the room. +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 }); expect((row as HTMLButtonElement).disabled).toBe(true); + expect(row.textContent).toContain("terminal.share.inviteSent"); + expect(row.textContent).not.toContain("terminal.share.inviteHasAccess"); +}); + +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" }], + }); + render(shareMenuElement()); + + const row = await screen.findByRole("button", { name: /alice/i }); expect(row.textContent).toContain("terminal.share.inviteHasAccess"); }); +// A6: without this the pending invite holds the seat until the session ends, +// which strands a Pro host at cap 1 whose invitee never arrives. +test("withdrawing a pending invite calls the server and refreshes the seat count", async () => { + mpState.activeSessions = [{ id: "mp-1", invitee_ids: ["alice"] }]; + renderShareMenu({ sharing: true }); + + await userEvent.click(await screen.findByRole("button", { name: "terminal.share.withdrawInvite" })); + expect(h.uninviteFromSession).toHaveBeenCalledWith("mp-1", "alice"); + expect(mpState.fetchActiveSessions).toHaveBeenCalled(); +}); + +test("a row that is neither invited nor joined offers no withdraw control", async () => { + renderShareMenu({ sharing: true }); + await screen.findByRole("button", { name: /alice/i }); + expect(screen.queryByRole("button", { name: "terminal.share.withdrawInvite" })).toBeNull(); +}); + test("hides the People tab's content in the active view when no session key is retained (invite_link)", async () => { mpState.connections = hostConnection({ sessionKeyBytes: undefined }); render(shareMenuElement()); diff --git a/src/components/terminal/ShareMenu.testHarness.ts b/src/components/terminal/ShareMenu.testHarness.ts index dfc6cf965..00388a796 100644 --- a/src/components/terminal/ShareMenu.testHarness.ts +++ b/src/components/terminal/ShareMenu.testHarness.ts @@ -20,6 +20,7 @@ export interface MpState { startSharingDirect: ReturnType; inviteToActiveSession: ReturnType; stopSharing: ReturnType; + fetchActiveSessions: ReturnType; } export function makeTeamState(): TeamState { @@ -41,6 +42,7 @@ export function makeMpState(): MpState { startSharingDirect: vi.fn(async () => "mp-1"), inviteToActiveSession: vi.fn(async () => {}), stopSharing: vi.fn(async () => {}), + fetchActiveSessions: vi.fn(async () => {}), }; } diff --git a/src/components/terminal/ShareMenu.tsx b/src/components/terminal/ShareMenu.tsx index ff97b9162..391be9dc3 100644 --- a/src/components/terminal/ShareMenu.tsx +++ b/src/components/terminal/ShareMenu.tsx @@ -6,6 +6,7 @@ import { Icon } from "@iconify/react"; import { useTeamStore } from "@/stores/teamStore"; import { useTeamSessionStore } from "@/stores/teamSessionStore"; import { buildInviteCode } from "@/services/inviteCode"; +import { uninviteFromSession } from "@/services/teamService"; import { guestCapFor, highestOwnerTier, inviteSessionOf, membersOfTeams, seatUsage, type InviteSession, type InviteTarget, type ShareTier } from "@/services/teamSharing"; import { useDelayedUnmount } from "@/hooks/useDelayedUnmount"; import { InviteCodeField } from "./InviteCodeField"; @@ -196,6 +197,21 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio setInvitedThisSession((prev) => new Set(prev).add(target.user_id)); }; + // Withdraws a standing invite (A6). The refetch is what frees the seat in the + // UI: `invitedIds` comes from the server's session record, and without it the + // host stays at "1 of 1 guest" with the invite already gone server-side. + const handleUninvite = async (userId: string) => { + const sessionId = activeMp?.multiplayerSessionId; + if (!sessionId) return; + await uninviteFromSession(sessionId, userId); + setInvitedThisSession((prev) => { + const next = new Set(prev); + next.delete(userId); + return next; + }); + await useTeamSessionStore.getState().fetchActiveSessions(); + }; + const handleStopSharing = async () => { setLoading(true); try { @@ -303,6 +319,7 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio inviteSession={inviteSession} invitedThisSession={invitedThisSession} onInvite={handleInvite} + onUninvite={handleUninvite} onStop={handleStopSharing} onUpgrade={onUpgrade} /> @@ -406,6 +423,7 @@ function ActiveSharingView({ inviteSession, invitedThisSession, onInvite, + onUninvite, onStop, onUpgrade, }: { @@ -419,6 +437,7 @@ function ActiveSharingView({ inviteSession: InviteSession; invitedThisSession: ReadonlySet; onInvite: (target: InviteTarget) => Promise; + onUninvite: (userId: string) => Promise; onStop: () => void; onUpgrade: () => void; }) { @@ -491,6 +510,7 @@ function ActiveSharingView({ tier={tier} onUpgrade={onUpgrade} onInvite={onInvite} + onUninvite={onUninvite} /> )} diff --git a/src/i18n/locales/en/terminal.json b/src/i18n/locales/en/terminal.json index 675367b4f..ef94e63f3 100644 --- a/src/i18n/locales/en/terminal.json +++ b/src/i18n/locales/en/terminal.json @@ -147,6 +147,8 @@ "inviteSent": "Invited", "inviteCapReached": "Cap reached", "inviteFailed": "Could not invite {{name}}", + "withdrawInvite": "Withdraw", + "uninviteFailed": "Could not withdraw the invite", "inviteNoTeammates": "No teammates yet", "inviteLoadFailed": "Could not load teammates", "peopleSearchPlaceholder": "Search by name, @handle, or email…", diff --git a/src/i18n/locales/fr/terminal.json b/src/i18n/locales/fr/terminal.json index 6562b9be8..c4d98b5e1 100644 --- a/src/i18n/locales/fr/terminal.json +++ b/src/i18n/locales/fr/terminal.json @@ -147,6 +147,8 @@ "inviteSent": "Invité", "inviteCapReached": "Limite atteinte", "inviteFailed": "Impossible d'inviter {{name}}", + "withdrawInvite": "Retirer", + "uninviteFailed": "Impossible de retirer l'invitation", "inviteNoTeammates": "Aucun coéquipier pour l'instant", "inviteLoadFailed": "Impossible de charger les coéquipiers", "peopleSearchPlaceholder": "Rechercher par nom, @pseudo ou e-mail…", diff --git a/src/i18n/locales/ru/terminal.json b/src/i18n/locales/ru/terminal.json index b8b86ab81..ff7e53560 100644 --- a/src/i18n/locales/ru/terminal.json +++ b/src/i18n/locales/ru/terminal.json @@ -157,6 +157,8 @@ "inviteSent": "Приглашён", "inviteCapReached": "Лимит достигнут", "inviteFailed": "Не удалось пригласить {{name}}", + "withdrawInvite": "Отозвать", + "uninviteFailed": "Не удалось отозвать приглашение", "inviteNoTeammates": "Пока нет коллег по команде", "inviteLoadFailed": "Не удалось загрузить список коллег", "peopleSearchPlaceholder": "Поиск по имени, @псевдониму или e-mail…", diff --git a/src/i18n/locales/zh/terminal.json b/src/i18n/locales/zh/terminal.json index d3d3f184f..405d8cd98 100644 --- a/src/i18n/locales/zh/terminal.json +++ b/src/i18n/locales/zh/terminal.json @@ -147,6 +147,8 @@ "inviteSent": "已邀请", "inviteCapReached": "已达上限", "inviteFailed": "无法邀请 {{name}}", + "withdrawInvite": "撤回", + "uninviteFailed": "无法撤回邀请", "inviteNoTeammates": "暂无队友", "inviteLoadFailed": "无法加载队友列表", "peopleSearchPlaceholder": "按姓名、@handle 或邮箱搜索…", diff --git a/src/services/teamSharing.allTeammates.test.ts b/src/services/teamSharing.allTeammates.test.ts index eb0d8f219..20389e459 100644 --- a/src/services/teamSharing.allTeammates.test.ts +++ b/src/services/teamSharing.allTeammates.test.ts @@ -9,7 +9,7 @@ vi.mock("@/services/teamService", async (importOriginal) => ({ })); import { useTeamStore } from "@/stores/teamStore"; -import { allTeammates, freshPublicKeys, memberHasAccess } from "./teamSharing.ts"; +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: "", @@ -65,6 +65,24 @@ test("reports access through a vault, a live participation, or an existing grant expect(memberHasAccess({ ...dave, teamIds: ["t4"] }, session)).toBe(false); }); +// A standing invite counts against the cap but is not "Has access": the host may +// still withdraw it, and the row must say so. +test("a pending invite is not live access, though it still counts as access", () => { + const session = { vaultIds: ["t1"], participantIds: ["bob"], invitedIds: ["carla"] }; + expect(memberHasLiveAccess({ ...alice, teamIds: ["t1"] }, session)).toBe(true); + expect(memberHasLiveAccess({ ...bob, teamIds: ["t2"] }, session)).toBe(true); + expect(memberHasLiveAccess({ ...carla, teamIds: ["t3"] }, session)).toBe(false); + expect(memberHasAccess({ ...carla, teamIds: ["t3"] }, session)).toBe(true); +}); + +test("withdrawing a pending invite frees its seat", () => { + const cap = 1; + const invited = { vaultIds: [], participantIds: [], invitedIds: ["carla"] }; + expect(seatUsage(invited, [], cap)).toEqual({ committedSeats: 1, atCap: true }); + // What the server reports back after DELETE .../invitees/carla. + expect(seatUsage({ ...invited, invitedIds: [] }, [], cap)).toEqual({ committedSeats: 0, atCap: false }); +}); + test("a shared teammate has access via any of their team_ids, not just the first", () => { const sharedAlice = { ...alice, teamIds: ["t1", "t2"] }; expect(memberHasAccess(sharedAlice, { vaultIds: ["t2"], participantIds: [], invitedIds: [] })).toBe(true); diff --git a/src/services/teamSharing.ts b/src/services/teamSharing.ts index e7c6d21d0..74ff5cf7f 100644 --- a/src/services/teamSharing.ts +++ b/src/services/teamSharing.ts @@ -133,18 +133,34 @@ export function seatUsage( return { committedSeats, atCap: committedSeats >= guestCap }; } -/** Whether a member already has a route into the session: a shared vault, live participation, or a standing invite. */ -export function memberHasAccess( +/** + * Whether a member is *in* the session already: a shared vault or live + * participation. A standing invite is deliberately not this — it is the + * `Invited` row state, which the host may still withdraw. + */ +export function memberHasLiveAccess( member: { user_id: string; teamIds: string[] }, session: InviteSession, ): boolean { return ( member.teamIds.some((id) => session.vaultIds.includes(id)) || - session.participantIds.includes(member.user_id) || - session.invitedIds.includes(member.user_id) + session.participantIds.includes(member.user_id) ); } +/** + * Whether a member already has a route into the session: a shared vault, live + * participation, or a standing invite. This is the seat-arithmetic question — + * a pending invite counts — so it is not the same as the row's label; see + * `memberHasLiveAccess`. + */ +export function memberHasAccess( + member: { user_id: string; teamIds: string[] }, + session: InviteSession, +): boolean { + return memberHasLiveAccess(member, session) || session.invitedIds.includes(member.user_id); +} + /** * Groups are labels, not modes: typing filters Recent and Your teams locally and * adds Elsewhere on Voltius from the server's results. A person is listed once — From 4f4ca9d1ff3885aa4e7c237ca798ad47e7a11caf Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 08:29:04 +0000 Subject: [PATCH 23/24] feat(account): put the handle in the account menu, and point free-tier copy at it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B4's account-menu surface was never built, and the free-tier note offered the user's email as the way to be reached — the opposite of why every account gets a handle. Copy-with-feedback is now one hook shared by both surfaces. --- .../layout/SidebarAccountButton.tsx | 26 +++++++++++++++++-- .../settings/sections/AccountSection.tsx | 12 ++------- src/hooks/useCopyHandle.ts | 21 +++++++++++++++ src/i18n/locales/en/layout.json | 1 + src/i18n/locales/en/settings.json | 2 +- src/i18n/locales/fr/layout.json | 1 + src/i18n/locales/fr/settings.json | 2 +- src/i18n/locales/ru/layout.json | 1 + src/i18n/locales/ru/settings.json | 2 +- src/i18n/locales/zh/layout.json | 1 + src/i18n/locales/zh/settings.json | 2 +- 11 files changed, 55 insertions(+), 16 deletions(-) create mode 100644 src/hooks/useCopyHandle.ts diff --git a/src/components/layout/SidebarAccountButton.tsx b/src/components/layout/SidebarAccountButton.tsx index e4b70198f..dd9fedfc5 100644 --- a/src/components/layout/SidebarAccountButton.tsx +++ b/src/components/layout/SidebarAccountButton.tsx @@ -5,9 +5,10 @@ import { useTranslation } from "react-i18next"; import { useUIStore } from "@/stores/uiStore"; import { useThemeStore } from "@/stores/themeStore"; import { useRipple } from "@/hooks/useRipple"; -import { getAccountMode, lockVaultSession, logout } from "@/services/account"; +import { getAccountMode, getMe, 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"; export function SidebarAccountButton() { const { t } = useTranslation(); @@ -22,17 +23,26 @@ export function SidebarAccountButton() { const [accountEmail, setAccountEmail] = useState(null); const [savedAccounts, setSavedAccounts] = useState([]); const [currentAccountId, setCurrentAccountId] = useState(null); + const [accountHandle, setAccountHandle] = useState(null); + const { copied: handleCopied, copy: copyHandle } = useCopyHandle(accountHandle); const refreshAccountInfo = async () => { const { invoke: inv } = await import("@tauri-apps/api/core"); - const [mode, email, accountId] = await Promise.all([ + const [mode, email, accountId, handle] = 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(() => {}); + } }; useEffect(() => { refreshAccountInfo(); }, []); @@ -137,6 +147,18 @@ export function SidebarAccountButton() { {accountEmail ?? t("layout.sidebarAccount.localAccountFallback")}
+ {accountHandle && ( + + )} {accountMode && ( {accountMode === "server" ? t("layout.sidebarAccount.modeCloud") : accountMode === "local" ? t("layout.sidebarAccount.modeLocalPassword") : t("layout.sidebarAccount.modeLocal")} diff --git a/src/components/settings/sections/AccountSection.tsx b/src/components/settings/sections/AccountSection.tsx index 7e983fc94..bff99c96b 100644 --- a/src/components/settings/sections/AccountSection.tsx +++ b/src/components/settings/sections/AccountSection.tsx @@ -10,7 +10,7 @@ import { openPortal } from "@/utils/billing"; import { openBillingCheckout } from "@/services/billingCheckout"; import { claimHandle, updateInvitePreferences, HandleClaimError } from "@/services/teamService"; import { Toggle } from "@/components/shared/Toggle"; -import { writeClipboard } from "@/utils/clipboard"; +import { useCopyHandle } from "@/hooks/useCopyHandle"; import EditEmailModal from "./EditEmailModal"; import ChangeMasterPasswordModal from "./ChangeMasterPasswordModal"; @@ -108,7 +108,6 @@ export default function AccountSection() { const [handleIsCustom, setHandleIsCustom] = useState(false); const [meTier, setMeTier] = useState(undefined); const [tierKnown, setTierKnown] = useState(false); - const [handleCopied, setHandleCopied] = useState(false); const [allowStrangerInvites, setAllowStrangerInvites] = useState(true); const [strangerInvitesError, setStrangerInvitesError] = useState(""); const [strangerInvitesLoading, setStrangerInvitesLoading] = useState(false); @@ -135,14 +134,7 @@ export default function AccountSection() { // already has exactly that. const isFreeTier = tierKnown && (!meTier || meTier === "free"); const isLapsedCustom = isFreeTier && handleIsCustom; - - const handleCopyHandle = () => { - if (!handle) return; - writeClipboard(`@${handle}`).then(() => { - setHandleCopied(true); - setTimeout(() => setHandleCopied(false), 1500); - }).catch(() => {}); - }; + const { copied: handleCopied, copy: handleCopyHandle } = useCopyHandle(handle); const toggleStrangerInvites = async (next: boolean) => { setStrangerInvitesLoading(true); // blocks the switch until this round trip resolves — a second click mid-flight can't race the first diff --git a/src/hooks/useCopyHandle.ts b/src/hooks/useCopyHandle.ts new file mode 100644 index 000000000..d98d7c7ce --- /dev/null +++ b/src/hooks/useCopyHandle.ts @@ -0,0 +1,21 @@ +import { useState } from "react"; +import { writeClipboard } from "@/utils/clipboard"; + +/** + * One-tap "copy my address": writes `@handle` and flips a transient copied flag. + * Shared by the two surfaces B4 puts the handle on — Settings → Account and the + * account menu — so both spell the address the same way. + */ +export function useCopyHandle(handle: string | null): { copied: boolean; copy: () => void } { + const [copied, setCopied] = useState(false); + const copy = () => { + if (!handle) return; + writeClipboard(`@${handle}`) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }) + .catch(() => {}); + }; + return { copied, copy }; +} diff --git a/src/i18n/locales/en/layout.json b/src/i18n/locales/en/layout.json index e953b68c0..3ccfd991e 100644 --- a/src/i18n/locales/en/layout.json +++ b/src/i18n/locales/en/layout.json @@ -86,6 +86,7 @@ "localShellFallback": "Local shell" }, "sidebarAccount": { + "copyHandle": "Copy your handle", "accountTitle": "Account", "localAccountFallback": "Local Account", "modeCloud": "Cloud account", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 8c8940726..895742f7d 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -262,7 +262,7 @@ "inputLabel": "Handle", "save": "Save", "upsell": "Custom handles require Pro or higher.", - "reachableNote": "People can still reach you by your full email address.", + "reachableNote": "People can still reach you by your @handle — a custom one just makes you searchable.", "lapsedKeepsHandle": "Your plan lapsed, but you keep your existing handle.", "lapsedRenameLocked": "Renaming is locked until you resubscribe.", "errorInvalid": "Handles can only contain letters, numbers, and hyphens.", diff --git a/src/i18n/locales/fr/layout.json b/src/i18n/locales/fr/layout.json index 5406eb1ab..fed38e69e 100644 --- a/src/i18n/locales/fr/layout.json +++ b/src/i18n/locales/fr/layout.json @@ -86,6 +86,7 @@ "localShellFallback": "Shell local" }, "sidebarAccount": { + "copyHandle": "Copier votre pseudo", "accountTitle": "Compte", "localAccountFallback": "Compte local", "modeCloud": "Compte cloud", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 5b7a0ba9f..9860a9999 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -262,7 +262,7 @@ "inputLabel": "Pseudo", "save": "Enregistrer", "upsell": "Les pseudos personnalisés nécessitent l'offre Pro ou supérieure.", - "reachableNote": "Vous restez joignable par votre adresse e-mail complète.", + "reachableNote": "Vous restez joignable par votre @pseudo — un pseudo personnalisé vous rend simplement trouvable.", "lapsedKeepsHandle": "Votre offre a expiré, mais vous conservez votre pseudo actuel.", "lapsedRenameLocked": "Le changement de pseudo est verrouillé jusqu'à votre réabonnement.", "errorInvalid": "Un pseudo ne peut contenir que des lettres, des chiffres et des tirets.", diff --git a/src/i18n/locales/ru/layout.json b/src/i18n/locales/ru/layout.json index c418ce35b..c938507d1 100644 --- a/src/i18n/locales/ru/layout.json +++ b/src/i18n/locales/ru/layout.json @@ -86,6 +86,7 @@ "localShellFallback": "Локальная оболочка" }, "sidebarAccount": { + "copyHandle": "Скопировать псевдоним", "accountTitle": "Учётная запись", "localAccountFallback": "Локальная учётная запись", "modeCloud": "Облачная учётная запись", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 351fbe3f5..7420a895f 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -262,7 +262,7 @@ "inputLabel": "Псевдоним", "save": "Сохранить", "upsell": "Пользовательские псевдонимы требуют тариф Pro или выше.", - "reachableNote": "Вас всё равно можно найти по полному адресу e-mail.", + "reachableNote": "С вами всё равно можно связаться по вашему @псевдониму — свой псевдоним лишь делает вас находимым в поиске.", "lapsedKeepsHandle": "Ваш тариф истёк, но текущий псевдоним сохраняется за вами.", "lapsedRenameLocked": "Переименование заблокировано до возобновления подписки.", "errorInvalid": "Псевдоним может содержать только буквы, цифры и дефисы.", diff --git a/src/i18n/locales/zh/layout.json b/src/i18n/locales/zh/layout.json index fe61f57b8..5f4e22223 100644 --- a/src/i18n/locales/zh/layout.json +++ b/src/i18n/locales/zh/layout.json @@ -86,6 +86,7 @@ "localShellFallback": "本地 Shell" }, "sidebarAccount": { + "copyHandle": "复制您的 handle", "accountTitle": "账户", "localAccountFallback": "本地账户", "modeCloud": "云账户", diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 0862aff11..6e9e44cb1 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -149,7 +149,7 @@ "inputLabel": "Handle", "save": "保存", "upsell": "自定义 handle 需要 Pro 或更高套餐。", - "reachableNote": "他人仍可通过您的完整邮箱地址联系您。", + "reachableNote": "他人仍可通过您的 @handle 联系您 — 自定义 handle 只是让您可被搜索到。", "lapsedKeepsHandle": "您的套餐已过期,但仍保留现有 handle。", "lapsedRenameLocked": "重新订阅前无法更改 handle。", "errorInvalid": "Handle 只能包含字母、数字和连字符。", From 867bb919c8084070d8981c3d0514c83be72b2816 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 12:02:17 +0000 Subject: [PATCH 24/24] fix(account): clear the cached handle and display name on sign-out resetVault kept its own literal list of keychain entries, so a key that account.ts cached but the list omitted survived a sign-out and was served to the next account: the account menu showed the previous user's handle. Both sides now read one list, and a test asserts it against the writers rather than against a copy of itself. --- src/services/accountCacheKeys.test.ts | 23 +++++++++++++++++++++++ src/services/accountCacheKeys.ts | 22 ++++++++++++++++++++++ src/services/vault.ts | 3 ++- 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/services/accountCacheKeys.test.ts create mode 100644 src/services/accountCacheKeys.ts diff --git a/src/services/accountCacheKeys.test.ts b/src/services/accountCacheKeys.test.ts new file mode 100644 index 000000000..d1d4e5cfb --- /dev/null +++ b/src/services/accountCacheKeys.test.ts @@ -0,0 +1,23 @@ +import { test, expect } from "vitest"; +import accountSource from "./account.ts?raw"; +import vaultSource from "./vault.ts?raw"; +import { ACCOUNT_CACHE_KEYS } from "./accountCacheKeys"; + +/** + * Read against the writers rather than a hand-kept list: a key that account.ts + * caches but resetVault never clears survives a sign-out and is then served to + * the *next* account. That shipped once — the account menu showed the previous + * user's handle — and a unit test over resetVault alone could not have caught + * it, because nothing in that function knows what it is missing. + */ +test("every keychain key account.ts caches is cleared on sign-out", () => { + const cached = [...accountSource.matchAll(/keychainSet\("([^"]+)"/g)].map((m) => m[1]); + + expect(cached.length).toBeGreaterThan(0); + const missing = cached.filter((key) => !ACCOUNT_CACHE_KEYS.includes(key as never)); + expect(missing).toEqual([]); +}); + +test("resetVault clears the account cache list, not a literal of its own", () => { + expect(vaultSource).toContain("of ACCOUNT_CACHE_KEYS"); +}); diff --git a/src/services/accountCacheKeys.ts b/src/services/accountCacheKeys.ts new file mode 100644 index 000000000..59fca62fd --- /dev/null +++ b/src/services/accountCacheKeys.ts @@ -0,0 +1,22 @@ +/** + * Every keychain entry that belongs to the signed-in account. + * + * Lives apart from `account.ts` (which writes them) and `vault.ts` (which clears + * them on sign-out) because those two already import in one direction. A key + * cached but not listed here survives a sign-out and is then read by the next + * account: `handle` did exactly that, showing the previous user's `@handle` in + * the account menu. + */ +export const ACCOUNT_CACHE_KEYS = [ + "master_password", + "account_id", + "mode", + "email", + "display_name", + "handle", + "jwt", + "refresh_token", + "server_url", + "device_id", + "wrapped_user_secrets", +] as const; diff --git a/src/services/vault.ts b/src/services/vault.ts index 81ff67945..fecd63704 100644 --- a/src/services/vault.ts +++ b/src/services/vault.ts @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import i18n from "@/i18n"; import { clearPersistedAccountUiState } from "@/stores/persistedAccountUiState"; +import { ACCOUNT_CACHE_KEYS } from "./accountCacheKeys"; // Pending key: set at login/setup, used to unlock secrets on first access let pendingKey: number[] | null = null; @@ -81,7 +82,7 @@ export async function resetVault(): Promise { await invoke("vault_reset"); // deletes secrets.enc + connections.json + legacy vault.hold // Clear all keychain entries so the app starts fresh - for (const key of ["master_password", "account_id", "mode", "email", "jwt", "refresh_token", "server_url", "device_id", "wrapped_user_secrets"]) { + for (const key of ACCOUNT_CACHE_KEYS) { await invoke("keychain_delete", { key }).catch(() => {}); } }