Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2303b7e
feat(invites): add the handle, preference, decline and un-invite clie…
kipavy Aug 15, 2026
f087740
refactor(invites): extract authedCall helper for the three plain-erro…
kipavy Aug 15, 2026
0c1c2ea
feat(share): wrap session keys for strangers and render a redacted se…
kipavy Aug 15, 2026
f99f6bb
fix(invites): distinguish a 404 no-such-user from a transport failure…
kipavy Aug 15, 2026
91c6be1
feat(invites): remember recently invited people in the encrypted user…
kipavy Aug 15, 2026
63372ad
feat(share): add the People tab with grouped results and the resoluti…
kipavy Aug 15, 2026
ac12169
fix(share): drop the dangling handle line and dedupe Recent teammates…
kipavy Aug 15, 2026
0161bb4
feat(share): make People a peer tab and animate the ShareMenu in and out
kipavy Aug 15, 2026
9a82c6d
fix(share): distinguish a failed roster load, pin the fade timing, co…
kipavy Aug 15, 2026
be36831
feat(inbox): render a stranger knock with join, decline and permanent…
kipavy Aug 15, 2026
ee5a48b
feat(settings): show, claim and rename a handle, and toggle stranger …
kipavy Aug 15, 2026
21b7847
fix(settings): keep a lapsed custom handle's copy accurate, kill the …
kipavy Aug 15, 2026
ef86960
i18n: add unified invite flow strings in en, fr, ru and zh
kipavy Aug 15, 2026
757a78b
test(team): assert on status code, not the raw i18n key
kipavy Aug 15, 2026
b87499d
fix(inbox): render a knock from the server-owned handle, never a supp…
kipavy Aug 15, 2026
386a062
fix(inbox): rename a joined knock's tab once the server un-redacts it
kipavy Aug 15, 2026
2ffe9bc
fix(share): keep a Recent teammate's vault access in the People tab
kipavy Aug 15, 2026
00ac33b
fix(recent): project and guard replaceAll, not just remember
kipavy Aug 15, 2026
1bcaf77
refactor(invites): route updatePublicKey through authedCall
kipavy Aug 15, 2026
5107348
fix(knock): retry the tab rename until the server un-redacts
kipavy Aug 15, 2026
b10287f
fix(recent): stamp an imported list instead of dating it 1970
kipavy Aug 15, 2026
aa6d09b
feat(share): let a host withdraw a pending invite, and label it Invited
kipavy Aug 15, 2026
4f4ca9d
feat(account): put the handle in the account menu, and point free-tie…
kipavy Aug 15, 2026
867bb91
fix(account): clear the cached handle and display name on sign-out
kipavy Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/components/hosts/TeamSessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
});
};
Expand Down Expand Up @@ -260,7 +264,7 @@ export function TeamSessions() {

<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<p className="text-sm font-bold truncate text-(--t-text-bright)">
{session.connection_name}
{sessionDisplayName(session)}
</p>

{/* Avatar stack — where tags sit on host cards */}
Expand Down
26 changes: 24 additions & 2 deletions src/components/layout/SidebarAccountButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -22,17 +23,26 @@ export function SidebarAccountButton() {
const [accountEmail, setAccountEmail] = useState<string | null>(null);
const [savedAccounts, setSavedAccounts] = useState<SavedAccount[]>([]);
const [currentAccountId, setCurrentAccountId] = useState<string | null>(null);
const [accountHandle, setAccountHandle] = useState<string | null>(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<string | null>("keychain_get", { key: "email" }).catch(() => null),
inv<string | null>("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<string | null>("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(); }, []);
Expand Down Expand Up @@ -137,6 +147,18 @@ export function SidebarAccountButton() {
{accountEmail ?? t("layout.sidebarAccount.localAccountFallback")}
</span>
</div>
{accountHandle && (
<button
type="button"
onClick={copyHandle}
title={t("layout.sidebarAccount.copyHandle")}
className="flex items-center gap-1 mt-0.5 text-xs transition-colors"
style={{ color: "var(--t-text-dim)" }}
>
<span className="truncate">@{accountHandle}</span>
<Icon icon={handleCopied ? "lucide:check" : "lucide:copy"} width={11} />
</button>
)}
{accountMode && (
<span className="text-xs mt-0.5 block" style={{ color: "var(--t-text-dim)" }}>
{accountMode === "server" ? t("layout.sidebarAccount.modeCloud") : accountMode === "local" ? t("layout.sidebarAccount.modeLocalPassword") : t("layout.sidebarAccount.modeLocal")}
Expand Down
18 changes: 8 additions & 10 deletions src/components/members/MembersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
Expand All @@ -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<SearchResult | null | undefined>(undefined);
const [buySeatsFor, setBuySeatsFor] = useState<UserSearchResult | null | undefined>(undefined);

const { atLimit: isAtSeatLimit } = seatAvailability(usedSeats, totalSeats);

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -880,15 +878,15 @@ 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;
adding: string | null;
error: string;
inputRef: React.RefObject<HTMLInputElement | null>;
dropdownRef: React.RefObject<HTMLDivElement | null>;
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();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions src/components/omni/OmniSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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: "" }];
}
Expand Down Expand Up @@ -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) })),
);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -828,7 +829,7 @@ export default function OmniSearch({ onClose }: OmniSearchProps) {
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate"
style={{ color: isSelected ? "var(--t-accent)" : "var(--t-text-primary)" }}>
{session.connection_name}
{sessionDisplayName(session)}
</span>
</div>
<span className="text-xs shrink-0 text-(--t-text-dim)">
Expand Down
150 changes: 150 additions & 0 deletions src/components/settings/sections/AccountSection.handle.test.tsx
Original file line number Diff line number Diff line change
@@ -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<MeResponse | null> => 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<typeof import("@/services/account")>("@/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(<AccountSection />);
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(<AccountSection />);
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(<AccountSection />);
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(<AccountSection />);
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(<AccountSection />);
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(<AccountSection />);
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<MeResponse>((resolve) => { resolveMe = resolve; }));
render(<AccountSection />);
// 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<void>((resolve) => { resolveUpdate = resolve; }));
render(<AccountSection />);
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));
});
Loading