diff --git a/src/components/layout/VaultHeader.share.test.tsx b/src/components/layout/VaultHeader.share.test.tsx new file mode 100644 index 000000000..f6aea5be2 --- /dev/null +++ b/src/components/layout/VaultHeader.share.test.tsx @@ -0,0 +1,41 @@ +import { test, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); +vi.mock("@iconify/react", () => ({ Icon: () => null })); +vi.mock("@/components/vault-share/VaultShareSheet", () => ({ + VaultShareSheet: () =>
share-sheet
, +})); + +import { MembersStack } from "./VaultHeader"; +import type { TeamMember } from "@/services/teamService"; + +afterEach(cleanup); + +const members = [ + { user_id: "u1", handle: "bob-builder", is_online: true, role_ids: [] }, +] as unknown as TeamMember[]; + +test("the stack opens on click, not only on hover", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "layout.vaultHeader.members" })); + expect(screen.getByText("share-sheet")).toBeTruthy(); +}); + +test("the stack is reachable by keyboard", () => { + render(); + const trigger = screen.getByRole("button", { name: "layout.vaultHeader.members" }); + trigger.focus(); + expect(document.activeElement).toBe(trigger); + fireEvent.keyDown(trigger, { key: "Enter" }); + expect(screen.getByText("share-sheet")).toBeTruthy(); +}); + +test("the + button opens the same sheet as the stack", () => { + render(); + fireEvent.click(screen.getByTitle("layout.vaultHeader.inviteMember")); + expect(screen.getByText("share-sheet")).toBeTruthy(); +}); diff --git a/src/components/layout/VaultHeader.tsx b/src/components/layout/VaultHeader.tsx index d8d5508d8..46eb52ed9 100644 --- a/src/components/layout/VaultHeader.tsx +++ b/src/components/layout/VaultHeader.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import { useTranslation } from "react-i18next"; import { Icon } from "@iconify/react"; import i18n from "@/i18n"; @@ -7,34 +7,26 @@ import { useUIStore } from "@/stores/uiStore"; import { useVaultContents } from "@/hooks/useVaultContents"; import { ContentCounts } from "@/components/shared/ContentCounts"; import { useTeamStore } from "@/stores/teamStore"; -import type { TeamMember, TeamRole } from "@/services/teamService"; -import { AvatarOverflow, MiniAvatar, avatarColor } from "@/components/shared/AvatarStack"; -import { PresenceAvatar } from "@/components/shared/PresenceAvatar"; +import type { TeamMember } from "@/services/teamService"; +import { AvatarOverflow, MiniAvatar } from "@/components/shared/AvatarStack"; import { PickerSurface } from "@/components/shared/PickerSurface"; import { getSyncState, onSyncStateChange } from "@/services/sync"; import { getAccountMode } from "@/services/account"; +import { VaultShareSheet } from "@/components/vault-share/VaultShareSheet"; -// ─── Online members stack ───────────────────────────────────────────────────── - -const BUILTIN_ROLE_COLORS: Record = { - owner: "#f59e0b", - manager: "#8b5cf6", - editor: "#3b82f6", - member: "#10b981", - "connect-only": "#6b7280", -}; +// ─── Members stack ───────────────────────────────────────────────────────── const MAX_STACK = 3; -function OnlineMembersStack({ members, roles, onInviteClick }: { members: TeamMember[]; roles: TeamRole[]; onInviteClick: () => void }) { +export function MembersStack({ members, vaultId }: { members: TeamMember[]; vaultId: string }) { const { t } = useTranslation(); - const [hovered, setHovered] = useState(false); + const openMembersInvite = useUIStore((s) => s.openMembersInvite); const [invHovered, setInvHovered] = useState(false); + const [open, setOpen] = useState(false); const ref = useRef(null); const closeTimer = useRef(null); const visible = members.slice(0, MAX_STACK); const overflow = members.length - MAX_STACK; - const onlineCount = members.filter((m) => m.is_online).length; // The popover is portalled out of the header, so moving the pointer into it // fires the stack's mouseleave. Defer the close so the popover's own @@ -42,25 +34,42 @@ function OnlineMembersStack({ members, roles, onInviteClick }: { members: TeamMe const openPopover = () => { if (closeTimer.current !== null) window.clearTimeout(closeTimer.current); closeTimer.current = null; - setHovered(true); + setOpen(true); }; const closePopover = () => { if (closeTimer.current !== null) window.clearTimeout(closeTimer.current); - closeTimer.current = window.setTimeout(() => setHovered(false), 120); + closeTimer.current = window.setTimeout(() => setOpen(false), 120); }; useEffect(() => () => { if (closeTimer.current !== null) window.clearTimeout(closeTimer.current); }, []); + const toggleOpen = () => setOpen((o) => !o); + const handleTriggerKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleOpen(); + } + }; + return ( -
+
{/* Stack */} {members.length > 0 && ( -
{visible.map((m, i) => (
))} - - {/* Hover popover — portalled: the page overlay in MainPanel outranks the - header's stacking context, so an in-flow popover paints under it. */} - setHovered(false)} - anchorRef={ref} - width={220} - align="right" - gap={0} - title={t("layout.vaultHeader.members")} - > -
-
- - {onlineCount > 0 ? t("layout.vaultHeader.onlineCount", { count: onlineCount }) : t("layout.vaultHeader.noOneOnline")} - -
- {members.map((m) => { - const memberRoles = (m.role_ids ?? []) - .map((rid) => roles.find((r) => r.id === rid)) - .filter(Boolean) as TeamRole[]; - return ( -
- -
- {m.handle} - {memberRoles.length > 0 && ( -
- {memberRoles.map((r) => { - const color = r.color ?? BUILTIN_ROLE_COLORS[r.name] ?? avatarColor(r.name); - return ( - - {r.name} - - ); - })} -
- )} -
-
- ); - })} -
-
-
+ )} {/* Invite + button */} + + {/* Popover — portalled: the page overlay in MainPanel outranks the + header's stacking context, so an in-flow popover paints under it. */} + setOpen(false)} + anchorRef={ref} + width={280} + align="right" + gap={4} + title={t("layout.vaultHeader.members")} + > +
+ +
+
); } @@ -173,8 +152,7 @@ export default function VaultHeader() { const vaults = useVaultStore((s) => s.vaults); const selectedVaultIds = useVaultStore((s) => s.selectedVaultIds); const setOmniOpen = useUIStore((s) => s.setOmniOpen); - const openMembersInvite = useUIStore((s) => s.openMembersInvite); - const { teams, membersByTeam, rolesByTeam, loadMembers } = useTeamStore(); + const { teams, membersByTeam, loadMembers } = useTeamStore(); const [syncState, setSyncState] = useState(getSyncState); useEffect(() => onSyncStateChange(() => setSyncState(getSyncState())), []); @@ -194,7 +172,6 @@ export default function VaultHeader() { ? (teams.find((t) => t.id === vault.teamId) ?? null) : standaloneTeam; const members = team ? (membersByTeam[team.id] ?? null) : null; - const roles = team ? (rolesByTeam[team.id] ?? []) : []; const contentVaultId = team?.id ?? activeVaultId ?? "personal"; const counts = useVaultContents(contentVaultId); @@ -306,8 +283,8 @@ export default function VaultHeader() { {/* Right zone: online members */}
- {team && members !== null && ( - + {team && members !== null && activeVaultId && ( + )}
diff --git a/src/components/members/MembersPage.InvitePanel.test.tsx b/src/components/members/MembersPage.InvitePanel.test.tsx index b94429f02..3ad87302a 100644 --- a/src/components/members/MembersPage.InvitePanel.test.tsx +++ b/src/components/members/MembersPage.InvitePanel.test.tsx @@ -1,20 +1,37 @@ import { test, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, cleanup, fireEvent, waitFor, act } from "@testing-library/react"; import type { TeamRole } from "@/stores/teamStore"; +import membersEn from "@/i18n/locales/en/members.json"; const h = vi.hoisted(() => ({ searchUsers: vi.fn(), - inviteByEmail: vi.fn(), - add: vi.fn(), + inviteUserById: vi.fn(), + inviteByEmailAddress: vi.fn(), assign: vi.fn(), reload: vi.fn(), getMyHandle: vi.fn(async () => "merry-quartz-2597"), + t: vi.fn((k: string) => k), usedSeats: 2, totalSeats: 3, })); +// Looks up the real English copy so the interpolating t below reproduces what +// i18next actually does: substitute {{vars}} into the resource string. +function lookup(key: string): string { + const value = key + .split(".") + .reduce((node, part) => (node as Record | undefined)?.[part], membersEn); + return typeof value === "string" ? value : key; +} +function interpolatingT(k: string, vars?: Record): string { + const template = lookup(k); + return vars + ? Object.entries(vars).reduce((s, [key, v]) => s.replace(new RegExp(`{{${key}}}`, "g"), String(v)), template) + : template; +} + vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (k: string) => k }), + useTranslation: () => ({ t: h.t }), initReactI18next: { type: "3rdParty", init: () => {} }, })); vi.mock("@iconify/react", () => ({ Icon: () => null })); @@ -27,15 +44,20 @@ vi.mock("@/components/shared/Panel", () => ({ vi.mock("@/services/teamService", () => ({ searchUsers: h.searchUsers, getMyUserId: vi.fn(), - inviteByEmail: h.inviteByEmail, revokePendingInvitation: vi.fn(), })); +// Keep the real `inviteFailureReason` (the URL-classification logic under test); +// only the network calls themselves are stubbed. +vi.mock("@/services/vaultShare", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, inviteUserById: h.inviteUserById, inviteByEmailAddress: h.inviteByEmailAddress }; +}); vi.mock("@/services/account", () => ({ getMyHandle: h.getMyHandle })); vi.mock("@/services/teamActionFeedback", () => ({ runTeamAction: async (o: { run: () => Promise }) => o.run(), })); vi.mock("@/stores/teamStore", () => { - const state = { addMemberById: h.add, assignMemberRole: h.assign }; + const state = { assignMemberRole: h.assign }; const useTeamStore = Object.assign( (sel: (s: typeof state) => unknown) => sel(state), { getState: () => state }, @@ -83,10 +105,11 @@ const inA = { user_id: "inA", handle: "included-alpha-3140", public_key: "pkA" } beforeEach(() => { h.searchUsers.mockReset(); - h.inviteByEmail.mockReset(); - h.add.mockReset(); + h.inviteUserById.mockReset(); + h.inviteByEmailAddress.mockReset(); h.assign.mockReset(); h.reload.mockReset().mockResolvedValue(undefined); + h.t.mockImplementation((k: string) => k); h.usedSeats = 2; h.totalSeats = 3; baseProps.onClose = vi.fn(); @@ -97,8 +120,11 @@ afterEach(() => { vi.useRealTimers(); }); +// Role-based, not placeholder-text-based: some tests switch `t` to a real, +// interpolating implementation, under which the placeholder is real English +// copy rather than the literal key. The panel has exactly one text input. function getInput() { - return screen.getByPlaceholderText("members.invite.searchUserPlaceholder"); + return screen.getByRole("textbox"); } /** Types a query and advances the 250ms debounce under fake timers, flushing the search promise. */ @@ -144,11 +170,10 @@ test("existingIds filter: excluded id absent from rendered results, included id expect(screen.queryByText("excluded-bravo-9022")).toBeNull(); }); -test("add success (not at limit): addMemberById + assignMemberRole(default role) + reload + onMemberAdded", async () => { +test("add success (not at limit): the chosen role travels with inviteUserById, not a doomed post-hoc assignment", async () => { vi.useFakeTimers(); h.searchUsers.mockResolvedValue([inA]); - h.add.mockResolvedValue({ status: "pending" }); - h.assign.mockResolvedValue(undefined); + h.inviteUserById.mockResolvedValue({ status: "pending" }); render(); await typeAndDebounce("in"); @@ -157,12 +182,35 @@ test("add success (not at limit): addMemberById + assignMemberRole(default role) fireEvent.click(screen.getByText("included-alpha-3140")); await waitFor(() => expect(baseProps.onMemberAdded).toHaveBeenCalled()); - expect(h.add).toHaveBeenCalledWith("t1", "inA"); - expect(h.assign).toHaveBeenCalledWith("t1", "inA", "r-mem"); + expect(h.inviteUserById).toHaveBeenCalledWith({ + teamId: "t1", userId: "inA", handle: "included-alpha-3140", roleName: "member", roleId: "r-mem", + }); + expect(h.assign).not.toHaveBeenCalled(); expect(h.reload).toHaveBeenCalled(); }); -test("add at seat limit: addMemberById NOT called, BuySeatsModal shown with that user", async () => { +test("add resolves already_member: extra selected roles (beyond the first) are assigned", async () => { + vi.useFakeTimers(); + const roles: TeamRole[] = [ + ...teamRoles, + { id: "r-editor", team_id: "t1", name: "editor", is_builtin: true, permissions: 0, position: 2, created_at: "" }, + ]; + h.searchUsers.mockResolvedValue([inA]); + h.inviteUserById.mockResolvedValue({ status: "already_member" }); + h.assign.mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByText("editor")); + await typeAndDebounce("in"); + vi.useRealTimers(); + fireEvent.click(screen.getByText("included-alpha-3140")); + + await waitFor(() => expect(baseProps.onMemberAdded).toHaveBeenCalled()); + expect(h.inviteUserById).toHaveBeenCalledWith(expect.objectContaining({ roleName: "member", roleId: "r-mem" })); + expect(h.assign).toHaveBeenCalledWith("t1", "inA", "r-editor"); +}); + +test("add at seat limit: inviteUserById NOT called, BuySeatsModal shown with that user", async () => { h.usedSeats = 3; h.totalSeats = 3; vi.useFakeTimers(); @@ -173,7 +221,7 @@ test("add at seat limit: addMemberById NOT called, BuySeatsModal shown with that vi.useRealTimers(); fireEvent.click(screen.getByText("included-alpha-3140")); - expect(h.add).not.toHaveBeenCalled(); + expect(h.inviteUserById).not.toHaveBeenCalled(); const modal = await screen.findByTestId("buy-seats-modal"); expect(modal.dataset.pendingUser).toBe("inA"); }); @@ -181,7 +229,7 @@ test("add at seat limit: addMemberById NOT called, BuySeatsModal shown with that test("add rejects {code:402} (not at limit): BuySeatsModal shown, no error text", async () => { vi.useFakeTimers(); h.searchUsers.mockResolvedValue([inA]); - h.add.mockRejectedValue(Object.assign(new Error("x"), { code: 402 })); + h.inviteUserById.mockRejectedValue(Object.assign(new Error("x"), { code: 402 })); render(); await typeAndDebounce("in"); @@ -196,7 +244,7 @@ test("add rejects {code:402} (not at limit): BuySeatsModal shown, no error text" test("add rejects Error with '402' in message (no code prop): BuySeatsModal shown", async () => { vi.useFakeTimers(); h.searchUsers.mockResolvedValue([inA]); - h.add.mockRejectedValue(new Error("boom 402 detail")); + h.inviteUserById.mockRejectedValue(new Error("boom 402 detail")); render(); await typeAndDebounce("in"); @@ -208,24 +256,44 @@ test("add rejects Error with '402' in message (no code prop): BuySeatsModal show expect(screen.queryByText("boom 402 detail")).toBeNull(); }); -test("add rejects generic error (no 402): error text shown, BuySeatsModal NOT rendered", async () => { +test("add rejects generic error (no 402): named inviteFailed message shown, BuySeatsModal NOT rendered", async () => { + h.t.mockImplementation(interpolatingT); vi.useFakeTimers(); h.searchUsers.mockResolvedValue([inA]); - h.add.mockRejectedValue(new Error("nope")); + h.inviteUserById.mockRejectedValue(new Error("nope")); render(); await typeAndDebounce("in"); vi.useRealTimers(); fireEvent.click(screen.getByText("included-alpha-3140")); - expect(await screen.findByText("nope")).toBeTruthy(); + expect(await screen.findByText(/Could not invite included-alpha-3140 — nope/)).toBeTruthy(); expect(screen.queryByTestId("buy-seats-modal")).toBeNull(); }); -test("email invite success (not at limit): inviteByEmail(default role) + reload + onMemberAdded", async () => { +test("add rejects a transport failure (no HTTP status): named message shown, no URL ever reaches the DOM", async () => { + h.t.mockImplementation(interpolatingT); + vi.useFakeTimers(); + h.searchUsers.mockResolvedValue([inA]); + h.inviteUserById.mockRejectedValue( + new Error("error sending request for url (http://v68-server:8080/v1/teams/a5c2d19d/invite)"), + ); + const { container } = render(); + + await typeAndDebounce("in"); + vi.useRealTimers(); + fireEvent.click(screen.getByText("included-alpha-3140")); + + expect(await screen.findByText(/Could not invite included-alpha-3140/)).toBeTruthy(); + expect(container.textContent).not.toMatch(/http/i); + // The dropdown also closes so the error is not hidden underneath it. + expect(screen.queryByText("included-alpha-3140")).toBeNull(); +}); + +test("email invite success (not at limit): inviteByEmailAddress(default role) + reload + onMemberAdded", async () => { vi.useFakeTimers(); h.searchUsers.mockResolvedValue([]); - h.inviteByEmail.mockResolvedValue({ status: "invited" }); + h.inviteByEmailAddress.mockResolvedValue({ status: "invited" }); render(); await typeAndDebounce("a@b.com"); @@ -234,11 +302,11 @@ test("email invite success (not at limit): inviteByEmail(default role) + reload fireEvent.click(await screen.findByRole("button", { name: /sendInviteLabel/ })); await waitFor(() => expect(baseProps.onMemberAdded).toHaveBeenCalled()); - expect(h.inviteByEmail).toHaveBeenCalledWith("t1", "a@b.com", "member"); + expect(h.inviteByEmailAddress).toHaveBeenCalledWith({ teamId: "t1", email: "a@b.com", roleName: "member" }); expect(h.reload).toHaveBeenCalled(); }); -test("email invite at seat limit: BuySeatsModal(null); inviteByEmail NOT called", async () => { +test("email invite at seat limit: BuySeatsModal(null); inviteByEmailAddress NOT called", async () => { h.usedSeats = 3; h.totalSeats = 3; vi.useFakeTimers(); @@ -251,13 +319,13 @@ test("email invite at seat limit: BuySeatsModal(null); inviteByEmail NOT called" const modal = await screen.findByTestId("buy-seats-modal"); expect(modal.dataset.pendingUser).toBe("none"); - expect(h.inviteByEmail).not.toHaveBeenCalled(); + expect(h.inviteByEmailAddress).not.toHaveBeenCalled(); }); test("email invite rejects 402: BuySeatsModal(null)", async () => { vi.useFakeTimers(); h.searchUsers.mockResolvedValue([]); - h.inviteByEmail.mockRejectedValue(Object.assign(new Error("x"), { code: 402 })); + h.inviteByEmailAddress.mockRejectedValue(Object.assign(new Error("x"), { code: 402 })); render(); await typeAndDebounce("a@b.com"); @@ -268,17 +336,18 @@ test("email invite rejects 402: BuySeatsModal(null)", async () => { expect(modal.dataset.pendingUser).toBe("none"); }); -test("email invite rejects generic error (no 402): error text shown, no modal", async () => { +test("email invite rejects generic error (no 402): named inviteFailed message shown, no modal", async () => { + h.t.mockImplementation(interpolatingT); vi.useFakeTimers(); h.searchUsers.mockResolvedValue([]); - h.inviteByEmail.mockRejectedValue(new Error("nope")); + h.inviteByEmailAddress.mockRejectedValue(new Error("nope")); render(); await typeAndDebounce("a@b.com"); vi.useRealTimers(); - fireEvent.click(await screen.findByRole("button", { name: /sendInviteLabel/ })); + fireEvent.click(await screen.findByRole("button", { name: /sendInviteLabel|Send invite to/ })); - expect(await screen.findByText("nope")).toBeTruthy(); + expect(await screen.findByText(/Could not invite a@b.com — nope/)).toBeTruthy(); expect(screen.queryByTestId("buy-seats-modal")).toBeNull(); }); @@ -301,3 +370,36 @@ test("BuySeatsModal onSuccess: reloadSubscription + onMemberAdded called, modal expect(h.reload).toHaveBeenCalled(); expect(screen.queryByTestId("buy-seats-modal")).toBeNull(); }); + +test("no role selected: the Add action is disabled, a hint is shown, clicking does nothing", async () => { + vi.useFakeTimers(); + h.searchUsers.mockResolvedValue([inA]); + render(); + + // "member" is auto-selected on mount; untick it so nothing is selected. + fireEvent.click(screen.getByText("member")); + expect(screen.getByText("members.invite.selectRoleHint")).toBeTruthy(); + + await typeAndDebounce("in"); + vi.useRealTimers(); + + const addButton = screen.getByText("included-alpha-3140").closest("button") as HTMLButtonElement; + expect(addButton.disabled).toBe(true); + fireEvent.click(addButton); + expect(h.inviteUserById).not.toHaveBeenCalled(); +}); + +test("no role selected: the email-invite action is disabled, clicking does nothing", async () => { + vi.useFakeTimers(); + h.searchUsers.mockResolvedValue([]); + render(); + + fireEvent.click(screen.getByText("member")); + await typeAndDebounce("a@b.com"); + vi.useRealTimers(); + + const emailButton = (await screen.findByRole("button", { name: /sendInviteLabel/ })) as HTMLButtonElement; + expect(emailButton.disabled).toBe(true); + fireEvent.click(emailButton); + expect(h.inviteByEmailAddress).not.toHaveBeenCalled(); +}); diff --git a/src/components/members/MembersPage.PrivateVaultAdd.test.tsx b/src/components/members/MembersPage.PrivateVaultAdd.test.tsx deleted file mode 100644 index ab4a94b31..000000000 --- a/src/components/members/MembersPage.PrivateVaultAdd.test.tsx +++ /dev/null @@ -1,266 +0,0 @@ -import { test, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent, waitFor, act } from "@testing-library/react"; - -const h = vi.hoisted(() => ({ - getMyUserId: vi.fn(), - getMyHandle: vi.fn(), - searchUsers: vi.fn(), - loadTeams: vi.fn(), - loadMembers: vi.fn(), - loadRoles: vi.fn(), - loadPendingInvitations: vi.fn(), - createTeam: vi.fn(), - setVaultTeamId: vi.fn(), - addMemberById: vi.fn(), - assignMemberRole: vi.fn(), - initTeamVaultKey: vi.fn(), - markLoaded: vi.fn(), - reloadSubscription: vi.fn(), - // mutable store state - rolesByTeam: {} as Record, -})); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (k: string) => k }), - initReactI18next: { type: "3rdParty", init: () => {} }, -})); -vi.mock("@iconify/react", () => ({ Icon: () => null })); -vi.mock("@/components/shared/StatusDot", () => ({ StatusDot: () => null })); -vi.mock("@/components/shared/Panel", () => ({ - PanelShell: ({ children }: { children: React.ReactNode }) =>
{children}
, - PanelHeader: ({ children }: { children?: React.ReactNode }) =>
{children}
, - PanelHeaderIconButton: () => null, - FormSection: ({ children }: { children: React.ReactNode }) =>
{children}
, -})); -vi.mock("@/components/shared/SidePanelLayout", () => ({ - SidePanelLayout: ({ panel, children }: { panel: React.ReactNode; children: React.ReactNode }) => ( -
{panel}{children}
- ), -})); -vi.mock("@/components/shared/DragSelectSurface", () => ({ - DragSelectSurface: ({ children }: { children: React.ReactNode }) =>
{children}
, -})); -vi.mock("@/components/shared/ToolbarViewControls", () => ({ ToolbarViewControls: () => null })); -vi.mock("@/components/shared/BaseCard", () => ({ BaseCard: () => null })); -vi.mock("@/components/settings/BuySeatsModal", () => ({ default: () => null })); -vi.mock("@/components/settings/sections/RolesSection", () => ({ - RoleModal: () => null, - PERM_META: {}, - TeamRolesPanel: () => null, -})); -vi.mock("@/hooks/useListKeyNav", () => ({ useListKeyNav: () => ({ focusedId: null, setFocusedId: () => {} }) })); -vi.mock("@/hooks/usePermission", () => ({ - PERM_BITS: { MANAGE_MEMBERS: 1, MANAGE_ROLES: 2, INVITE_MEMBERS: 4 }, - effectivePermissions: () => 0, - hasBuiltinRole: () => false, -})); -vi.mock("@/services/teamService", () => ({ - searchUsers: h.searchUsers, - getMyUserId: h.getMyUserId, - inviteByEmail: vi.fn(), - revokePendingInvitation: vi.fn(), -})); -vi.mock("@/services/account", () => ({ getMyHandle: h.getMyHandle })); -vi.mock("@/services/teamActionFeedback", () => ({ - runTeamAction: async (o: { run: () => Promise }) => o.run(), -})); -vi.mock("@/services/teamVaultActivation", () => ({ - markTeamVaultLoadedAfterLocalActivation: h.markLoaded, -})); -vi.mock("@/services/billingCheckout", () => ({ openBillingCheckout: vi.fn() })); -vi.mock("@/services/teamVaultSync", () => ({ initTeamVaultKey: h.initTeamVaultKey })); -vi.mock("@/stores/teamVaultStateStore", () => ({ - useTeamVaultStateStore: { getState: () => ({ tag: "vault-state" }) }, -})); - -vi.mock("@/stores/vaultStore", () => { - const state = { - selectedVaultIds: ["v1"], - vaults: [{ id: "v1", name: "V", teamId: null }], - setVaultTeamId: h.setVaultTeamId, - }; - const useVaultStore = Object.assign( - (sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state), - { getState: () => state }, - ); - return { useVaultStore }; -}); -vi.mock("@/stores/teamStore", () => { - const state = { - teams: [], - loadTeams: h.loadTeams, - membersByTeam: {}, - loadMembers: h.loadMembers, - get rolesByTeam() { return h.rolesByTeam; }, - loadRoles: h.loadRoles, - pendingInvitationsByTeam: {}, - loadPendingInvitations: h.loadPendingInvitations, - createTeam: h.createTeam, - addMemberById: h.addMemberById, - assignMemberRole: h.assignMemberRole, - removeMemberRole: vi.fn(), - removeMember: vi.fn(), - }; - const useTeamStore = Object.assign( - (sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state), - { getState: () => state }, - ); - return { useTeamStore }; -}); -vi.mock("@/stores/subscriptionStore", () => { - const state = { - isTeams: true, - accountMode: "server", - usedSeats: 1, - totalSeats: 5, - load: h.reloadSubscription, - }; - const useSubscriptionStore = Object.assign( - (sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state), - { getState: () => state }, - ); - return { useSubscriptionStore }; -}); -vi.mock("@/stores/uiStore", () => { - const state = { - membersLayoutMode: "list", - membersSortMode: "name-asc", - setMembersLayoutMode: vi.fn(), - setMembersSortMode: vi.fn(), - membersInvitePending: false, - clearMembersInvitePending: vi.fn(), - openSettings: vi.fn(), - openCloudAuth: vi.fn(), - }; - const useUIStore = Object.assign( - (sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state), - { getState: () => state }, - ); - return { useUIStore }; -}); -vi.mock("@/stores/teamSessionStore", () => { - const state = { activeSessions: [], connections: {}, startSharing: vi.fn(), inviteToActiveSession: vi.fn() }; - const useTeamSessionStore = Object.assign( - (sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state), - { getState: () => state }, - ); - return { useTeamSessionStore }; -}); -vi.mock("@/stores/historyStore", () => ({ - useHistoryStore: (sel: (s: { push: () => void }) => unknown) => sel({ push: vi.fn() }), -})); - -import MembersPage from "./MembersPage"; - -const foundUser = { user_id: "u1", handle: "zesty-otter-1180", public_key: "pk1" }; - -beforeEach(() => { - Object.values(h).forEach((v) => { if (typeof v === "function" && "mockReset" in v) (v as ReturnType).mockReset(); }); - h.getMyUserId.mockResolvedValue("me"); - h.getMyHandle.mockResolvedValue("merry-quartz-2597"); - h.loadTeams.mockResolvedValue(undefined); - h.createTeam.mockResolvedValue({ id: "newteam", name: "V" }); - h.addMemberById.mockResolvedValue(undefined); - h.assignMemberRole.mockResolvedValue(undefined); - h.loadRoles.mockResolvedValue(undefined); - h.initTeamVaultKey.mockResolvedValue(undefined); - h.reloadSubscription.mockResolvedValue(undefined); - h.rolesByTeam = { - newteam: [{ id: "r-mem", team_id: "newteam", name: "member", is_builtin: true, permissions: 0, position: 1, created_at: "" }], - }; -}); -afterEach(() => { - cleanup(); - vi.useRealTimers(); -}); - -/** Renders the page (private-vault branch) and opens the invite panel. */ -async function renderAndOpenInvite() { - render(); - // flush getMyUserId/getMe/loadTeams so canPrivateInvite becomes true - await act(async () => { await Promise.resolve(); await Promise.resolve(); }); - fireEvent.click(screen.getByRole("button", { name: /members.toolbar.inviteBtn/ })); -} - -function getPrivateInput() { - return screen.getByPlaceholderText("members.invite.searchUserPlaceholder"); -} - -test("private search debounce: no call <2 chars, exactly one searchUsers after 250ms", async () => { - h.searchUsers.mockResolvedValue([]); - await renderAndOpenInvite(); - vi.useFakeTimers(); - - fireEvent.change(getPrivateInput(), { target: { value: "z" } }); - await act(async () => { await vi.advanceTimersByTimeAsync(300); }); - expect(h.searchUsers).not.toHaveBeenCalled(); - - fireEvent.change(getPrivateInput(), { target: { value: "zo" } }); - await act(async () => { await vi.advanceTimersByTimeAsync(100); }); - expect(h.searchUsers).not.toHaveBeenCalled(); - - await act(async () => { await vi.advanceTimersByTimeAsync(150); }); - expect(h.searchUsers).toHaveBeenCalledTimes(1); - expect(h.searchUsers).toHaveBeenCalledWith("zo"); -}); - -test("handlePrivateAdd: ordered createTeam -> setVaultTeamId -> initTeamVaultKey -> markLoaded -> addMemberById -> loadRoles -> assignMemberRole", async () => { - h.searchUsers.mockResolvedValue([foundUser]); - await renderAndOpenInvite(); - vi.useFakeTimers(); - fireEvent.change(getPrivateInput(), { target: { value: "zo" } }); - await act(async () => { await vi.advanceTimersByTimeAsync(250); }); - vi.useRealTimers(); - - fireEvent.click(screen.getByText("zesty-otter-1180")); - - await waitFor(() => expect(h.assignMemberRole).toHaveBeenCalled()); - - expect(h.createTeam).toHaveBeenCalledWith("V"); - expect(h.setVaultTeamId).toHaveBeenCalledWith("v1", "newteam"); - expect(h.initTeamVaultKey).toHaveBeenCalledWith("newteam", []); - expect(h.markLoaded).toHaveBeenCalledWith("newteam", { tag: "vault-state" }); - expect(h.addMemberById).toHaveBeenCalledWith("newteam", "u1"); - expect(h.loadRoles).toHaveBeenCalledWith("newteam"); - expect(h.assignMemberRole).toHaveBeenCalledWith("newteam", "u1", "r-mem"); - - const order = (f: ReturnType) => f.mock.invocationCallOrder[0]; - expect(order(h.createTeam)).toBeLessThan(order(h.setVaultTeamId)); - expect(order(h.setVaultTeamId)).toBeLessThan(order(h.initTeamVaultKey)); - expect(order(h.initTeamVaultKey)).toBeLessThan(order(h.markLoaded)); - expect(order(h.markLoaded)).toBeLessThan(order(h.addMemberById)); - expect(order(h.addMemberById)).toBeLessThan(order(h.loadRoles)); - expect(order(h.loadRoles)).toBeLessThan(order(h.assignMemberRole)); -}); - -test("handlePrivateAdd: role not found in reloaded roles -> assignMemberRole NOT called", async () => { - h.rolesByTeam = { newteam: [] }; - h.searchUsers.mockResolvedValue([foundUser]); - await renderAndOpenInvite(); - vi.useFakeTimers(); - fireEvent.change(getPrivateInput(), { target: { value: "zo" } }); - await act(async () => { await vi.advanceTimersByTimeAsync(250); }); - vi.useRealTimers(); - - fireEvent.click(screen.getByText("zesty-otter-1180")); - - await waitFor(() => expect(h.addMemberById).toHaveBeenCalledWith("newteam", "u1")); - await waitFor(() => expect(h.loadRoles).toHaveBeenCalled()); - expect(h.assignMemberRole).not.toHaveBeenCalled(); -}); - -test("handlePrivateAdd: createTeam rejects -> error shown, addMemberById never called", async () => { - h.createTeam.mockRejectedValue(new Error("boom")); - h.searchUsers.mockResolvedValue([foundUser]); - await renderAndOpenInvite(); - vi.useFakeTimers(); - fireEvent.change(getPrivateInput(), { target: { value: "zo" } }); - await act(async () => { await vi.advanceTimersByTimeAsync(250); }); - vi.useRealTimers(); - - fireEvent.click(screen.getByText("zesty-otter-1180")); - - expect(await screen.findByText("boom")).toBeTruthy(); - expect(h.addMemberById).not.toHaveBeenCalled(); - expect(h.assignMemberRole).not.toHaveBeenCalled(); -}); diff --git a/src/components/members/MembersPage.tsx b/src/components/members/MembersPage.tsx index 5e9722ef7..3c35e07b3 100644 --- a/src/components/members/MembersPage.tsx +++ b/src/components/members/MembersPage.tsx @@ -13,7 +13,6 @@ import { PresenceAvatar } from "@/components/shared/PresenceAvatar"; import { UserSearchField } from "@/components/shared/UserSearchField"; import { getMyUserId, - inviteByEmail, revokePendingInvitation, } from "@/services/teamService"; import type { PendingInvitation } from "@/stores/teamStore"; @@ -30,15 +29,16 @@ import { useListKeyNav } from "@/hooks/useListKeyNav"; import BuySeatsModal from "@/components/settings/BuySeatsModal"; import { effectivePermissions, hasBuiltinRole, PERM_BITS } from "@/hooks/usePermission"; import { runTeamAction } from "@/services/teamActionFeedback"; -import { markTeamVaultLoadedAfterLocalActivation } from "@/services/teamVaultActivation"; 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, sessionDisplayName } from "@/services/teamSharing"; import { SeatsMeter } from "@/components/members/SeatsMeter"; import { ROLE_META, RoleToggleChip } from "@/components/members/roleChips"; import { useUserSearch, type UserSearchResult } from "@/hooks/useUserSearch"; +import { inviteUserById, inviteByEmailAddress, inviteFailureReason } from "@/services/vaultShare"; +import { ConvertToTeamGate } from "@/components/vault-share/ConvertToTeamGate"; +import { assignableRoles, leastPrivilegedRole } from "@/components/vault-share/vaultShareModel"; function RoleChip({ role }: { role: TeamRole }) { const { t } = useTranslation(); @@ -231,7 +231,7 @@ function MembersToolbar({ className="relative flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors shrink-0" style={{ background: showInvitePanel ? "var(--t-accent-hover)" : "var(--t-accent)", - color: "var(--t-bg-terminal)", + color: "var(--t-on-accent, #fff)", border: "1px solid var(--t-accent-hover)", }} onMouseEnter={(e) => (e.currentTarget.style.background = "var(--t-accent-hover)")} @@ -668,7 +668,6 @@ interface InvitePanelProps { export function InvitePanel({ teamId, existingIds, teamRoles, onClose, onMemberAdded }: InvitePanelProps) { const { t } = useTranslation(); - const addMemberById = useTeamStore((s) => s.addMemberById); const assignMemberRole = useTeamStore((s) => s.assignMemberRole); const { usedSeats, totalSeats, load: reloadSubscription } = useSubscriptionStore(); const { query, setQuery, results, searching, open, setOpen, inputRef, dropdownRef, reset } = @@ -682,10 +681,7 @@ export function InvitePanel({ teamId, existingIds, teamRoles, onClose, onMemberA const { atLimit: isAtSeatLimit } = seatAvailability(usedSeats, totalSeats); - const builtinRoles = useMemo( - () => teamRoles.filter((r) => !(r.is_builtin && r.name === "owner")).sort((a, b) => a.position - b.position), - [teamRoles], - ); + const builtinRoles = useMemo(() => assignableRoles(teamRoles), [teamRoles]); const defaultMemberRoleId = useMemo( () => builtinRoles.find((r) => r.is_builtin && r.name === "member")?.id, [builtinRoles], @@ -701,9 +697,13 @@ export function InvitePanel({ teamId, existingIds, teamRoles, onClose, onMemberA setSelectedRoleIds((prev) => prev.includes(roleId) ? prev.filter((id) => id !== roleId) : [...prev, roleId], ); + const hasRoleSelected = selectedRoleIds.length > 0; + // Never relied on by the UI (the invite actions are disabled without a selection) — + // a pure safety net so no future caller can end up granting "member" by accident. + const fallbackRoleName = useMemo(() => leastPrivilegedRole(teamRoles)?.name ?? "connect-only", [teamRoles]); const primaryRoleName = useMemo( - () => builtinRoles.find((r) => selectedRoleIds.includes(r.id))?.name ?? "member", - [selectedRoleIds, builtinRoles], + () => builtinRoles.find((r) => selectedRoleIds.includes(r.id))?.name ?? fallbackRoleName, + [selectedRoleIds, builtinRoles, fallbackRoleName], ); const selectedRoleLabel = useMemo(() => { const names = selectedRoleIds @@ -716,18 +716,21 @@ export function InvitePanel({ teamId, existingIds, teamRoles, onClose, onMemberA useEffect(() => { inputRef.current?.focus(); }, []); const handleAdd = async (user: UserSearchResult) => { + if (!hasRoleSelected) return; if (isAtSeatLimit) { setBuySeatsFor(user); setOpen(false); return; } setAdding(user.user_id); setError(""); setSuccess(""); try { - const result = await runTeamAction({ - pending: t("members.toast.invitingUser", { name: user.handle }), - success: (r) => r.status === "pending" - ? t("members.toast.invitationSentToUser", { name: user.handle }) - : t("members.toast.userAdded", { name: user.handle }), - run: () => addMemberById(teamId, user.user_id), + const [firstRoleId, ...restRoleIds] = selectedRoleIds; + const firstRoleName = builtinRoles.find((r) => r.id === firstRoleId)?.name ?? fallbackRoleName; + const result = await inviteUserById({ + teamId, + userId: user.user_id, + handle: user.handle, + roleName: firstRoleName, + roleId: firstRoleId, }); - if (result.status === "pending") { - for (const roleId of selectedRoleIds) { + if (result.status === "already_member") { + for (const roleId of restRoleIds) { await assignMemberRole(teamId, user.user_id, roleId).catch(() => {}); } } @@ -742,23 +745,19 @@ export function InvitePanel({ teamId, existingIds, teamRoles, onClose, onMemberA if ((e as { code?: number }).code === 402 || err.message.includes("402")) { setBuySeatsFor(user); setOpen(false); } else { - setError(err.message); + setOpen(false); + setError(t("members.error.inviteFailed", { name: user.handle, reason: inviteFailureReason(err) })); } } finally { setAdding(null); } }; const handleEmailInvite = async () => { - if (!isValidEmail(query)) return; + if (!isValidEmail(query) || !hasRoleSelected) return; if (isAtSeatLimit) { setBuySeatsFor(null); return; } setSendingInvite(true); setError(""); setSuccess(""); try { const invitedEmail = query; - const result = await runTeamAction({ - pending: t("members.toast.invitingEmail", { email: invitedEmail }), - success: () => t("members.toast.invitationSentToEmail", { email: invitedEmail }), - run: () => inviteByEmail(teamId, invitedEmail, primaryRoleName), - }); - void result; + await inviteByEmailAddress({ teamId, email: invitedEmail, roleName: primaryRoleName }); reset(); setSuccess(t("members.toast.invitationSentToEmail", { email: invitedEmail })); await reloadSubscription(); @@ -768,7 +767,8 @@ export function InvitePanel({ teamId, existingIds, teamRoles, onClose, onMemberA if ((e as { code?: number }).code === 402 || err.message.includes("402")) { setBuySeatsFor(null); } else { - setError(err.message); + setOpen(false); + setError(t("members.error.inviteFailed", { name: query, reason: inviteFailureReason(err) })); } } finally { setSendingInvite(false); } }; @@ -816,6 +816,9 @@ export function InvitePanel({ teamId, existingIds, teamRoles, onClose, onMemberA /> ))}
+ {!hasRoleSelected && ( +

{t("members.invite.selectRoleHint")}

+ )} {/* Search input */} @@ -835,6 +838,7 @@ export function InvitePanel({ teamId, existingIds, teamRoles, onClose, onMemberA adding={adding} addLabel={t("members.invite.addWithRole", { role: selectedRoleLabel })} onAdd={(user) => void handleAdd(user)} + actionsDisabled={!hasRoleSelected} emailOption={{ visible: showEmailInviteOption, label: <>{t("members.invite.sendInviteLabel")} {query}, @@ -858,86 +862,6 @@ export function InvitePanel({ teamId, existingIds, teamRoles, onClose, onMemberA ); } -// ─── Private vault invite panel ─────────────────────────────────────────────── - -const PRIVATE_VAULT_ROLES = ["manager", "editor", "member", "connect-only"] as const; - -function PrivateVaultInvitePanel({ - query, onQueryChange, - results, searching, open, setOpen, - adding, error, - inputRef, dropdownRef, - onAdd, onClose, -}: { - query: string; - onQueryChange: (v: string) => void; - results: UserSearchResult[]; - searching: boolean; - open: boolean; - setOpen: (v: boolean) => void; - adding: string | null; - error: string; - inputRef: React.RefObject; - dropdownRef: React.RefObject; - onAdd: (user: UserSearchResult, roleName: string) => void; - onClose: () => void; -}) { - const { t } = useTranslation(); - const reloadSubscription = useSubscriptionStore((s) => s.load); - const [selectedRole, setSelectedRole] = useState("member"); - - useEffect(() => { void reloadSubscription(); }, []); - useEffect(() => { inputRef.current?.focus(); }, []); - - return ( - - -
- - {/* Seats */} - - - - - {/* Role selector */} - -
- {PRIVATE_VAULT_ROLES.map((name) => ( - setSelectedRole(name)} - /> - ))} -
-
- - {/* Search input */} - - { onQueryChange(""); setOpen(false); }} - results={results} - searching={searching} - open={open} - setOpen={setOpen} - inputRef={inputRef} - dropdownRef={dropdownRef} - adding={adding} - addLabel={t("members.invite.addAsRole", { role: selectedRole })} - onAdd={(user) => onAdd(user, selectedRole)} - /> - - - {error &&

{error}

} -
-
- ); -} - // ─── Upgrade CTA ────────────────────────────────────────────────────────────── function SignInToCloudCTA({ onSignIn }: { onSignIn: () => void }) { @@ -1000,8 +924,6 @@ export default function MembersPage() { const vaults = useVaultStore((s) => s.vaults); const { teams, loadTeams, membersByTeam, loadMembers, rolesByTeam, loadRoles, pendingInvitationsByTeam, loadPendingInvitations } = useTeamStore(); const { tier, isTeams, accountMode } = useSubscriptionStore(); - const { createTeam } = useTeamStore(); - const { setVaultTeamId } = useVaultStore(); const addMemberById = useTeamStore((s) => s.addMemberById); const assignMemberRole = useTeamStore((s) => s.assignMemberRole); const removeMemberRole = useTeamStore((s) => s.removeMemberRole); @@ -1036,11 +958,6 @@ export default function MembersPage() { }, [membersInvitePending, clearMembersInvitePending]); const [detailMemberId, setDetailMemberId] = useState(null); - // Private-vault invite state - const privateSearch = useUserSearch(); - const [privateAdding, setPrivateAdding] = useState(null); - const [privateError, setPrivateError] = useState(""); - useEffect(() => { getMyUserId().then((id) => { if (id) setMyUserId(id); }).catch(() => {}); // getMyHandle() resolves to "" (never rejects) on a keychain miss with no @@ -1098,29 +1015,6 @@ export default function MembersPage() { loadPendingInvitations(teamId).catch(() => {}); }, [teamId, canManageMembers, loadPendingInvitations]); - const handlePrivateAdd = async (user: UserSearchResult, roleName: string) => { - if (!localVault || !primaryVaultId) return; - setPrivateAdding(user.user_id); setPrivateError(""); - try { - const team = await createTeam(localVault.name); - setVaultTeamId(primaryVaultId, team.id); - const { initTeamVaultKey } = await import("@/services/teamVaultSync"); - await initTeamVaultKey(team.id, []); - markTeamVaultLoadedAfterLocalActivation(team.id, useTeamVaultStateStore.getState()); - await addMemberById(team.id, user.user_id); - await loadRoles(team.id); - const role = useTeamStore.getState().rolesByTeam[team.id]?.find( - (r) => r.is_builtin && r.name === roleName, - ); - if (role) { - await assignMemberRole(team.id, user.user_id, role.id); - } - privateSearch.reset(); - } catch (e) { - setPrivateError(e instanceof Error ? e.message : t("members.error.failedToAddMember")); - } finally { setPrivateAdding(null); } - }; - // Filter + sort const searchLower = search.trim().toLowerCase(); const filteredMembers = useMemo(() => { @@ -1481,28 +1375,16 @@ const vaultTabs = selectedVaultIds.length > 1 } return ( - { privateSearch.setQuery(v); setPrivateError(""); }} - results={privateSearch.results} - searching={privateSearch.searching} - open={privateSearch.open} - setOpen={privateSearch.setOpen} - adding={privateAdding} - error={privateError} - inputRef={privateSearch.inputRef} - dropdownRef={privateSearch.dropdownRef} - onAdd={(user, roleName) => void handlePrivateAdd(user, roleName)} - onClose={() => setShowInvitePanel(false)} + <> + {showInvitePanel && ( + setShowInvitePanel(false)} + onConverted={() => {}} /> - ) : null} - className="chrome-canvas" - > -
+ )} +
{toolbar}
@@ -1546,7 +1428,7 @@ const vaultTabs = selectedVaultIds.length > 1
- + ); } diff --git a/src/components/members/SeatsMeter.test.tsx b/src/components/members/SeatsMeter.test.tsx index 2864cd399..320252311 100644 --- a/src/components/members/SeatsMeter.test.tsx +++ b/src/components/members/SeatsMeter.test.tsx @@ -40,12 +40,12 @@ test("overshooting the seat count clamps the bar at 100%", () => { expect(bar().style.width).toBe("100%"); }); -test("an unknown total renders an empty bar and '?' placeholders", () => { +test("an unknown total renders an empty bar and an explicit unavailable message, never '?'", () => { h.seats = { usedSeats: null, totalSeats: null }; render(); expect(bar().style.width).toBe("0%"); - expect(screen.getByText(/"used":0/)).toBeTruthy(); - expect(screen.getByText(/"available":"\?","total":"\?"/)).toBeTruthy(); + expect(screen.getByText("members.invite.seatsUnknown")).toBeTruthy(); + expect(screen.queryByText(/\?/)).toBeNull(); }); test("the buy-seats button appears only with a handler and reports clicks", () => { diff --git a/src/components/members/SeatsMeter.tsx b/src/components/members/SeatsMeter.tsx index 04e6864ae..22faaa58b 100644 --- a/src/components/members/SeatsMeter.tsx +++ b/src/components/members/SeatsMeter.tsx @@ -1,13 +1,17 @@ import { Icon } from "@iconify/react"; import { useTranslation } from "react-i18next"; import { useSubscriptionStore } from "@/stores/subscriptionStore"; -import { seatAvailability } from "@/services/seatMath"; +import { seatState } from "@/components/vault-share/vaultShareModel"; /** Seat usage bar for the invite surfaces, with an optional "buy seats" action. */ export function SeatsMeter({ onBuySeats }: { onBuySeats?: () => void }) { const { t } = useTranslation(); const { usedSeats, totalSeats } = useSubscriptionStore(); - const { atLimit, available } = seatAvailability(usedSeats, totalSeats); + const seats = seatState(usedSeats, totalSeats); + const atLimit = seats.kind === "known" && seats.atLimit; + const barWidth = seats.kind === "known" && seats.total > 0 + ? `${Math.min(100, (seats.used / seats.total) * 100)}%` + : "0%"; return (
@@ -15,19 +19,16 @@ export function SeatsMeter({ onBuySeats }: { onBuySeats?: () => void }) {
-

- {t("members.invite.seatsSummary", { - used: usedSeats ?? 0, - available: available ?? "?", - total: totalSeats ?? "?", - })} -

+ {seats.kind === "unknown" ? ( +

{t("members.invite.seatsUnknown")}

+ ) : ( +

+ {t("members.invite.seatsSummary", { used: seats.used, available: seats.available, total: seats.total })} +

+ )}
{onBuySeats && ( + +
+ + + ); +} diff --git a/src/components/vault-share/InviteControl.test.tsx b/src/components/vault-share/InviteControl.test.tsx new file mode 100644 index 000000000..e6d5ad6e4 --- /dev/null +++ b/src/components/vault-share/InviteControl.test.tsx @@ -0,0 +1,72 @@ +import { test, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import type { TeamRole } from "@/stores/teamStore"; + +const h = vi.hoisted(() => ({ + inviteUserById: vi.fn(), + inviteByEmailAddress: vi.fn(), + results: [] as unknown[], +})); + +vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => k }) })); +vi.mock("@iconify/react", () => ({ Icon: () => null })); +vi.mock("@/services/vaultShare", () => ({ + inviteUserById: h.inviteUserById, + inviteByEmailAddress: h.inviteByEmailAddress, +})); +vi.mock("@/hooks/useUserSearch", () => ({ + useUserSearch: () => ({ + query: "bob", setQuery: vi.fn(), results: h.results, searching: false, + open: true, setOpen: vi.fn(), inputRef: { current: null }, dropdownRef: { current: null }, + reset: vi.fn(), + }), +})); + +import { InviteControl } from "./InviteControl"; + +const roles = [ + { id: "r-owner", name: "owner", position: 0, is_builtin: true }, + { id: "r-manager", name: "manager", position: 1, is_builtin: true }, + { id: "r-editor", name: "editor", position: 2, is_builtin: true }, +] as unknown as TeamRole[]; + +beforeEach(() => { h.inviteUserById.mockReset(); h.results = []; }); +afterEach(cleanup); + +test("owner is never offered as an assignable role", () => { + render(); + expect(screen.queryByText("owner")).toBeNull(); + expect(screen.getByText("manager")).toBeTruthy(); +}); + +test("the custom-handle search rule is stated", () => { + render(); + expect(screen.getByText("members.invite.handleRule")).toBeTruthy(); +}); + +test("seats render an explicit unknown state, never a question mark", () => { + render(); + expect(screen.getByText("members.invite.seatsUnknown")).toBeTruthy(); +}); + +test("with no role named member, the least privileged role is the default", () => { + const noMemberRoles = [ + { id: "r-owner", name: "owner", position: 0, is_builtin: true }, + { id: "r-manager", name: "manager", position: 1, is_builtin: true }, + { id: "r-editor", name: "editor", position: 2, is_builtin: true }, + { id: "r-connect", name: "connect-only", position: 3, is_builtin: true }, + ] as unknown as TeamRole[]; + render(); + expect(screen.getByText("connect-only").getAttribute("aria-pressed")).toBe("true"); +}); + +test("choosing a role and a person invites with that role", async () => { + h.results = [{ user_id: "u1", handle: "bob-builder", display_name: "bob-builder", is_teammate: false }]; + h.inviteUserById.mockResolvedValue({ status: "pending" }); + render(); + fireEvent.click(screen.getByText("editor")); + fireEvent.click(screen.getByText("members.invite.inviteAction")); + await waitFor(() => expect(h.inviteUserById).toHaveBeenCalledWith( + expect.objectContaining({ teamId: "t1", userId: "u1", roleName: "editor", roleId: "r-editor" }), + )); +}); diff --git a/src/components/vault-share/InviteControl.tsx b/src/components/vault-share/InviteControl.tsx new file mode 100644 index 000000000..d441e4276 --- /dev/null +++ b/src/components/vault-share/InviteControl.tsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Icon } from "@iconify/react"; +import type { TeamRole } from "@/stores/teamStore"; +import { useUserSearch } from "@/hooks/useUserSearch"; +import { assignableRoles, seatState } from "./vaultShareModel"; +import { inviteUserById, inviteByEmailAddress } from "@/services/vaultShare"; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +interface Props { + teamId: string; + roles: TeamRole[]; + existingIds: Set; + usedSeats: number | null; + totalSeats: number | null; + onInvited: () => void; +} + +export function InviteControl({ teamId, roles, existingIds, usedSeats, totalSeats, onInvited }: Props) { + const { t } = useTranslation(); + const options = assignableRoles(roles); + const seats = seatState(usedSeats, totalSeats); + const [roleId, setRoleId] = useState(null); + const search = useUserSearch(existingIds); + + useEffect(() => { + if (!roleId && options.length > 0) { + setRoleId(options.find((r) => r.name === "member")?.id ?? options[options.length - 1].id); + } + }, [options, roleId]); + + const role = options.find((r) => r.id === roleId) ?? null; + const isEmail = EMAIL_RE.test(search.query.trim()); + + const invite = async (userId: string, handle: string) => { + if (!role) return; + try { + await inviteUserById({ teamId, userId, handle, roleName: role.name, roleId: role.id }); + } catch { + return; + } + search.reset(); + onInvited(); + }; + + const inviteEmail = async () => { + if (!role) return; + try { + await inviteByEmailAddress({ teamId, email: search.query.trim(), roleName: role.name }); + } catch { + return; + } + search.reset(); + onInvited(); + }; + + return ( +
+
+ + {t("members.invite.searchOrInviteLabel")} + +
+ + search.setQuery(e.target.value)} + placeholder={t("members.invite.searchUserPlaceholder")} + className="bg-transparent outline-none text-xs flex-1 text-(--t-text-primary)" + /> +
+ + {search.open && search.results.map((u) => ( +
+ {u.handle} + +
+ ))} + + {isEmail && ( + + )} + +

{t("members.invite.handleRule")}

+
+ +
+ + {t("members.invite.joinsAs")} + +
+ {options.map((r) => ( + + ))} +
+ {role &&

{t(`members.roleBlurb.${role.name}`, { defaultValue: "" })}

} +
+ + + {seats.kind === "known" + ? t("members.invite.seatsSummary", { used: seats.used, available: seats.available, total: seats.total }) + : t("members.invite.seatsUnknown")} + +
+ ); +} diff --git a/src/components/vault-share/PeopleList.test.tsx b/src/components/vault-share/PeopleList.test.tsx new file mode 100644 index 000000000..55aad04ed --- /dev/null +++ b/src/components/vault-share/PeopleList.test.tsx @@ -0,0 +1,45 @@ +import { test, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; + +vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => k }) })); +vi.mock("@iconify/react", () => ({ Icon: () => null })); +vi.mock("@/components/shared/PresenceAvatar", () => ({ + PresenceAvatar: ({ handle }: { handle: string }) =>
{`avatar:${handle}`}
, +})); + +import { PeopleList, type Person } from "./PeopleList"; + +const person = (over: Partial): Person => ({ + userId: "u1", handle: "bob-builder", roleNames: ["member"], online: false, state: "member", ...over, +}); + +afterEach(cleanup); + +test("the handle is rendered in full, never truncated in markup", () => { + render(); + expect(screen.getByText("bob-builder")).toBeTruthy(); +}); + +test("pending people appear in the same list with a pending marker", () => { + render(); + expect(screen.getByText("members.people.pending")).toBeTruthy(); +}); + +test("a pending person offers a copy-link action", () => { + const onCopy = vi.fn(); + render(); + fireEvent.click(screen.getByTitle("members.people.copyInviteLink")); + expect(onCopy).toHaveBeenCalledWith(expect.objectContaining({ invitationId: "inv-1" })); +}); + +test("an awaiting-key person offers Grant now to a manager", () => { + const onGrant = vi.fn(); + render(); + fireEvent.click(screen.getByText("members.people.grantKey")); + expect(onGrant).toHaveBeenCalled(); +}); + +test("a non-manager sees no destructive actions", () => { + render(); + expect(screen.queryByTitle("members.people.remove")).toBeNull(); +}); diff --git a/src/components/vault-share/PeopleList.tsx b/src/components/vault-share/PeopleList.tsx new file mode 100644 index 000000000..279518328 --- /dev/null +++ b/src/components/vault-share/PeopleList.tsx @@ -0,0 +1,80 @@ +import { useTranslation } from "react-i18next"; +import { Icon } from "@iconify/react"; +import { PresenceAvatar } from "@/components/shared/PresenceAvatar"; +import { roleChipColors } from "@/components/members/roleChips"; + +export type Person = { + userId: string; + handle: string; + roleNames: string[]; + online: boolean; + state: "member" | "pending" | "awaiting_key"; + invitationId?: string; +}; + +interface Props { + people: Person[]; + canManage: boolean; + onRemove: (p: Person) => void; + onRevoke: (p: Person) => void; + onGrantKey: (p: Person) => void; + onCopyInviteLink: (p: Person) => void; +} + +export function PeopleList({ people, canManage, onRemove, onRevoke, onGrantKey, onCopyInviteLink }: Props) { + const { t } = useTranslation(); + return ( +
+ {people.map((p) => ( +
+ +
+ {/* No truncation: the handle is what an owner reads before granting access. */} + {p.handle} + {p.state === "awaiting_key" && ( + {t("members.people.awaitingKey")} + )} +
+ + {p.state === "pending" && ( + + {t("members.people.pending")} + + )} + + {p.roleNames.map((name) => { + const { color, bg } = roleChipColors(name); + return ( + + {name} + + ); + })} + + {p.state === "awaiting_key" && canManage && ( + + )} + + {p.state === "pending" && ( + + )} + + {canManage && ( + + )} +
+ ))} +
+ ); +} diff --git a/src/components/vault-share/VaultShareSheet.test.tsx b/src/components/vault-share/VaultShareSheet.test.tsx new file mode 100644 index 000000000..442aabafd --- /dev/null +++ b/src/components/vault-share/VaultShareSheet.test.tsx @@ -0,0 +1,67 @@ +import { test, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; + +const h = vi.hoisted(() => ({ + vault: { id: "v1", name: "Personal", teamId: null as string | null }, + members: [] as unknown[], + roles: [] as unknown[], + pending: [] as unknown[], +})); + +vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => k }) })); +vi.mock("@iconify/react", () => ({ Icon: () => null })); +vi.mock("./PeopleList", () => ({ PeopleList: ({ people }: { people: unknown[] }) =>
{`people:${people.length}`}
})); +vi.mock("./InviteControl", () => ({ InviteControl: () =>
invite-control
})); +vi.mock("./ConvertToTeamGate", () => ({ ConvertToTeamGate: () =>
convert-gate
})); +vi.mock("@/stores/vaultStore", () => ({ + useVaultStore: Object.assign((sel: (s: unknown) => unknown) => sel({ vaults: [h.vault] }), { + getState: () => ({ vaults: [h.vault] }), + }), +})); +vi.mock("@/stores/teamStore", () => ({ + useTeamStore: Object.assign( + (sel: (s: unknown) => unknown) => + sel({ + membersByTeam: { t1: h.members }, + rolesByTeam: { t1: h.roles }, + pendingInvitationsByTeam: { t1: h.pending }, + loadMembers: vi.fn(), loadRoles: vi.fn(), loadPendingInvitations: vi.fn(), + }), + { getState: () => ({ loadMembers: vi.fn(), loadRoles: vi.fn(), loadPendingInvitations: vi.fn() }) }, + ), +})); +vi.mock("@/stores/subscriptionStore", () => ({ + useSubscriptionStore: Object.assign((sel: (s: unknown) => unknown) => sel({ usedSeats: 1, totalSeats: 10, load: vi.fn() }), { + getState: () => ({ load: vi.fn() }), + }), +})); +vi.mock("@/stores/teamVaultStateStore", () => ({ + useTeamVaultStateStore: Object.assign((sel: (s: unknown) => unknown) => sel({ statusByTeamId: {} }), { + getState: () => ({ statusByTeamId: {} }), + }), +})); +vi.mock("@/services/teamService", () => ({ getMyUserId: vi.fn().mockResolvedValue(null) })); + +import { VaultShareSheet } from "./VaultShareSheet"; + +afterEach(() => { cleanup(); h.vault.teamId = null; }); + +test("a private vault shows the conversion gate instead of the tabs", () => { + render(); + expect(screen.getByText("convert-gate")).toBeTruthy(); + expect(screen.queryByText("invite-control")).toBeNull(); +}); + +test("a team vault shows the tabs, People first", () => { + h.vault.teamId = "t1"; + render(); + expect(screen.getByText("people:0")).toBeTruthy(); + expect(screen.queryByText("convert-gate")).toBeNull(); +}); + +test("switching to Invite renders the invite control", () => { + h.vault.teamId = "t1"; + render(); + fireEvent.click(screen.getByText("members.share.tabInvite")); + expect(screen.getByText("invite-control")).toBeTruthy(); +}); diff --git a/src/components/vault-share/VaultShareSheet.tsx b/src/components/vault-share/VaultShareSheet.tsx new file mode 100644 index 000000000..e80d6c3c6 --- /dev/null +++ b/src/components/vault-share/VaultShareSheet.tsx @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useVaultStore } from "@/stores/vaultStore"; +import { useTeamStore } from "@/stores/teamStore"; +import { useSubscriptionStore } from "@/stores/subscriptionStore"; +import { useTeamVaultStateStore } from "@/stores/teamVaultStateStore"; +import { getMyUserId } from "@/services/teamService"; +import { ConvertToTeamGate } from "./ConvertToTeamGate"; +import { InviteControl } from "./InviteControl"; +import { PeopleList, type Person } from "./PeopleList"; +import { canManageShare } from "./vaultShareModel"; + +type Tab = "people" | "invite" | "links"; + +interface Props { + vaultId: string; + variant: "popover" | "full"; + onRequestFull?: () => void; +} + +export function VaultShareSheet({ vaultId, variant, onRequestFull }: Props) { + const { t } = useTranslation(); + const vault = useVaultStore((s) => s.vaults.find((v) => v.id === vaultId)); + const teamId = vault?.teamId ?? null; + + const membersByTeam = useTeamStore((s) => s.membersByTeam); + const rolesByTeam = useTeamStore((s) => s.rolesByTeam); + const pendingInvitationsByTeam = useTeamStore((s) => s.pendingInvitationsByTeam); + const statusByTeamId = useTeamVaultStateStore((s) => s.statusByTeamId); + const usedSeats = useSubscriptionStore((s) => s.usedSeats); + const totalSeats = useSubscriptionStore((s) => s.totalSeats); + + const [tab, setTab] = useState("people"); + // Owned by the sheet, never by a tab: the bug this component replaces was an + // error written to a panel the same handler had just unmounted. + const [error, setError] = useState(""); + + // undefined while resolving, null once resolved with no signed-in user. + const [myUserId, setMyUserId] = useState(undefined); + + useEffect(() => { + let cancelled = false; + getMyUserId() + .then((id) => { if (!cancelled) setMyUserId(id); }) + .catch(() => { if (!cancelled) setMyUserId(null); }); + return () => { cancelled = true; }; + }, []); + + useEffect(() => { + if (!teamId) return; + setError(""); + const { loadMembers, loadRoles, loadPendingInvitations } = useTeamStore.getState(); + void Promise.allSettled([loadMembers(teamId), loadRoles(teamId), loadPendingInvitations(teamId)]).then( + (results) => { + if (results.some((r) => r.status === "rejected")) setError(t("members.share.loadFailed")); + }, + ); + void useSubscriptionStore.getState().load(); + // t intentionally omitted: this loads once per teamId, not once per render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [teamId]); + + const people = useMemo(() => { + if (!teamId) return []; + const roles = rolesByTeam[teamId] ?? []; + const roleName = (id: string) => roles.find((r) => r.id === id)?.name ?? ""; + const awaiting = statusByTeamId[teamId] === "awaiting_key"; + const members: Person[] = (membersByTeam[teamId] ?? []).map((m) => ({ + userId: m.user_id, + handle: m.handle ?? "?", + roleNames: m.role_ids.map(roleName).filter(Boolean), + online: !!m.is_online, + state: awaiting ? "awaiting_key" : "member", + })); + const pending: Person[] = (pendingInvitationsByTeam[teamId] ?? []).map((inv) => ({ + userId: inv.id, + handle: inv.display_name, + roleNames: [inv.role], + online: false, + state: "pending", + invitationId: inv.id, + })); + return [...members, ...pending]; + }, [teamId, membersByTeam, rolesByTeam, pendingInvitationsByTeam, statusByTeamId]); + + // While the caller's own id is still resolving, treat them as not a manager + // rather than flash manage controls and then withdraw them. + const myRoleNames = useMemo(() => { + if (!teamId || myUserId == null) return []; + const me = (membersByTeam[teamId] ?? []).find((m) => m.user_id === myUserId); + if (!me) return []; + const roles = rolesByTeam[teamId] ?? []; + return me.role_ids + .map((rid) => roles.find((r) => r.id === rid)?.name) + .filter((n): n is string => !!n); + }, [teamId, myUserId, membersByTeam, rolesByTeam]); + + if (!vault) return null; + + if (!teamId) { + return ( + onRequestFull?.()} + onConverted={() => setTab("invite")} + /> + ); + } + + const canManage = canManageShare(myRoleNames); + + return ( +
+
+ + +
+ + {error &&

{error}

} + + {tab === "people" && ( + {}} + onRevoke={() => {}} + onGrantKey={() => {}} + onCopyInviteLink={() => {}} + /> + )} + + {tab === "invite" && ( + p.userId))} + usedSeats={usedSeats} + totalSeats={totalSeats} + onInvited={() => setTab("people")} + /> + )} + + {variant === "popover" && ( + + )} +
+ ); +} diff --git a/src/components/vault-share/vaultShareModel.test.ts b/src/components/vault-share/vaultShareModel.test.ts new file mode 100644 index 000000000..35a9e001f --- /dev/null +++ b/src/components/vault-share/vaultShareModel.test.ts @@ -0,0 +1,40 @@ +import { test, expect } from "vitest"; +import { assignableRoles, leastPrivilegedRole, seatState, canManageShare, canMintLink } from "./vaultShareModel"; +import type { TeamRole } from "@/stores/teamStore"; + +const role = (name: string, position: number, is_builtin = true): TeamRole => + ({ id: `id-${name}`, name, position, is_builtin, permissions: 0, color: null } as unknown as TeamRole); + +test("assignable roles drop owner and sort by position", () => { + const roles = [role("member", 3), role("owner", 0), role("manager", 1), role("editor", 2)]; + expect(assignableRoles(roles).map((r) => r.name)).toEqual(["manager", "editor", "member"]); +}); + +test("assignable roles keep custom roles", () => { + const roles = [role("owner", 0), role("auditor", 5, false)]; + expect(assignableRoles(roles).map((r) => r.name)).toEqual(["auditor"]); +}); + +test("least privileged role is the highest-position assignable role, never owner", () => { + const roles = [role("member", 3), role("owner", 0), role("manager", 1), role("connect-only", 4)]; + expect(leastPrivilegedRole(roles)?.name).toBe("connect-only"); + expect(leastPrivilegedRole([])).toBeNull(); +}); + +test("seat state reports unknown instead of question marks", () => { + expect(seatState(undefined, undefined)).toEqual({ kind: "unknown" }); + expect(seatState(0, null)).toEqual({ kind: "unknown" }); +}); + +test("seat state computes availability when known", () => { + expect(seatState(2, 10)).toEqual({ kind: "known", used: 2, total: 10, available: 8, atLimit: false }); + expect(seatState(10, 10)).toEqual({ kind: "known", used: 10, total: 10, available: 0, atLimit: true }); +}); + +test("only owner and manager may manage sharing or mint links", () => { + expect(canManageShare(["owner"])).toBe(true); + expect(canManageShare(["manager"])).toBe(true); + expect(canManageShare(["editor"])).toBe(false); + expect(canMintLink(["member"])).toBe(false); + expect(canMintLink(["manager"])).toBe(true); +}); diff --git a/src/components/vault-share/vaultShareModel.ts b/src/components/vault-share/vaultShareModel.ts new file mode 100644 index 000000000..da3ed44dd --- /dev/null +++ b/src/components/vault-share/vaultShareModel.ts @@ -0,0 +1,46 @@ +import type { TeamRole } from "@/stores/teamStore"; + +/** Roles an owner may hand out: everything except owner, in the server's order. */ +export function assignableRoles(roles: TeamRole[]): TeamRole[] { + return roles + .filter((r) => !(r.is_builtin && r.name === "owner")) + .sort((a, b) => a.position - b.position); +} + +/** + * The safest possible stand-in when a role choice is ambiguous: whichever + * assignable role has the least access (highest `position`), never a more + * privileged one such as "member". + */ +export function leastPrivilegedRole(roles: TeamRole[]): TeamRole | null { + const sorted = assignableRoles(roles); + return sorted.length > 0 ? sorted[sorted.length - 1] : null; +} + +export type SeatState = + | { kind: "known"; used: number; total: number; available: number; atLimit: boolean } + | { kind: "unknown" }; + +/** + * A failed subscription load used to render as "? available · ? total". + * Unknown is its own state so the UI can say so in words. + */ +export function seatState( + used: number | null | undefined, + total: number | null | undefined, +): SeatState { + if (typeof used !== "number" || typeof total !== "number") return { kind: "unknown" }; + const available = Math.max(0, total - used); + return { kind: "known", used, total, available, atLimit: available <= 0 }; +} + +const MANAGING_ROLES = new Set(["owner", "manager"]); + +export function canManageShare(myRoleNames: string[]): boolean { + return myRoleNames.some((n) => MANAGING_ROLES.has(n)); +} + +/** Same gate as the server puts on minting a grant. */ +export function canMintLink(myRoleNames: string[]): boolean { + return canManageShare(myRoleNames); +} diff --git a/src/i18n/locales/en/members.json b/src/i18n/locales/en/members.json index 1193854b3..c86427a88 100644 --- a/src/i18n/locales/en/members.json +++ b/src/i18n/locales/en/members.json @@ -52,6 +52,24 @@ "JOIN_TERMINAL_SESSION": "Join sessions", "VIEW_TERMINAL_SESSIONS": "View sessions" }, + "convert": { + "title": "Share \"{{vault}}\" with other people?", + "body": "This turns your private vault into a team vault.", + "offlineWarning": "Opens only while Voltius can reach the sync server — no offline access.", + "keyCustody": "Everyone you add gets a copy of its key. Removing them later doesn't take it back.", + "change": "There's no way back to private.", + "cancel": "Cancel", + "confirm": "Turn into a team vault" + }, + "share": { + "shareVerb": "Share", + "title": "Share {{vault}}", + "tabPeople": "People", + "tabInvite": "Invite", + "manage": "Manage…", + "inviteSomeone": "Invite someone", + "loadFailed": "Couldn't load everyone — try again" + }, "invite": { "title": "Invite member", "seatsLabel": "Seats", @@ -66,7 +84,18 @@ "addAsRole": "Add as {{role}}", "noRoleFallback": "no role", "sendInviteLabel": "Send invite to", - "inviteArrow": "Invite →" + "inviteArrow": "Invite →", + "handleRule": "Only custom handles match on part of a name. For a generated handle like rapid-violet-8884, type it in full or use their email.", + "seatsUnknown": "Seat count unavailable — check your connection to the sync server", + "inviteAction": "Invite", + "joinsAs": "They join as", + "selectRoleHint": "Select at least one role before inviting" + }, + "roleBlurb": { + "manager": "Managers can invite people, change roles and manage links.", + "editor": "Editors can add and change hosts, keys and snippets in this vault.", + "member": "Members can use everything in this vault but not change it.", + "connect-only": "Connect-only people can open connections but never see the credentials behind them." }, "toast": { "removingRoleFrom": "Removing {{role}} from {{name}}...", @@ -83,13 +112,18 @@ "invitingEmail": "Inviting {{email}}...", "invitationSentToEmail": "Invitation sent to {{email}}", "invitingToSession": "Inviting {{name}} to session...", - "invitedToSession": "Invited {{name}} to session" + "invitedToSession": "Invited {{name}} to session", + "convertingVault": "Turning {{vault}} into a team vault...", + "vaultConverted": "{{vault}} is now a team vault" }, "error": { "cannotRemoveOwnerRole": "Cannot remove the owner role from the team owner", "failedToUpdateRole": "Failed to update role", "failedToRemoveMember": "Failed to remove member", - "failedToAddMember": "Failed to add member" + "failedToAddMember": "Failed to add member", + "inviteFailed": "Could not invite {{name}} — {{reason}}", + "convertFailed": "Could not turn {{vault}} into a team vault — {{reason}}", + "serverUnreachable": "the sync server could not be reached" }, "history": { "removeRole": "Remove role: {{name}}", @@ -114,6 +148,14 @@ "signInToInvite": "Sign in to invite teammates to this vault.", "vaultNotFound": "Vault not found." }, + "people": { + "pending": "Pending", + "awaitingKey": "Waiting for a key — they cannot open the vault yet", + "grantKey": "Grant now", + "copyInviteLink": "Copy invite link", + "revoke": "Revoke invitation", + "remove": "Remove from vault" + }, "cta": { "signInTitle": "Sign in to use team features", "signInDesc": "Members, invites, and shared vault access require a cloud account before you can upgrade or manage a team.", diff --git a/src/i18n/locales/fr/members.json b/src/i18n/locales/fr/members.json index 2481ff921..bad3f20d1 100644 --- a/src/i18n/locales/fr/members.json +++ b/src/i18n/locales/fr/members.json @@ -52,6 +52,24 @@ "JOIN_TERMINAL_SESSION": "Rejoindre des sessions", "VIEW_TERMINAL_SESSIONS": "Voir les sessions" }, + "convert": { + "title": "Partager « {{vault}} » avec d'autres personnes ?", + "body": "Ceci transforme votre coffre-fort privé en coffre-fort d'équipe.", + "offlineWarning": "Ne s'ouvre que si Voltius joint le serveur de synchronisation — pas d'accès hors ligne.", + "keyCustody": "Chaque personne ajoutée reçoit une copie de la clé. La retirer plus tard ne la reprend pas.", + "change": "Il n'y a pas de retour possible au statut privé.", + "cancel": "Annuler", + "confirm": "Transformer en coffre-fort d'équipe" + }, + "share": { + "shareVerb": "Partager", + "title": "Partager {{vault}}", + "tabPeople": "Personnes", + "tabInvite": "Inviter", + "manage": "Gérer…", + "inviteSomeone": "Inviter quelqu'un", + "loadFailed": "Impossible de charger la liste — réessayez" + }, "invite": { "title": "Inviter un membre", "seatsLabel": "Postes", @@ -66,7 +84,18 @@ "addAsRole": "Ajouter en tant que {{role}}", "noRoleFallback": "aucun rôle", "sendInviteLabel": "Envoyer une invitation à", - "inviteArrow": "Inviter →" + "inviteArrow": "Inviter →", + "handleRule": "Seuls les pseudos personnalisés se retrouvent avec une partie du nom. Pour un pseudo généré comme rapid-violet-8884, saisissez-le en entier ou utilisez son e-mail.", + "seatsUnknown": "Nombre de postes indisponible — vérifiez votre connexion au serveur de synchronisation", + "inviteAction": "Inviter", + "joinsAs": "Ils rejoignent en tant que", + "selectRoleHint": "Sélectionnez au moins un rôle avant d'inviter" + }, + "roleBlurb": { + "manager": "Les gestionnaires peuvent inviter des personnes, changer les rôles et gérer les liens.", + "editor": "Les éditeurs peuvent ajouter et modifier les hôtes, clés et snippets de ce coffre-fort.", + "member": "Les membres peuvent tout utiliser dans ce coffre-fort mais pas le modifier.", + "connect-only": "Les personnes en connexion seule peuvent ouvrir des connexions mais ne voient jamais les identifiants derrière." }, "toast": { "removingRoleFrom": "Suppression du rôle {{role}} de {{name}}…", @@ -83,13 +112,18 @@ "invitingEmail": "Invitation de {{email}}…", "invitationSentToEmail": "Invitation envoyée à {{email}}", "invitingToSession": "Invitation de {{name}} à la session…", - "invitedToSession": "{{name}} invité à la session" + "invitedToSession": "{{name}} invité à la session", + "convertingVault": "Transformation de {{vault}} en coffre-fort d'équipe…", + "vaultConverted": "{{vault}} est désormais un coffre-fort d'équipe" }, "error": { "cannotRemoveOwnerRole": "Impossible de retirer le rôle de propriétaire au propriétaire de l'équipe", "failedToUpdateRole": "Échec de la mise à jour du rôle", "failedToRemoveMember": "Échec de la suppression du membre", - "failedToAddMember": "Échec de l'ajout du membre" + "failedToAddMember": "Échec de l'ajout du membre", + "inviteFailed": "Impossible d'inviter {{name}} — {{reason}}", + "convertFailed": "Impossible de transformer {{vault}} en coffre-fort d'équipe — {{reason}}", + "serverUnreachable": "le serveur de synchronisation est injoignable" }, "history": { "removeRole": "Retirer le rôle : {{name}}", @@ -114,6 +148,14 @@ "signInToInvite": "Connectez-vous pour inviter des collègues dans ce coffre-fort.", "vaultNotFound": "Coffre-fort introuvable." }, + "people": { + "pending": "En attente", + "awaitingKey": "Attente d'une clé — impossible d'ouvrir le coffre-fort pour le moment", + "grantKey": "Autoriser maintenant", + "copyInviteLink": "Copier le lien d'invitation", + "revoke": "Révoquer l'invitation", + "remove": "Retirer du coffre-fort" + }, "cta": { "signInTitle": "Connectez-vous pour utiliser les fonctionnalités d'équipe", "signInDesc": "Les membres, invitations et l'accès partagé aux coffres-forts nécessitent un compte cloud avant de pouvoir mettre à niveau ou gérer une équipe.", diff --git a/src/i18n/locales/ru/members.json b/src/i18n/locales/ru/members.json index e06e9bd98..0fbddc350 100644 --- a/src/i18n/locales/ru/members.json +++ b/src/i18n/locales/ru/members.json @@ -54,6 +54,24 @@ "JOIN_TERMINAL_SESSION": "Присоединение к сессиям", "VIEW_TERMINAL_SESSIONS": "Просмотр сессий" }, + "convert": { + "title": "Предоставить общий доступ к \"{{vault}}\" другим людям?", + "body": "Это превратит ваше приватное хранилище в командное.", + "offlineWarning": "Открывается, только пока Voltius может связаться с сервером синхронизации — без офлайн-доступа.", + "keyCustody": "Каждый добавленный получает копию ключа. Удаление позже его не отзовёт.", + "change": "Вернуть приватный статус будет нельзя.", + "cancel": "Отмена", + "confirm": "Превратить в командное хранилище" + }, + "share": { + "shareVerb": "Поделиться", + "title": "Поделиться «{{vault}}»", + "tabPeople": "Люди", + "tabInvite": "Пригласить", + "manage": "Управление…", + "inviteSomeone": "Пригласить кого-то", + "loadFailed": "Не удалось загрузить список — попробуйте снова" + }, "invite": { "title": "Пригласить участника", "seatsLabel": "Места", @@ -68,7 +86,18 @@ "addAsRole": "Добавить как {{role}}", "noRoleFallback": "без роли", "sendInviteLabel": "Отправить приглашение", - "inviteArrow": "Пригласить →" + "inviteArrow": "Пригласить →", + "handleRule": "По части имени находятся только пользовательские ники. Сгенерированный ник вроде rapid-violet-8884 нужно ввести полностью, либо пригласите по email.", + "seatsUnknown": "Количество мест недоступно — проверьте соединение с сервером синхронизации", + "inviteAction": "Пригласить", + "joinsAs": "Присоединится как", + "selectRoleHint": "Выберите хотя бы одну роль перед приглашением" + }, + "roleBlurb": { + "manager": "Менеджеры могут приглашать людей, менять роли и управлять ссылками.", + "editor": "Редакторы могут добавлять и изменять хосты, ключи и сниппеты в этом хранилище.", + "member": "Участники могут пользоваться всем в этом хранилище, но не изменять его.", + "connect-only": "Люди с доступом только для подключения могут открывать соединения, но никогда не видят учётные данные за ними." }, "toast": { "removingRoleFrom": "Удаление роли {{role}} у {{name}}...", @@ -85,13 +114,18 @@ "invitingEmail": "Приглашение {{email}}...", "invitationSentToEmail": "Приглашение отправлено на {{email}}", "invitingToSession": "Приглашение {{name}} в сессию...", - "invitedToSession": "{{name}} приглашён в сессию" + "invitedToSession": "{{name}} приглашён в сессию", + "convertingVault": "Преобразование {{vault}} в командное хранилище...", + "vaultConverted": "{{vault}} теперь командное хранилище" }, "error": { "cannotRemoveOwnerRole": "Нельзя удалить роль владельца у владельца команды", "failedToUpdateRole": "Не удалось обновить роль", "failedToRemoveMember": "Не удалось удалить участника", - "failedToAddMember": "Не удалось добавить участника" + "failedToAddMember": "Не удалось добавить участника", + "inviteFailed": "Не удалось пригласить {{name}} — {{reason}}", + "convertFailed": "Не удалось превратить {{vault}} в командное хранилище — {{reason}}", + "serverUnreachable": "сервер синхронизации недоступен" }, "history": { "removeRole": "Удалить роль: {{name}}", @@ -118,6 +152,14 @@ "signInToInvite": "Войдите, чтобы приглашать участников команды в это хранилище.", "vaultNotFound": "Хранилище не найдено." }, + "people": { + "pending": "Ожидает", + "awaitingKey": "Ожидание ключа — они не смогут открыть хранилище", + "grantKey": "Предоставить сейчас", + "copyInviteLink": "Копировать ссылку приглашения", + "revoke": "Отозвать приглашение", + "remove": "Удалить из хранилища" + }, "cta": { "signInTitle": "Войдите, чтобы использовать командные функции", "signInDesc": "Для работы с участниками, приглашениями и общим доступом к хранилищу требуется облачная учётная запись, прежде чем вы сможете улучшить план или управлять командой.", diff --git a/src/i18n/locales/zh/members.json b/src/i18n/locales/zh/members.json index d8abb4b55..65f5f0275 100644 --- a/src/i18n/locales/zh/members.json +++ b/src/i18n/locales/zh/members.json @@ -52,6 +52,24 @@ "JOIN_TERMINAL_SESSION": "加入会话", "VIEW_TERMINAL_SESSIONS": "查看会话" }, + "convert": { + "title": "要与他人共享 \"{{vault}}\" 吗?", + "body": "这会将您的私有保险库转变为团队保险库。", + "offlineWarning": "仅在 Voltius 可连接同步服务器时才能打开——不支持离线访问。", + "keyCustody": "您添加的每个人都会获得密钥副本。之后移除也无法收回。", + "change": "无法再改回私有状态。", + "cancel": "取消", + "confirm": "转换为团队保险库" + }, + "share": { + "shareVerb": "共享", + "title": "共享 {{vault}}", + "tabPeople": "成员", + "tabInvite": "邀请", + "manage": "管理…", + "inviteSomeone": "邀请某人", + "loadFailed": "无法加载列表——请重试" + }, "invite": { "title": "邀请成员", "seatsLabel": "座位", @@ -66,7 +84,18 @@ "addAsRole": "添加为 {{role}}", "noRoleFallback": "无角色", "sendInviteLabel": "发送邀请给", - "inviteArrow": "邀请 →" + "inviteArrow": "邀请 →", + "handleRule": "只有自定义用户名支持按部分名称匹配。对于自动生成的用户名(如 rapid-violet-8884),请完整输入,或改用对方的邮箱邀请。", + "seatsUnknown": "座位数量暂不可用 — 请检查与同步服务器的连接", + "inviteAction": "邀请", + "joinsAs": "他们将以此身份加入", + "selectRoleHint": "邀请前请至少选择一个角色" + }, + "roleBlurb": { + "manager": "管理员可以邀请他人、更改角色并管理链接。", + "editor": "编辑者可以在此保险库中添加和更改主机、密钥和代码片段。", + "member": "成员可以使用此保险库中的所有内容,但无法更改。", + "connect-only": "仅连接权限的人可以打开连接,但永远无法看到其背后的凭据。" }, "toast": { "removingRoleFrom": "正在从 {{name}} 移除 {{role}}…", @@ -83,13 +112,18 @@ "invitingEmail": "正在邀请 {{email}}…", "invitationSentToEmail": "已发送邀请给 {{email}}", "invitingToSession": "正在邀请 {{name}} 加入会话…", - "invitedToSession": "已邀请 {{name}} 加入会话" + "invitedToSession": "已邀请 {{name}} 加入会话", + "convertingVault": "正在将 {{vault}} 转换为团队保险库…", + "vaultConverted": "{{vault}} 已成为团队保险库" }, "error": { "cannotRemoveOwnerRole": "无法从团队所有者移除所有者角色", "failedToUpdateRole": "更新角色失败", "failedToRemoveMember": "移除成员失败", - "failedToAddMember": "添加成员失败" + "failedToAddMember": "添加成员失败", + "inviteFailed": "无法邀请 {{name}} — {{reason}}", + "convertFailed": "无法将 {{vault}} 转换为团队保险库 — {{reason}}", + "serverUnreachable": "无法连接到同步服务器" }, "history": { "removeRole": "移除角色:{{name}}", @@ -114,6 +148,14 @@ "signInToInvite": "登录以邀请队友加入此保险库。", "vaultNotFound": "未找到保险库。" }, + "people": { + "pending": "待处理", + "awaitingKey": "等待获取密钥 — 他们暂时无法打开保险库", + "grantKey": "立即授予", + "copyInviteLink": "复制邀请链接", + "revoke": "撤销邀请", + "remove": "从保险库中移除" + }, "cta": { "signInTitle": "登录以使用团队功能", "signInDesc": "成员、邀请和共享保险库访问需要云账户才能升级或管理团队。", diff --git a/src/services/vaultConvert.test.ts b/src/services/vaultConvert.test.ts new file mode 100644 index 000000000..5dbcfc78b --- /dev/null +++ b/src/services/vaultConvert.test.ts @@ -0,0 +1,51 @@ +import { test, expect, vi, beforeEach } from "vitest"; + +const h = vi.hoisted(() => ({ + createTeam: vi.fn(), + setVaultTeamId: vi.fn(), + initTeamVaultKey: vi.fn(), + markLoaded: vi.fn(), +})); + +vi.mock("@/stores/teamStore", () => ({ + useTeamStore: { getState: () => ({ createTeam: h.createTeam }) }, +})); +vi.mock("@/stores/vaultStore", () => ({ + useVaultStore: { getState: () => ({ setVaultTeamId: h.setVaultTeamId }) }, +})); +vi.mock("@/stores/teamVaultStateStore", () => ({ + useTeamVaultStateStore: { getState: () => ({}) }, +})); +vi.mock("@/services/teamVaultSync", () => ({ + initTeamVaultKey: h.initTeamVaultKey, +})); +vi.mock("@/services/teamVaultActivation", () => ({ + markTeamVaultLoadedAfterLocalActivation: h.markLoaded, +})); +vi.mock("@/services/teamActionFeedback", () => ({ + runTeamAction: async (o: { run: () => Promise }) => o.run(), +})); +vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); + +import { convertVaultToTeam } from "./vaultConvert"; + +beforeEach(() => { + h.createTeam.mockReset(); + h.setVaultTeamId.mockReset(); + h.initTeamVaultKey.mockReset(); +}); + +test("conversion links the vault and initialises the key", async () => { + h.createTeam.mockResolvedValue({ id: "team-1" }); + h.initTeamVaultKey.mockResolvedValue(undefined); + const teamId = await convertVaultToTeam("vault-1", "Personal"); + expect(teamId).toBe("team-1"); + expect(h.setVaultTeamId).toHaveBeenCalledWith("vault-1", "team-1"); + expect(h.initTeamVaultKey).toHaveBeenCalledWith("team-1", []); +}); + +test("a failed key init surfaces rather than leaving a silent half-conversion", async () => { + h.createTeam.mockResolvedValue({ id: "team-1" }); + h.initTeamVaultKey.mockRejectedValue(new Error("no key")); + await expect(convertVaultToTeam("vault-1", "Personal")).rejects.toThrow("no key"); +}); diff --git a/src/services/vaultConvert.ts b/src/services/vaultConvert.ts new file mode 100644 index 000000000..70c3811ab --- /dev/null +++ b/src/services/vaultConvert.ts @@ -0,0 +1,29 @@ +import i18n from "@/i18n"; +import { useTeamStore } from "@/stores/teamStore"; +import { useVaultStore } from "@/stores/vaultStore"; +import { useTeamVaultStateStore } from "@/stores/teamVaultStateStore"; +import { runTeamAction } from "@/services/teamActionFeedback"; +import { markTeamVaultLoadedAfterLocalActivation } from "@/services/teamVaultActivation"; + +/** + * Turn a private vault into a team vault. + * + * Deliberately does not invite anyone. Conversion used to be a side effect of + * the first invite, which meant one handler could convert the vault and then + * fail the invite with nothing rendered. + */ +export async function convertVaultToTeam(vaultId: string, vaultName: string): Promise { + return runTeamAction({ + pending: i18n.t("members.toast.convertingVault", { vault: vaultName }), + success: i18n.t("members.toast.vaultConverted", { vault: vaultName }), + error: (e: Error) => i18n.t("members.error.convertFailed", { vault: vaultName, reason: e.message }), + run: async () => { + const team = await useTeamStore.getState().createTeam(vaultName); + useVaultStore.getState().setVaultTeamId(vaultId, team.id); + const { initTeamVaultKey } = await import("@/services/teamVaultSync"); + await initTeamVaultKey(team.id, []); + markTeamVaultLoadedAfterLocalActivation(team.id, useTeamVaultStateStore.getState()); + return team.id; + }, + }); +} diff --git a/src/services/vaultShare.test.ts b/src/services/vaultShare.test.ts new file mode 100644 index 000000000..a27d2be17 --- /dev/null +++ b/src/services/vaultShare.test.ts @@ -0,0 +1,68 @@ +import { test, expect, vi, beforeEach } from "vitest"; + +const h = vi.hoisted(() => ({ + addMemberById: vi.fn(), + assignMemberRole: vi.fn(), + inviteByEmail: vi.fn(), +})); + +vi.mock("@/stores/teamStore", () => ({ + useTeamStore: { + getState: () => ({ addMemberById: h.addMemberById, assignMemberRole: h.assignMemberRole }), + }, +})); +vi.mock("@/services/teamService", () => ({ inviteByEmail: h.inviteByEmail })); +vi.mock("@/services/teamActionFeedback", () => ({ + runTeamAction: async (o: { run: () => Promise }) => o.run(), +})); +vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); + +import { inviteUserById, inviteByEmailAddress, inviteFailureReason } from "./vaultShare"; + +beforeEach(() => { + h.addMemberById.mockReset(); + h.assignMemberRole.mockReset(); + h.inviteByEmail.mockReset(); +}); + +test("the chosen role travels with the invitation", async () => { + h.addMemberById.mockResolvedValue({ status: "pending" }); + await inviteUserById({ teamId: "t1", userId: "u1", handle: "bob-builder", roleName: "editor", roleId: "r-editor" }); + expect(h.addMemberById).toHaveBeenCalledWith("t1", "u1", "editor"); +}); + +test("a pending invitee never gets a role assignment", async () => { + h.addMemberById.mockResolvedValue({ status: "pending" }); + await inviteUserById({ teamId: "t1", userId: "u1", handle: "bob-builder", roleName: "editor", roleId: "r-editor" }); + expect(h.assignMemberRole).not.toHaveBeenCalled(); +}); + +test("an already-member does get the role assigned", async () => { + h.addMemberById.mockResolvedValue({ status: "already_member" }); + await inviteUserById({ teamId: "t1", userId: "u1", handle: "bob-builder", roleName: "editor", roleId: "r-editor" }); + expect(h.assignMemberRole).toHaveBeenCalledWith("t1", "u1", "r-editor"); +}); + +test("failures propagate instead of being swallowed", async () => { + h.addMemberById.mockRejectedValue(new Error("boom")); + await expect( + inviteUserById({ teamId: "t1", userId: "u1", handle: "bob-builder", roleName: "editor", roleId: "r-editor" }), + ).rejects.toThrow("boom"); +}); + +test("email invites carry the role too", async () => { + h.inviteByEmail.mockResolvedValue({ status: "invited" }); + await inviteByEmailAddress({ teamId: "t1", email: "dave@example.com", roleName: "connect-only" }); + expect(h.inviteByEmail).toHaveBeenCalledWith("t1", "dave@example.com", "connect-only"); +}); + +test("a transport failure's raw URL is never returned as the reason", () => { + const raw = "error sending request for url (http://v68-server:8080/v1/teams/a5c2d19d/invite)"; + const reason = inviteFailureReason(new Error(raw)); + expect(reason).not.toContain("http"); + expect(reason).not.toBe(raw); +}); + +test("a real HTTP failure's short, already-translated message passes through unchanged", () => { + expect(inviteFailureReason(new Error("User not found"))).toBe("User not found"); +}); diff --git a/src/services/vaultShare.ts b/src/services/vaultShare.ts new file mode 100644 index 000000000..6e0981fc1 --- /dev/null +++ b/src/services/vaultShare.ts @@ -0,0 +1,68 @@ +import i18n from "@/i18n"; +import { useTeamStore } from "@/stores/teamStore"; +import { inviteByEmail } from "@/services/teamService"; +import { runTeamAction } from "@/services/teamActionFeedback"; + +const URL_IN_MESSAGE = /https?:\/\//i; + +/** + * A raw transport failure (no HTTP response — fetch/reqwest rejected before a + * status came back) can embed the server URL in its message, e.g. "error + * sending request for url (http://host:port/...)". That must never reach the + * UI, so collapse it to a translated, URL-free reason; any other error is + * already a short, translated, URL-free message and passes through as-is. + */ +export function inviteFailureReason(err: Error): string { + return URL_IN_MESSAGE.test(err.message) + ? i18n.t("members.error.serverUnreachable") + : err.message; +} + +/** + * Invite a known user. + * + * The role is passed to `addMemberById`, which the server stores on the pending + * invitation row. Assigning a role afterwards only works for someone who is + * already a member: a pending invitee has no `team_members` row, so that call + * 404s. The old code did exactly that and swallowed the failure, which is why an + * invitee always landed on the default role. + */ +export async function inviteUserById(args: { + teamId: string; + userId: string; + handle: string; + roleName: string; + roleId: string; +}): Promise<{ status: "pending" | "already_member" }> { + const { teamId, userId, handle, roleName, roleId } = args; + const { addMemberById, assignMemberRole } = useTeamStore.getState(); + + const result = await runTeamAction({ + pending: i18n.t("members.toast.invitingUser", { name: handle }), + success: (r: { status: string }) => + r.status === "pending" + ? i18n.t("members.toast.invitationSentToUser", { name: handle }) + : i18n.t("members.toast.userAdded", { name: handle }), + error: (e: Error) => i18n.t("members.error.inviteFailed", { name: handle, reason: inviteFailureReason(e) }), + run: () => addMemberById(teamId, userId, roleName), + }); + + if (result.status === "already_member") { + await assignMemberRole(teamId, userId, roleId); + } + return result; +} + +export async function inviteByEmailAddress(args: { + teamId: string; + email: string; + roleName: string; +}): Promise<{ status: "added" | "invited" }> { + const { teamId, email, roleName } = args; + return runTeamAction({ + pending: i18n.t("members.toast.invitingUser", { name: email }), + success: i18n.t("members.toast.invitationSentToUser", { name: email }), + error: (e: Error) => i18n.t("members.error.inviteFailed", { name: email, reason: inviteFailureReason(e) }), + run: () => inviteByEmail(teamId, email, roleName), + }); +}