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/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/members/MembersPage.tsx b/src/components/members/MembersPage.tsx index 32b20888f..bdd56808c 100644 --- a/src/components/members/MembersPage.tsx +++ b/src/components/members/MembersPage.tsx @@ -35,10 +35,10 @@ 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 } 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 { @@ -1189,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/components/settings/sections/AccountSection.handle.test.tsx b/src/components/settings/sections/AccountSection.handle.test.tsx new file mode 100644 index 000000000..e344af87d --- /dev/null +++ b/src/components/settings/sections/AccountSection.handle.test.tsx @@ -0,0 +1,150 @@ +import { test, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, waitFor } 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(); + } +}); + +test("a lapsed pro user keeps the custom-handle message, not the upsell", async () => { + h.getMe.mockResolvedValue({ handle: "kevin-p", handle_is_custom: true, tier: "free", allow_stranger_invites: true }); + render(); + await screen.findByText("@kevin-p"); + expect(screen.getByText("settings.account.handle.lapsedKeepsHandle")).toBeTruthy(); + expect(screen.queryByText("settings.account.handle.upsell")).toBeNull(); +}); + +test("no upsell flash before the tier is known", async () => { + let resolveMe!: (v: MeResponse) => void; + h.getMe.mockReturnValue(new Promise((resolve) => { resolveMe = resolve; })); + render(); + // Wait for mode ("server") to resolve and the handle block to mount, while + // getMe (and so the tier) is still pending — this is the exact window a + // paying user would otherwise see the free-tier upsell flash in. + await screen.findByText("settings.account.handle.title"); + expect(screen.queryByText("settings.account.handle.upsell")).toBeNull(); + expect(screen.queryByRole("button", { name: "settings.account.handle.choose" })).toBeNull(); + resolveMe({ handle: "swift-otter-4821", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); + expect(await screen.findByRole("button", { name: "settings.account.handle.choose" })).toBeTruthy(); +}); + +test("the stranger-invite toggle disables itself mid-flight so a second click can't race it", async () => { + h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); + let resolveUpdate!: () => void; + h.updateInvitePreferences.mockReturnValue(new Promise((resolve) => { resolveUpdate = resolve; })); + render(); + const toggle = await screen.findByRole("switch", { name: "settings.account.strangerInvites.label" }); + await userEvent.click(toggle); + expect(toggle.hasAttribute("disabled")).toBe(true); + resolveUpdate(); + await waitFor(() => expect(toggle.hasAttribute("disabled")).toBe(false)); +}); diff --git a/src/components/settings/sections/AccountSection.tsx b/src/components/settings/sections/AccountSection.tsx index d14bdb0ec..bff99c96b 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 { useCopyHandle } from "@/hooks/useCopyHandle"; 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,56 @@ 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 [tierKnown, setTierKnown] = useState(false); + const [allowStrangerInvites, setAllowStrangerInvites] = useState(true); + const [strangerInvitesError, setStrangerInvitesError] = useState(""); + const [strangerInvitesLoading, setStrangerInvitesLoading] = useState(false); 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); }, + ); + // Lapsing from Pro drops back to "free" but keeps a custom handle and its + // searchability — only the ability to rename is gated on tier. That account + // must never see the "upgrade to get a searchable handle" upsell, since it + // already has exactly that. + const isFreeTier = tierKnown && (!meTier || meTier === "free"); + const isLapsedCustom = isFreeTier && handleIsCustom; + 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 + 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)); + } finally { + setStrangerInvitesLoading(false); + } + }; + const SESSION_TIMEOUT_OPTIONS = [ { label: t("settings.account.sessionSecurity.timeout.never"), value: "never" }, { label: t("settings.account.sessionSecurity.timeout.5min"), value: "5" }, @@ -60,7 +162,15 @@ 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); + setTierKnown(true); + if (typeof me.allow_stranger_invites === "boolean") setAllowStrangerInvites(me.allow_stranger_invites); + }).catch(() => {}); setStep("idle"); setError(""); setSuccess(""); @@ -132,6 +242,88 @@ export default function AccountSection() { + {mode === "server" && ( +
+

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

+
+
+ + {handle ? `@${handle}` : "—"} + + +
+ + {!tierKnown ? null : isLapsedCustom ? ( +
+

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

+

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

+
+ ) : 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 +376,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 +417,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/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/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 ( - ); - })} -
- )} - - ); -} diff --git a/src/components/terminal/PeopleTab.test.tsx b/src/components/terminal/PeopleTab.test.tsx new file mode 100644 index 000000000..b0c2e52f0 --- /dev/null +++ b/src/components/terminal/PeopleTab.test.tsx @@ -0,0 +1,288 @@ +import { test, expect, vi, beforeEach, afterEach } from "vitest"; +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", () => ({ + 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")); +}); + +// An older server omits `handle` from /members, so a teammate row must never +// render a dangling "@" with nothing after it — it shipped once already. +test("a teammate row with no handle shows its name and renders no handle line", async () => { + h.allTeammates.mockResolvedValue([{ user_id: "u-alice", team_id: "t1", display_name: "Alice", is_online: true, teamIds: ["t1"] }]); + render(); + const row = await screen.findByRole("button", { name: /alice/i }); + expect(within(row).getByText("Alice")).toBeTruthy(); + expect(row.textContent).not.toMatch(/@/); +}); + +test("Recent's own empty state stands alone even while Your teams has results", async () => { + h.allTeammates.mockResolvedValue([{ user_id: "u-alice", team_id: "t1", display_name: "Alice", is_online: true, teamIds: ["t1"] }]); + render(); + 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(); +}); + +// 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; + 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); +}); + +// ─── 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(); + 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 new file mode 100644 index 000000000..539eef21c --- /dev/null +++ b/src/components/terminal/PeopleTab.tsx @@ -0,0 +1,352 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Icon } from "@iconify/react"; +import { useTeamStore } from "@/stores/teamStore"; +import { + allTeammates, + groupPeople, + memberHasLiveAccess, + 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, 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; + 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. */ +interface RowEntry { + target: InviteTarget; + /** 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; +} + +function ErrorBanner({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function PersonRow({ + entry, + hasAccess, + inFlight, + invited, + capBlocked, + onInvite, + onUninvite, + t, +}: { + entry: RowEntry; + hasAccess: boolean; + inFlight: boolean; + 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 && ( + + )} +
+ ); +} + +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); + const forget = useRecentPeopleStore((s) => s.forget); + 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); + + // 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); setTeammatesLoadFailed(false); } }) + .catch(() => { if (!cancelled) { setTeammates([]); setTeammatesLoadFailed(true); } }); + 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); + } + }; + + // 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) => { + // 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, + isStranger: false, + isOnline: !!m.is_online, + })); + const strangerEntries: RowEntry[] = groups.strangers.map((s) => ({ + target: { user_id: s.user_id, display_name: s.display_name, handle: s.handle }, + teamIds: [], + isStranger: true, + })); + + const renderRow = (entry: RowEntry) => { + // "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 = + !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 ( + handleInvite(entry.target)} + onUninvite={onUninvite ? () => handleUninvite(entry.target.user_id) : undefined} + 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}} + + {teammatesLoadFailed && {t("terminal.share.inviteLoadFailed")}} + + {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/components/terminal/ShareMenu.invitePeople.test.tsx b/src/components/terminal/ShareMenu.invitePeople.test.tsx index 2c164572a..3cc69095d 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 () => { @@ -26,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 }; @@ -44,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"; @@ -65,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()); @@ -128,20 +129,50 @@ 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"); }); -test("hides the invite section in the active view when no session key is retained (invite_link)", async () => { +// 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()); 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 +190,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 +200,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 +230,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..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 the invite-link tab - // is the only tab, so it renders directly without a tab click. + // 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={() => {}} />, @@ -78,6 +78,8 @@ function renderMenu() { async function generateInviteLink() { renderMenu(); + // People is the default tab now; switch to Link before generating. + fireEvent.click(screen.getByText("terminal.share.tabInviteLink")); fireEvent.click(screen.getByText("terminal.share.generateInviteLink")); await waitFor(() => expect(mpState.startSharingInviteLink).toHaveBeenCalled()); } @@ -130,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.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 3c94b2342..391be9dc3 100644 --- a/src/components/terminal/ShareMenu.tsx +++ b/src/components/terminal/ShareMenu.tsx @@ -6,12 +6,15 @@ 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 { 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"; -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 +33,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 +43,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 +79,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 +191,25 @@ 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)); + }; + + // 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 () => { @@ -198,16 +222,22 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio } }; - if (!open) return null; + if (!mounted) return null; return createPortal(
e.stopPropagation()} > @@ -289,6 +319,7 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio inviteSession={inviteSession} invitedThisSession={invitedThisSession} onInvite={handleInvite} + onUninvite={handleUninvite} onStop={handleStopSharing} onUpgrade={onUpgrade} /> @@ -313,7 +344,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 +359,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 +372,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" && ( - - )} )} , @@ -395,6 +423,7 @@ function ActiveSharingView({ inviteSession, invitedThisSession, onInvite, + onUninvite, onStop, onUpgrade, }: { @@ -407,7 +436,8 @@ function ActiveSharingView({ tier: ShareTier; inviteSession: InviteSession; invitedThisSession: ReadonlySet; - onInvite: (member: TeamMember) => Promise; + onInvite: (target: InviteTarget) => Promise; + onUninvite: (userId: string) => Promise; onStop: () => void; onUpgrade: () => void; }) { @@ -473,13 +503,14 @@ function ActiveSharingView({ )} {canInviteDirectly && ( - )} 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/hooks/useDelayedUnmount.test.tsx b/src/hooks/useDelayedUnmount.test.tsx new file mode 100644 index 000000000..eb37fe5a4 --- /dev/null +++ b/src/hooks/useDelayedUnmount.test.tsx @@ -0,0 +1,27 @@ +import { test, expect, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useDelayedUnmount } from "./useDelayedUnmount"; + +test("stays mounted for the exit duration, then unmounts", async () => { + 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/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/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/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/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/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..895742f7d 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 @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.", + "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 9dc217ac1..ef94e63f3 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,23 @@ "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}}", + "withdrawInvite": "Withdraw", + "uninviteFailed": "Could not withdraw the invite", "inviteNoTeammates": "No teammates yet", - "inviteLoadFailed": "Could not load teammates" + "inviteLoadFailed": "Could not load teammates", + "peopleSearchPlaceholder": "Search by name, @handle, or email…", + "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": "People you invite will show up here.", + "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/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/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/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..9860a9999 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 @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.", + "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 bcad2e781..c4d98b5e1 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,23 @@ "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}}", + "withdrawInvite": "Retirer", + "uninviteFailed": "Impossible de retirer l'invitation", "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": "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": "Les personnes que vous invitez apparaîtront ici.", + "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/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/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/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..7420a895f 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": "С вами всё равно можно связаться по вашему @псевдониму — свой псевдоним лишь делает вас находимым в поиске.", + "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 3bc890f44..ff7e53560 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,23 @@ "upgradeToBusiness": "Перейти на Business", "hasControl": "Управляет", "stopSharing": "Остановить совместный доступ", - "invitePeople": "Пригласить людей", "inviteHasAccess": "Уже есть доступ", "inviteSent": "Приглашён", "inviteCapReached": "Лимит достигнут", "inviteFailed": "Не удалось пригласить {{name}}", + "withdrawInvite": "Отозвать", + "uninviteFailed": "Не удалось отозвать приглашение", "inviteNoTeammates": "Пока нет коллег по команде", - "inviteLoadFailed": "Не удалось загрузить список коллег" + "inviteLoadFailed": "Не удалось загрузить список коллег", + "peopleSearchPlaceholder": "Поиск по имени, @псевдониму или e-mail…", + "peopleNoMatch": "В ваших командах никто не соответствует «{{query}}».", + "peopleFindRule": "Людей вне ваших команд можно найти по их @псевдониму или полному адресу e-mail.", + "recentLabel": "Недавние", + "recentEmpty": "Приглашённые вами люди появятся здесь.", + "yourTeamsLabel": "Ваши команды", + "elsewhereLabel": "Другие пользователи Voltius", + "notInYourTeams": "Не в ваших командах", + "forgetPerson": "Убрать из недавних" }, "snippetVariableModal": { "on": "Вкл", 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/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/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..6e9e44cb1 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": "他人仍可通过您的 @handle 联系您 — 自定义 handle 只是让您可被搜索到。", + "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 d0cbc7a4c..405d8cd98 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,23 @@ "upgradeToBusiness": "升级到 Business", "hasControl": "拥有控制权", "stopSharing": "停止共享", - "invitePeople": "邀请队友", "inviteHasAccess": "已有访问权限", "inviteSent": "已邀请", "inviteCapReached": "已达上限", "inviteFailed": "无法邀请 {{name}}", + "withdrawInvite": "撤回", + "uninviteFailed": "无法撤回邀请", "inviteNoTeammates": "暂无队友", - "inviteLoadFailed": "无法加载队友列表" + "inviteLoadFailed": "无法加载队友列表", + "peopleSearchPlaceholder": "按姓名、@handle 或邮箱搜索…", + "peopleNoMatch": "您的团队中没有人与“{{query}}”匹配。", + "peopleFindRule": "团队之外的用户可通过其 @handle 或完整邮箱地址找到。", + "recentLabel": "最近", + "recentEmpty": "您邀请的人会显示在这里。", + "yourTeamsLabel": "您的团队", + "elsewhereLabel": "Voltius 上的其他人", + "notInYourTeams": "不在您的团队中", + "forgetPerson": "从最近记录中移除" }, "snippetVariableModal": { "on": "开", 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/account.ts b/src/services/account.ts index 9bc1db23a..cf4faed93 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -367,7 +367,19 @@ export async function getCurrentDisplayName(): Promise { return keychainGet("display_name"); } -export async function fetchAndCacheDisplayName(): Promise { +export interface MeResponse { + display_name?: string | null; + handle?: string; + handle_is_custom?: boolean; + allow_stranger_invites?: boolean; + tier?: string; +} + +/** Fetches /v1/auth/me and caches the display name and handle for offline use + * (e.g. getCurrentDisplayName). Returns the full payload so callers that need + * the live tier/preference fields — the settings identity UI — don't need a + * second round trip. */ +export async function getMe(): Promise { const [jwt, serverUrl] = await Promise.all([keychainGet("jwt"), keychainGet("server_url")]); if (!jwt || !serverUrl) return null; try { @@ -375,14 +387,20 @@ export async function fetchAndCacheDisplayName(): Promise { headers: { Authorization: `Bearer ${jwt}` }, }); if (!res.ok) return null; - const me = await res.json(); + const me: MeResponse = await res.json(); if (me.display_name) await keychainSet("display_name", me.display_name); - return me.display_name ?? null; + if (me.handle) await keychainSet("handle", me.handle); + return me; } catch { return null; } } +export async function fetchAndCacheDisplayName(): Promise { + const me = await getMe(); + return me?.display_name ?? null; +} + export async function updateDisplayName(newName: string): Promise { const [jwt, serverUrl] = await Promise.all([keychainGet("jwt"), keychainGet("server_url")]); if (!jwt || !serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); 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/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 408686fde..75c3eaee8 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"; @@ -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; @@ -22,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[]; } @@ -127,13 +134,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); @@ -146,12 +164,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 }; @@ -200,7 +231,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); @@ -230,14 +261,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")); @@ -250,7 +283,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.test.ts b/src/services/teamInbox.test.ts index cbff3c5db..3e6434793 100644 --- a/src/services/teamInbox.test.ts +++ b/src/services/teamInbox.test.ts @@ -11,10 +11,18 @@ 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 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 () => {}), + declineSessionInvite: vi.fn(async () => {}), getCurrentUserEmail: vi.fn(async () => "me@x" as string | null), isMobileShell: vi.fn(() => false), sessionState, @@ -23,6 +31,8 @@ const h = vi.hoisted(() => { useUIStore, joinSession, grantControl, + fetchActiveSessions, + teamSessionState, useTeamSessionStore, }; }); @@ -30,6 +40,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 +53,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 +90,9 @@ beforeEach(() => { h.isMobileShell.mockClear().mockReturnValue(false); h.joinSession.mockClear().mockResolvedValue("local-99"); 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; @@ -120,8 +137,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 +259,141 @@ 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", invited_by_handle: "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", + ]); +}); + +// 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("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(); + + await vi.waitFor(() => { + expect(h.fetchActiveSessions).toHaveBeenCalled(); + expect(h.sessionState.sessions).toHaveLength(1); + expect(h.sessionState.sessions[0].connectionName).toBe("web-prod"); + }); +}); + +// 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(); + + 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 () => { + 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 3c5f6eead..c300a7c58 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"; @@ -10,9 +11,10 @@ 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"; const APP_SOURCE = { kind: "app", area: "team" } as const; @@ -81,13 +83,53 @@ 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"); - await joinTeamSessionAndOpenTab({ + const localSessionId = await joinTeamSessionAndOpenTab({ sessionId: session.id, displayName, - connectionName: session.connection_name, + connectionName: sessionDisplayName(session), }); + + // 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); +} + +/** + * 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( @@ -103,19 +145,37 @@ 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 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 knock = invited && s.connection_name === null; + // A knock renders the server-resolved handle and nothing else. Participant + // display names arrive in the sender's own WebSocket query string, so + // 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 { 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 }), + 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 @@ -129,20 +189,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/services/teamService.invites.test.ts b/src/services/teamService.invites.test.ts new file mode 100644 index 000000000..0ba44be6f --- /dev/null +++ b/src/services/teamService.invites.test.ts @@ -0,0 +1,46 @@ +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(); +}); + +test("a 500 from the key lookup throws instead of masquerading as a missing user", async () => { + h.appFetch.mockResolvedValue({ ok: false, status: 500 }); + // 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"); +}); diff --git a/src/services/teamService.ts b/src/services/teamService.ts index 79d2fdfd1..6a3c91fdc 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 { @@ -247,7 +249,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,14 +265,96 @@ export async function searchUsers(q: string): Promise<{ user_id: string; display return res.json(); } -export async function updatePublicKey(publicKey: string): Promise { +export interface UserKeyLookup { + user_id: string; + display_name: string; + handle: string; + public_key: string; +} + +/** + * `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) throw new Error(i18n.t("common.error.notConnectedToServer")); + const res = await fetchAuth(`${serverUrl}/v1/users/${userId}/public-key`); + if (res.status === 404) return null; + if (!res.ok) throw new Error(i18n.t("common.error.failedToFetchPublicKey", { status: res.status })); + return res.json(); +} + +export class HandleClaimError extends Error { + constructor(public status: number) { + super(`handle claim failed: ${status}`); + } +} + +// 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")); - const res = await fetchAuth(`${serverUrl}/v1/auth/public-key`, { + const res = await fetchAuth(`${serverUrl}/v1/users/me/handle`, { method: "PUT", - body: JSON.stringify({ public_key: publicKey }), + body: JSON.stringify({ handle }), }); - if (!res.ok) throw new Error(i18n.t("common.error.failedToUpdatePublicKey", { status: res.status })); + if (!res.ok) throw new HandleClaimError(res.status); +} + +/** + * 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, interpolate?.(res.status))); +} + +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 query = opts?.permanent ? "?block=permanent" : ""; + await authedCall( + `/v1/terminal-sessions/${sessionId}/invitees/me${query}`, + { method: "DELETE" }, + "common.error.failedToDecline", + ); +} + +export async function uninviteFromSession(sessionId: string, userId: string): Promise { + await authedCall( + `/v1/terminal-sessions/${sessionId}/invitees/${userId}`, + { method: "DELETE" }, + "common.error.failedToUninvite", + ); +} + +export async function updatePublicKey(publicKey: string): Promise { + await authedCall( + "/v1/auth/public-key", + { method: "PUT", body: JSON.stringify({ public_key: publicKey }) }, + "common.error.failedToUpdatePublicKey", + (status) => ({ status }), + ); } export async function getJwtToken(): Promise { diff --git a/src/services/teamSharing.allTeammates.test.ts b/src/services/teamSharing.allTeammates.test.ts index 648e43bf8..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); @@ -81,7 +99,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.grouping.test.ts b/src/services/teamSharing.grouping.test.ts new file mode 100644 index 000000000..5b6fbe58d --- /dev/null +++ b/src/services/teamSharing.grouping.test.ts @@ -0,0 +1,34 @@ +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([]); +}); + +test("a person in both Recent and Your teams appears once, under Recent", () => { + const recentMate = { user_id: "m1", handle: "quiet-otter-1", display_name: "Zoe", last_invited_at: "2026-08-15T00:00:00.000Z" }; + const g = groupPeople({ query: "", teammates: [mate], recent: [recentMate], results: [] }); + expect(g.recent.map((p) => p.user_id)).toEqual(["m1"]); + expect(g.teammates).toEqual([]); +}); diff --git a/src/services/teamSharing.ts b/src/services/teamSharing.ts index ed8d25588..74ff5cf7f 100644 --- a/src/services/teamSharing.ts +++ b/src/services/teamSharing.ts @@ -1,9 +1,29 @@ +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"; -/** Account tier as used across the share flow (ShareMenu, InvitePeopleSection, ParticipantsRatioNotice). */ +/** + * 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, PeopleTab, ParticipantsRatioNotice). */ export type ShareTier = Tier; /** Guests a shared session may hold, from the tier whose plan the session runs on. */ @@ -40,7 +60,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])); @@ -113,14 +133,55 @@ 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 — + * 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 recentIds = new Set(recent.map((p) => p.user_id)); + // Recent is the more specific group: a person already in Recent does not repeat + // under Your teams, even if they are also a current teammate. + const teammates = input.teammates.filter((p) => !recentIds.has(p.user_id) && matches(p.display_name, p.handle)); + const claimed = new Set([...recentIds, ...teammates.map((p) => p.user_id)]); + const strangers = q ? input.results.filter((r) => !claimed.has(r.user_id) && !r.is_teammate) : []; + return { recent, teammates, strangers }; +} diff --git a/src/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/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(() => {}); } } 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"; diff --git a/src/stores/recentPeopleStore.test.ts b/src/stores/recentPeopleStore.test.ts new file mode 100644 index 000000000..78606b69f --- /dev/null +++ b/src/stores/recentPeopleStore.test.ts @@ -0,0 +1,72 @@ +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, +}); + +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"); +}); + +// 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([]); +}); + +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 new file mode 100644 index 000000000..d2fafc8b6 --- /dev/null +++ b/src/stores/recentPeopleStore.ts @@ -0,0 +1,77 @@ +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; +} + +/** + * 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; + 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 = project(person); + 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 }; + }), + + // 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(() => { + const recentUpdatedAt = settingsStamp(); + pushSettingsChange(); + return { recent: Array.isArray(list) ? list.slice(0, MAX_RECENT).map(project) : [], recentUpdatedAt }; + }), + }), + { name: "voltius-recent-people" }, + ), +); 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..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.