Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ vi.mock("@/components/members/panels/RolesPanel", () => ({
vi.mock("@/stores/teamStore", () => {
const state = {
membersByTeam: {} as Record<string, TeamMember[]>,
teams: [] as { id: string; owner_id: string }[],
assignMemberRole: h.assign,
removeMemberRole: h.remove,
removeMember: h.removeMember,
Expand Down
12 changes: 9 additions & 3 deletions src/components/members/MembersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import { SignInToCloudCTA, UpgradeToTeamsCTA } from "@/components/members/panels
import { compareStrings } from "@/utils/localeFormat";
import { searchMatcher } from "@/utils/search";
import { memberLabel, memberSortKey, memberNamingSupported } from "@/services/memberLabel";
import { memberOwnership } from "@/services/teamOwnership";
import { useOwnershipContext } from "@/hooks/useOwnershipContext";

// ─── Main page ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -117,8 +119,12 @@ export default function MembersPage() {

const isOwnerMember = (member: TeamMember) =>
hasBuiltinRole(member, "owner", teamRoles);
const ownershipCtx = useOwnershipContext(teamId, myMember, teamRoles);
const outOfReach = (member: TeamMember) =>
memberOwnership(ownershipCtx, { userId: member.user_id, isMe: member.user_id === myUserId, isOwner: isOwnerMember(member) })
.protectedFromViewer;
const canActOn = (member: TeamMember) =>
canManageMembers && !isOwnerMember(member) && member.user_id !== myUserId;
canManageMembers && !outOfReach(member) && member.user_id !== myUserId;
const canEditMember = (member: TeamMember) => canNameMembers || canActOn(member);

const existingMemberIds = useMemo(() => new Set(members.map((m) => m.user_id)), [members]);
Expand Down Expand Up @@ -313,7 +319,7 @@ export default function MembersPage() {
const bulkContextMenuItems = useMemo((): ContextMenuItem[] | undefined => {
if (selectedIdSet.size <= 1) return undefined;
const selectedMembers = sortedMembers.filter((m) =>
selectedIdSet.has(m.user_id) && m.user_id !== myUserId && !isOwnerMember(m)
selectedIdSet.has(m.user_id) && m.user_id !== myUserId && !outOfReach(m)
);
if (selectedMembers.length === 0) return undefined;

Expand Down Expand Up @@ -400,7 +406,7 @@ export default function MembersPage() {

return items.length > 0 ? items : undefined;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIdSet, sortedMembers, myUserId, canManageMembers, teamRoles, teamId, t, businessLocked]);
}, [selectedIdSet, sortedMembers, myUserId, canManageMembers, teamRoles, teamId, t, businessLocked, ownershipCtx.multiOwner, ownershipCtx.billingOwnerId, ownershipCtx.viewerIsOwner, ownershipCtx.viewerIsPrimary]);

const vaultTabs = selectedVaultIds.length > 1
? selectedVaultIds.map((vid) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ vi.mock("@/components/shared/Panel", () => ({
}));
vi.mock("@/components/members/panels/RolesPanel", () => ({ RoleModal: () => null, TeamRolesPanel: () => null }));
vi.mock("@/stores/teamStore", () => {
const state = { setMemberName: h.setMemberName, membersByTeam: {} };
const state = { setMemberName: h.setMemberName, membersByTeam: {}, teams: [] };
const useTeamStore = Object.assign(
(sel: (s: typeof state) => unknown) => sel(state),
{ getState: () => state },
Expand Down
147 changes: 147 additions & 0 deletions src/components/members/panels/MemberDetailPanel.ownership.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { test, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import type { TeamMember, TeamRole } from "@/stores/teamStore";

const h = vi.hoisted(() => ({
assign: vi.fn(),
remove: vi.fn(),
transfer: vi.fn(),
caps: { multiOwner: true, billingEnabled: false },
myUserId: "me",
}));

vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => k }),
initReactI18next: { type: "3rdParty", init: () => {} },
}));
vi.mock("@iconify/react", () => ({ Icon: () => null }));
vi.mock("@/components/shared/Panel", () => ({
PanelShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PanelHeader: () => null,
FormSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PanelHeaderIconButton: () => null,
}));
vi.mock("@/components/members/panels/RolesPanel", () => ({ RoleModal: () => null, TeamRolesPanel: () => null }));
vi.mock("@/stores/teamStore", () => {
const state = {
membersByTeam: {},
teams: [{ id: "t", owner_id: "primary" }],
assignMemberRole: h.assign,
removeMemberRole: h.remove,
transferBillingOwner: h.transfer,
};
const useTeamStore = Object.assign(
(sel: (s: typeof state) => unknown) => sel(state),
{ getState: () => state },
);
return { useTeamStore };
});
vi.mock("@/services/serverCapabilities", () => ({
useServerMultiOwner: () => h.caps.multiOwner,
useServerBillingEnabled: () => h.caps.billingEnabled,
}));
vi.mock("@/hooks/useMyUserId", () => ({ useMyUserId: () => h.myUserId }));
vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } }));
vi.mock("@/stores/historyStore", () => ({
useHistoryStore: Object.assign(
(sel: (s: { push: () => void }) => unknown) => sel({ push: vi.fn() }),
{ getState: () => ({ push: vi.fn() }) },
),
}));
vi.mock("@/services/teamActionFeedback", () => ({
runTeamAction: async (o: { run: () => Promise<unknown> }) => o.run(),
}));
vi.mock("@/services/teamKeyRotation", () => ({ checkAndRotateTeamKey: vi.fn() }));
vi.mock("@/hooks/useBusinessLock", () => ({ useBusinessLock: () => ({ locked: false, isOwner: false }) }));
vi.mock("@/services/billingCheckout", () => ({ openBillingCheckout: vi.fn() }));

import { MemberDetailPanel } from "./MemberDetailPanel";

const role = (id: string, name: string, position: number): TeamRole =>
({ id, team_id: "t", name, permissions: 0, is_builtin: true, position, created_at: "" }) as TeamRole;
const teamRoles = [role("r-owner", "owner", 0), role("r-manager", "manager", 1)];
const person = (user_id: string, role_ids: string[]): TeamMember => ({
team_id: "t", user_id, invited_by_display_name: null, joined_at: "2024-01-01T00:00:00Z",
handle: user_id, public_key: "pk", role_ids,
});

function renderPanel(target: TeamMember, viewer: TeamMember, isMe = false) {
return render(
<MemberDetailPanel
member={target}
isMe={isMe}
teamId="t"
teamRoles={teamRoles}
canManageMembers
canNameMembers={false}
isTargetOwner={target.role_ids.includes("r-owner")}
viewer={viewer}
onClose={vi.fn()}
onUpdated={vi.fn()}
/>,
);
}

const ownerViewer = person("me", ["r-owner"]);

beforeEach(() => {
Object.assign(h.caps, { multiOwner: true, billingEnabled: false });
h.myUserId = "me";
for (const fn of [h.assign, h.remove, h.transfer]) fn.mockReset().mockResolvedValue(undefined);
});
afterEach(() => cleanup());

test("an Owner sees the Owner role and granting it asks for confirmation first", async () => {
renderPanel(person("bob", ["r-manager"]), ownerViewer);
fireEvent.click(screen.getByRole("button", { name: /members\.roleName\.owner/ }));
expect(h.assign).not.toHaveBeenCalled();
fireEvent.click(await screen.findByRole("button", { name: "members.ownership.grant.confirm" }));
await waitFor(() => expect(h.assign).toHaveBeenCalledWith("t", "bob", "r-owner"));
});

test("a manager never sees the Owner role", () => {
renderPanel(person("bob", ["r-manager"]), person("me", ["r-manager"]));
expect(screen.queryByRole("button", { name: /members\.roleName\.owner/ })).toBeNull();
});

test("an old server hides the Owner role even from an Owner", () => {
h.caps.multiOwner = false;
renderPanel(person("bob", ["r-manager"]), ownerViewer);
expect(screen.queryByRole("button", { name: /members\.roleName\.owner/ })).toBeNull();
});

test("the primary owner cannot be removed, and no transfer is offered on them", () => {
renderPanel(person("primary", ["r-owner"]), ownerViewer);
expect(screen.getByText("members.ownership.primary")).toBeTruthy();
expect(screen.queryByRole("button", { name: "members.removeFromTeam" })).toBeNull();
expect(screen.queryByRole("button", { name: "members.ownership.makePrimary" })).toBeNull();
});

test("self-hosted, an Owner can make a co-owner primary after confirming", async () => {
renderPanel(person("bob", ["r-owner"]), ownerViewer);
expect(screen.getByRole("button", { name: "members.removeFromTeam" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "members.ownership.makePrimary" }));
const buttons = await screen.findAllByRole("button", { name: "members.ownership.makePrimary" });
fireEvent.click(buttons[buttons.length - 1]);
await waitFor(() => expect(h.transfer).toHaveBeenCalledWith("t", "bob"));
});

test("on the cloud only the primary owner is offered the transfer", () => {
h.caps.billingEnabled = true;
renderPanel(person("bob", ["r-owner"]), ownerViewer);
expect(screen.queryByRole("button", { name: "members.ownership.makePrimary" })).toBeNull();
cleanup();
h.myUserId = "primary";
renderPanel(person("bob", ["r-owner"]), person("primary", ["r-owner"]));
expect(screen.getByRole("button", { name: "members.ownership.makePrimary" })).toBeTruthy();
});

test("a co-owner can leave; the primary owner is told to hand over first", () => {
renderPanel(person("me", ["r-owner"]), ownerViewer, true);
expect(screen.getByRole("button", { name: "members.leaveTeam" })).toBeTruthy();
cleanup();
h.myUserId = "primary";
renderPanel(person("primary", ["r-owner"]), person("primary", ["r-owner"]), true);
expect(screen.queryByRole("button", { name: "members.leaveTeam" })).toBeNull();
expect(screen.getByText("members.ownership.leaveBlocked")).toBeTruthy();
});
94 changes: 87 additions & 7 deletions src/components/members/panels/MemberDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { formatDate } from "@/utils/localeFormat";
import { searchMatcher } from "@/utils/search";
import { MemberNameInput } from "@/components/members/MemberNameInput";
import { inviterLabel, memberLabel, secondaryHandle } from "@/services/memberLabel";
import { memberOwnership } from "@/services/teamOwnership";
import { useOwnershipContext } from "@/hooks/useOwnershipContext";

export interface MemberDetailPanelProps {
member: TeamMember;
Expand Down Expand Up @@ -92,12 +94,15 @@ export function MemberDetailPanel({
// Stores the intent, not the computed masks — commitOverride recomputes them
// from the render current at confirm time, in case member state changed meanwhile.
const [pendingRevoke, setPendingRevoke] = useState<{ permission: Permission; next: OverrideState } | "clear" | null>(null);
const [pendingOwnerRole, setPendingOwnerRole] = useState<TeamRole | null>(null);
const [confirmingPrimary, setConfirmingPrimary] = useState(false);

const ownershipCtx = useOwnershipContext(teamId, viewer, teamRoles);
const ownership = memberOwnership(ownershipCtx, { userId: member.user_id, isMe, isOwner: isTargetOwner });
const canChangeRoles = canManageMembers && !isMe;
const canRemove = canManageMembers && !isTargetOwner && !isMe;
// The server rejects an owner removing themselves (teams.rs `is_owner`), so
// offering Leave to an owner would promise something that 403s.
const canLeave = isMe && !isTargetOwner;
const canRemove = canManageMembers && !ownership.protectedFromViewer && !isMe;
const canLeave = ownership.canLeave;
const isOwnerRole = (role: TeamRole) => role.is_builtin && role.name === "owner";

const runReversible = async (opts: {
pending: string;
Expand All @@ -115,12 +120,16 @@ export function MemberDetailPanel({
});
};

const handleToggleRole = async (role: TeamRole) => {
const handleToggleRole = async (role: TeamRole, confirmed = false) => {
const hasRole = member.role_ids.includes(role.id);
if (hasRole && isTargetOwner && role.is_builtin && role.name === "owner") {
if (isOwnerRole(role) && hasRole && ownership.isPrimaryOwner) {
setError(t("members.error.cannotRemoveOwnerRole"));
return;
}
if (isOwnerRole(role) && !confirmed) {
setPendingOwnerRole(role);
return;
}
const store = useTeamStore.getState();
const assign = () => store.assignMemberRole(teamId, member.user_id, role.id);
const remove = () => store.removeMemberRole(teamId, member.user_id, role.id);
Expand Down Expand Up @@ -308,7 +317,7 @@ export function MemberDetailPanel({
{canChangeRoles ? (
<div className="flex flex-wrap items-start gap-2">
{[...teamRoles]
.filter((r) => !(r.is_builtin && r.name === "owner"))
.filter((r) => !isOwnerRole(r) || ownership.canGrantOwner)
.sort((a, b) => a.position - b.position).map((role) => {

const hasRole = member.role_ids.includes(role.id);
Expand Down Expand Up @@ -431,6 +440,30 @@ export function MemberDetailPanel({
</FormSection>
)}

{ownershipCtx.multiOwner && ownership.isOwner && (ownership.isPrimaryOwner || ownership.canMakePrimary) && (
<FormSection label={t("members.ownership.title")}>
{ownership.isPrimaryOwner ? (
<div className="text-xs">
<p className="flex items-center gap-1.5 font-medium text-(--t-text-primary)">
<Icon icon="lucide:crown" width={12} />
{t("members.ownership.primary")}
</p>
<p className="text-[11px] mt-1 text-(--t-text-secondary)">
{t(ownershipCtx.billingEnabled ? "members.ownership.primaryHintBilling" : "members.ownership.primaryHintSelfHosted")}
</p>
</div>
) : (
<button
onClick={() => setConfirmingPrimary(true)}
className="btn btn-secondary w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-xs font-medium"
>
<Icon icon="lucide:crown" width={13} />
{t("members.ownership.makePrimary")}
</button>
)}
</FormSection>
)}

{/* Info */}
<FormSection label={t("members.info")}>
<div className="space-y-2 text-xs">
Expand All @@ -447,6 +480,10 @@ export function MemberDetailPanel({
</div>
</FormSection>

{isMe && ownershipCtx.multiOwner && ownership.isPrimaryOwner && (
<p className="text-[11px] px-1 text-(--t-text-secondary)">{t("members.ownership.leaveBlocked")}</p>
)}

{/* Danger zone */}
{(canRemove || canLeave) && (
<FormSection label={t("members.dangerZone")}>
Expand Down Expand Up @@ -481,6 +518,49 @@ export function MemberDetailPanel({
/>
)}

{pendingOwnerRole && (() => {
const granting = !member.role_ids.includes(pendingOwnerRole.id);
const kind = granting ? "grant" : "revoke";
return (
<ConfirmModal
tone="warning"
title={t(`members.ownership.${kind}.title`, { name: memberLabel(member) })}
message={t(`members.ownership.${kind}.body`, { name: memberLabel(member) })}
confirmLabel={t(`members.ownership.${kind}.confirm`)}
onCancel={() => setPendingOwnerRole(null)}
onConfirm={() => {
const role = pendingOwnerRole;
setPendingOwnerRole(null);
void handleToggleRole(role, true);
}}
/>
);
})()}

{confirmingPrimary && (
<ConfirmModal
tone="warning"
title={t("members.ownership.confirmPrimary.title", { name: memberLabel(member) })}
message={t(
ownershipCtx.billingEnabled ? "members.ownership.confirmPrimary.bodyBilling" : "members.ownership.confirmPrimary.bodySelfHosted",
{ name: memberLabel(member) },
)}
confirmLabel={t("members.ownership.makePrimary")}
onCancel={() => setConfirmingPrimary(false)}
onConfirm={() => {
setConfirmingPrimary(false);
setError("");
void runTeamAction({
pending: t("members.toast.makingPrimaryOwner", { name: memberLabel(member) }),
success: t("members.toast.primaryOwnerChanged", { name: memberLabel(member) }),
run: () => useTeamStore.getState().transferBillingOwner(teamId, member.user_id),
})
.then(onUpdated)
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)));
}}
/>
)}

{pendingRevoke && (
<ConfirmModal
tone="warning"
Expand Down
4 changes: 2 additions & 2 deletions src/components/team/TeamVaultStatePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useUIStore } from "@/stores/uiStore";
import { fetchTeamData } from "@/services/teamVaultSync";
import { ownerLabel } from "@/services/teamVaultFirstAccess";
import { checkForUpdate } from "@/services/updater";
import { isTeamOwner } from "@/services/permissions";
import { isPrimaryOwner } from "@/services/permissions";
import { useMyUserId } from "@/hooks/useMyUserId";

export default function TeamVaultStatePanel({
Expand All @@ -27,7 +27,7 @@ export default function TeamVaultStatePanel({
const members = useTeamStore((s) => s.membersByTeam[teamId]);
const loadMembers = useTeamStore((s) => s.loadMembers);
const myUserId = useMyUserId();
const isOwner = isTeamOwner(team, myUserId);
const isOwner = isPrimaryOwner(team, myUserId);

// The waiting copy names the owner the user is waiting on, so the roster has
// to be there — this panel replaces the pages that would otherwise load it.
Expand Down
Loading
Loading