+
{/* 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 && (
+