diff --git a/src/components/members/MembersPage.MemberDetailPanel.test.tsx b/src/components/members/MembersPage.MemberDetailPanel.test.tsx index 16ffe278e..00adffd34 100644 --- a/src/components/members/MembersPage.MemberDetailPanel.test.tsx +++ b/src/components/members/MembersPage.MemberDetailPanel.test.tsx @@ -33,6 +33,7 @@ vi.mock("@/components/members/panels/RolesPanel", () => ({ vi.mock("@/stores/teamStore", () => { const state = { membersByTeam: {} as Record, + teams: [] as { id: string; owner_id: string }[], assignMemberRole: h.assign, removeMemberRole: h.remove, removeMember: h.removeMember, diff --git a/src/components/members/MembersPage.tsx b/src/components/members/MembersPage.tsx index 251cad755..ba9f04761 100644 --- a/src/components/members/MembersPage.tsx +++ b/src/components/members/MembersPage.tsx @@ -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 ──────────────────────────────────────────────────────────────── @@ -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]); @@ -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; @@ -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) => { diff --git a/src/components/members/panels/MemberDetailPanel.memberName.test.tsx b/src/components/members/panels/MemberDetailPanel.memberName.test.tsx index 8b9d01e64..cf9ac4111 100644 --- a/src/components/members/panels/MemberDetailPanel.memberName.test.tsx +++ b/src/components/members/panels/MemberDetailPanel.memberName.test.tsx @@ -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 }, diff --git a/src/components/members/panels/MemberDetailPanel.ownership.test.tsx b/src/components/members/panels/MemberDetailPanel.ownership.test.tsx new file mode 100644 index 000000000..6398b5146 --- /dev/null +++ b/src/components/members/panels/MemberDetailPanel.ownership.test.tsx @@ -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 }) =>
{children}
, + PanelHeader: () => null, + FormSection: ({ children }: { children: React.ReactNode }) =>
{children}
, + 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 }) => 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( + , + ); +} + +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(); +}); diff --git a/src/components/members/panels/MemberDetailPanel.tsx b/src/components/members/panels/MemberDetailPanel.tsx index 0dfb54c22..f3579bfd9 100644 --- a/src/components/members/panels/MemberDetailPanel.tsx +++ b/src/components/members/panels/MemberDetailPanel.tsx @@ -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; @@ -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(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; @@ -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); @@ -308,7 +317,7 @@ export function MemberDetailPanel({ {canChangeRoles ? (
{[...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); @@ -431,6 +440,30 @@ export function MemberDetailPanel({ )} + {ownershipCtx.multiOwner && ownership.isOwner && (ownership.isPrimaryOwner || ownership.canMakePrimary) && ( + + {ownership.isPrimaryOwner ? ( +
+

+ + {t("members.ownership.primary")} +

+

+ {t(ownershipCtx.billingEnabled ? "members.ownership.primaryHintBilling" : "members.ownership.primaryHintSelfHosted")} +

+
+ ) : ( + + )} +
+ )} + {/* Info */}
@@ -447,6 +480,10 @@ export function MemberDetailPanel({
+ {isMe && ownershipCtx.multiOwner && ownership.isPrimaryOwner && ( +

{t("members.ownership.leaveBlocked")}

+ )} + {/* Danger zone */} {(canRemove || canLeave) && ( @@ -481,6 +518,49 @@ export function MemberDetailPanel({ /> )} + {pendingOwnerRole && (() => { + const granting = !member.role_ids.includes(pendingOwnerRole.id); + const kind = granting ? "grant" : "revoke"; + return ( + setPendingOwnerRole(null)} + onConfirm={() => { + const role = pendingOwnerRole; + setPendingOwnerRole(null); + void handleToggleRole(role, true); + }} + /> + ); + })()} + + {confirmingPrimary && ( + 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 && ( 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. diff --git a/src/components/vault-admin/vaultAdminTarget.ts b/src/components/vault-admin/vaultAdminTarget.ts index e80406dda..a89723828 100644 --- a/src/components/vault-admin/vaultAdminTarget.ts +++ b/src/components/vault-admin/vaultAdminTarget.ts @@ -1,4 +1,4 @@ -import { VAULT_MANAGER_BITS, isTeamOwner } from "@/services/permissions"; +import { VAULT_MANAGER_BITS, isPrimaryOwner } from "@/services/permissions"; /** What a vault-admin surface acts on. Mirrors `VaultDetail` in VaultsSection. */ export interface VaultAdminTarget { @@ -35,7 +35,7 @@ export function vaultAdminCapabilities( const team = target.teamId ? teams.find((t) => t.id === target.teamId) : undefined; const roles = target.teamId ? rolesByTeam[target.teamId] ?? [] : []; const myRoles = (team?.role_ids ?? []).flatMap((rid) => roles.filter((role) => role.id === rid)); - const isOwner = isTeamOwner(team, myUserId); + const isOwner = isPrimaryOwner(team, myUserId); const managesTeam = isOwner || myRoles.some((r) => ((r.permissions ?? 0) & VAULT_MANAGER_BITS) !== 0); return { @@ -43,10 +43,10 @@ export function vaultAdminCapabilities( isOwner, canRename: isTeam ? managesTeam : isLocal, canSetLockPolicy: isTeam && managesTeam, - // The server lets only the owner delete a team, and that deletes it for every member. + // The server lets only the primary owner delete a team, and that deletes it for every member. canDelete: isTeam ? isOwner : isLocal && target.vaultId !== "personal", canMakePrivate: isTeam && isOwner && isLocal && target.vaultId !== null, - // The server refuses an owner leaving their own team. + // The server refuses the primary owner leaving their own team. canLeave: isTeam && !!myUserId && !isOwner, }; } diff --git a/src/hooks/useBusinessLock.ts b/src/hooks/useBusinessLock.ts index b49124e81..2ab4b2ca9 100644 --- a/src/hooks/useBusinessLock.ts +++ b/src/hooks/useBusinessLock.ts @@ -1,6 +1,6 @@ import { useTeamStore } from "@/stores/teamStore"; import { isBusinessLocked } from "@/stores/subscriptionTier"; -import { isTeamOwner } from "@/services/permissions"; +import { isPrimaryOwner } from "@/services/permissions"; import { useMyUserId } from "@/hooks/useMyUserId"; export function useBusinessLock(teamId: string | null | undefined): { locked: boolean; isOwner: boolean | null } { @@ -8,6 +8,6 @@ export function useBusinessLock(teamId: string | null | undefined): { locked: bo const myUserId = useMyUserId(); return { locked: isBusinessLocked(team), - isOwner: myUserId ? isTeamOwner(team, myUserId) : null, + isOwner: myUserId ? isPrimaryOwner(team, myUserId) : null, }; } diff --git a/src/hooks/useOwnershipContext.ts b/src/hooks/useOwnershipContext.ts new file mode 100644 index 000000000..6d90ebe91 --- /dev/null +++ b/src/hooks/useOwnershipContext.ts @@ -0,0 +1,19 @@ +import { useTeamStore, type TeamMember, type TeamRole } from "@/stores/teamStore"; +import { hasBuiltinRole, isPrimaryOwner } from "@/services/permissions"; +import { useServerBillingEnabled, useServerMultiOwner } from "@/services/serverCapabilities"; +import { useMyUserId } from "@/hooks/useMyUserId"; +import type { OwnershipContext } from "@/services/teamOwnership"; + +export function useOwnershipContext(teamId: string | null, viewer: TeamMember | undefined, roles: TeamRole[]): OwnershipContext { + const multiOwner = useServerMultiOwner(); + const billingEnabled = useServerBillingEnabled(); + const team = useTeamStore((s) => s.teams.find((t) => t.id === teamId)); + const myUserId = useMyUserId(); + return { + multiOwner, + billingEnabled, + billingOwnerId: team?.owner_id, + viewerIsOwner: !!viewer && hasBuiltinRole(viewer, "owner", roles), + viewerIsPrimary: isPrimaryOwner(team, myUserId), + }; +} diff --git a/src/i18n/locales/cs/common.json b/src/i18n/locales/cs/common.json index 3340418b2..6e4348245 100644 --- a/src/i18n/locales/cs/common.json +++ b/src/i18n/locales/cs/common.json @@ -135,6 +135,10 @@ "failedToAddMember": "Člena se nepodařilo přidat: {{status}}", "failedToRemoveMember": "Člena se nepodařilo odebrat: {{status}}", "failedToAssignRole": "Roli se nepodařilo přiřadit: {{status}}", + "billingTransferNeedsPlan": "Potřebuje vlastní předplatné Teams nebo Business alespoň na úrovni tohoto týmu, s místem pro každého člena svých týmů i tohoto", + "billingTransferNeedsOwner": "Nejprve ho nastavte jako vlastníka", + "billingTransferForbidden": "Předat to může jen hlavní vlastník", + "failedToTransferBilling": "Hlavního vlastníka se nepodařilo změnit: {{status}}", "failedToRemoveRole": "Roli se nepodařilo odebrat: {{status}}", "insufficientPermissionSetMemberPermissions": "Nemáte oprávnění měnit oprávnění tohoto člena", "insufficientPermissionNameMembers": "Nemáte oprávnění pojmenovávat členy", diff --git a/src/i18n/locales/cs/members.json b/src/i18n/locales/cs/members.json index 181aa921d..00d890b72 100644 --- a/src/i18n/locales/cs/members.json +++ b/src/i18n/locales/cs/members.json @@ -5,6 +5,29 @@ "memberSince": "Členem od", "invitedBy": "Pozval(a)", "dangerZone": "Nebezpečná zóna", + "ownership": { + "title": "Vlastnictví", + "primary": "Hlavní vlastník", + "primaryHintBilling": "Platí za tento tým a jako jediný ho může smazat.", + "primaryHintSelfHosted": "Jediný, kdo může tento tým smazat.", + "makePrimary": "Nastavit jako hlavního vlastníka", + "leaveBlocked": "Než tým opustíte, nastavte hlavním vlastníkem jiného vlastníka.", + "confirmPrimary": { + "title": "Nastavit {{name}} jako hlavního vlastníka?", + "bodyBilling": "Za tento tým bude od teď platit vlastní předplatné Teams nebo Business uživatele {{name}} a jen on ho bude moci smazat. Vaše předplatné zůstává beze změny: snižte počet míst nebo ho zrušte sami.", + "bodySelfHosted": "Tento tým bude moci smazat jen {{name}}. Zůstáváte vlastníkem a poté můžete odejít." + }, + "grant": { + "title": "Nastavit {{name}} jako vlastníka?", + "body": "Vlastníci mají všechna oprávnění včetně přidávání a odebírání dalších vlastníků.", + "confirm": "Nastavit jako vlastníka" + }, + "revoke": { + "title": "Odebrat roli vlastníka uživateli {{name}}?", + "body": "{{name}} si ponechá ostatní role.", + "confirm": "Odebrat roli" + } + }, "confirmRemoval": "Klikněte znovu pro potvrzení odebrání", "removeFromTeam": "Odebrat z týmu", "newRole": "Nová role", @@ -147,6 +170,8 @@ "connect-only": "Lidé s rolí Jen připojení mohou navazovat připojení, ale nikdy neuvidí použité přihlašovací údaje." }, "toast": { + "makingPrimaryOwner": "{{name}} se stává hlavním vlastníkem…", + "primaryOwnerChanged": "{{name}} je nyní hlavní vlastník", "removingRoleFrom": "Odebírání role {{role}} uživateli {{name}}...", "roleRemovedFrom": "Role {{role}} odebrána uživateli {{name}}", "assigningRoleTo": "Přiřazování role {{role}} uživateli {{name}}...", @@ -170,7 +195,7 @@ "leftTeam": "Opustili jste tým" }, "error": { - "cannotRemoveOwnerRole": "Vlastníkovi týmu nelze odebrat roli vlastníka", + "cannotRemoveOwnerRole": "Nejprve nastavte hlavním vlastníkem jiného vlastníka", "failedToUpdateRole": "Roli se nepodařilo upravit", "failedToUpdatePermissions": "Oprávnění se nepodařilo upravit", "failedToRemoveMember": "Člena se nepodařilo odebrat", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 2a2c045ea..4a856a8a4 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -135,6 +135,10 @@ "failedToAddMember": "Failed to add member: {{status}}", "failedToRemoveMember": "Failed to remove member: {{status}}", "failedToAssignRole": "Failed to assign role: {{status}}", + "billingTransferNeedsPlan": "They need their own Teams or Business plan, at least as high as this team's, with a seat for everyone in their teams and this one", + "billingTransferNeedsOwner": "Make them an Owner first", + "billingTransferForbidden": "Only the primary owner can hand this over", + "failedToTransferBilling": "Failed to change the primary owner: {{status}}", "failedToRemoveRole": "Failed to remove role: {{status}}", "insufficientPermissionSetMemberPermissions": "You do not have permission to change this member's permissions", "insufficientPermissionNameMembers": "You don't have permission to name members", diff --git a/src/i18n/locales/en/members.json b/src/i18n/locales/en/members.json index df5eac7b2..7a2fe522f 100644 --- a/src/i18n/locales/en/members.json +++ b/src/i18n/locales/en/members.json @@ -5,6 +5,29 @@ "memberSince": "Member since", "invitedBy": "Invited by", "dangerZone": "Danger Zone", + "ownership": { + "title": "Ownership", + "primary": "Primary owner", + "primaryHintBilling": "Pays for this team and is the only one who can delete it.", + "primaryHintSelfHosted": "The only one who can delete this team.", + "makePrimary": "Make primary owner", + "leaveBlocked": "Make another Owner the primary owner before you leave this team.", + "confirmPrimary": { + "title": "Make {{name}} the primary owner?", + "bodyBilling": "{{name}}'s own Teams or Business subscription pays for this team from now on, and only they can delete it. Your subscription stays as it is: lower your seats or cancel it yourself.", + "bodySelfHosted": "Only {{name}} will be able to delete this team. You stay an Owner and can leave afterwards." + }, + "grant": { + "title": "Make {{name}} an Owner?", + "body": "Owners hold every permission, including adding and removing other Owners.", + "confirm": "Make Owner" + }, + "revoke": { + "title": "Remove Owner from {{name}}?", + "body": "{{name}} keeps any other roles they hold.", + "confirm": "Remove Owner" + } + }, "confirmRemoval": "Click again to confirm removal", "removeFromTeam": "Remove from team", "newRole": "New role", @@ -141,6 +164,8 @@ "connect-only": "Connect-only people can open connections but never see the credentials behind them." }, "toast": { + "makingPrimaryOwner": "Making {{name}} the primary owner…", + "primaryOwnerChanged": "{{name}} is now the primary owner", "removingRoleFrom": "Removing {{role}} from {{name}}...", "roleRemovedFrom": "{{role}} removed from {{name}}", "assigningRoleTo": "Assigning {{role}} to {{name}}...", @@ -164,7 +189,7 @@ "leftTeam": "You left the team" }, "error": { - "cannotRemoveOwnerRole": "Cannot remove the owner role from the team owner", + "cannotRemoveOwnerRole": "Make another Owner the primary owner first", "failedToUpdateRole": "Failed to update role", "failedToUpdatePermissions": "Failed to update permissions", "failedToRemoveMember": "Failed to remove member", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 47feb7ba6..202c64bde 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -135,6 +135,10 @@ "failedToAddMember": "Échec de l'ajout du membre : {{status}}", "failedToRemoveMember": "Échec de la suppression du membre : {{status}}", "failedToAssignRole": "Échec de l'attribution du rôle : {{status}}", + "billingTransferNeedsPlan": "Il lui faut son propre abonnement Teams ou Business, au moins équivalent à celui de cette équipe, avec un siège pour chaque membre de ses équipes et de celle-ci", + "billingTransferNeedsOwner": "Nommez-le d'abord propriétaire", + "billingTransferForbidden": "Seul le propriétaire principal peut transférer ce rôle", + "failedToTransferBilling": "Échec du changement de propriétaire principal : {{status}}", "failedToRemoveRole": "Échec du retrait du rôle : {{status}}", "insufficientPermissionSetMemberPermissions": "Vous n'avez pas la permission de modifier les permissions de ce membre", "insufficientPermissionNameMembers": "Vous n'avez pas l'autorisation de nommer les membres", diff --git a/src/i18n/locales/fr/members.json b/src/i18n/locales/fr/members.json index 188fe2f1c..79466381c 100644 --- a/src/i18n/locales/fr/members.json +++ b/src/i18n/locales/fr/members.json @@ -5,6 +5,29 @@ "memberSince": "Membre depuis", "invitedBy": "Invité par", "dangerZone": "Zone de danger", + "ownership": { + "title": "Propriété", + "primary": "Propriétaire principal", + "primaryHintBilling": "Paie pour cette équipe et est le seul à pouvoir la supprimer.", + "primaryHintSelfHosted": "Le seul à pouvoir supprimer cette équipe.", + "makePrimary": "Nommer propriétaire principal", + "leaveBlocked": "Nommez un autre propriétaire comme propriétaire principal avant de quitter cette équipe.", + "confirmPrimary": { + "title": "Nommer {{name}} propriétaire principal ?", + "bodyBilling": "L'abonnement Teams ou Business de {{name}} paiera désormais pour cette équipe, et seul {{name}} pourra la supprimer. Votre abonnement ne change pas : réduisez vos sièges ou résiliez-le vous-même.", + "bodySelfHosted": "Seul {{name}} pourra supprimer cette équipe. Vous restez propriétaire et pourrez partir ensuite." + }, + "grant": { + "title": "Nommer {{name}} propriétaire ?", + "body": "Les propriétaires ont toutes les permissions, y compris ajouter et retirer d'autres propriétaires.", + "confirm": "Nommer propriétaire" + }, + "revoke": { + "title": "Retirer le rôle de propriétaire à {{name}} ?", + "body": "{{name}} conserve ses autres rôles.", + "confirm": "Retirer le rôle" + } + }, "confirmRemoval": "Cliquer à nouveau pour confirmer le retrait", "removeFromTeam": "Retirer de l'équipe", "newRole": "Nouveau rôle", @@ -142,6 +165,8 @@ "connect-only": "Les personnes en connexion seule peuvent ouvrir des connexions mais ne voient jamais les identifiants derrière." }, "toast": { + "makingPrimaryOwner": "{{name}} devient propriétaire principal…", + "primaryOwnerChanged": "{{name}} est maintenant propriétaire principal", "removingRoleFrom": "Suppression du rôle {{role}} de {{name}}…", "roleRemovedFrom": "Rôle {{role}} supprimé de {{name}}", "assigningRoleTo": "Attribution du rôle {{role}} à {{name}}…", @@ -165,7 +190,7 @@ "leftTeam": "Vous avez quitté l'équipe" }, "error": { - "cannotRemoveOwnerRole": "Impossible de retirer le rôle de propriétaire au propriétaire de l'équipe", + "cannotRemoveOwnerRole": "Nommez d'abord un autre propriétaire comme propriétaire principal", "failedToUpdateRole": "Échec de la mise à jour du rôle", "failedToUpdatePermissions": "Échec de la mise à jour des permissions", "failedToRemoveMember": "Échec de la suppression du membre", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 6e28dc350..b34aa4972 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -135,6 +135,10 @@ "failedToAddMember": "Не удалось добавить участника: {{status}}", "failedToRemoveMember": "Не удалось удалить участника: {{status}}", "failedToAssignRole": "Не удалось назначить роль: {{status}}", + "billingTransferNeedsPlan": "Нужна собственная подписка Teams или Business не ниже тарифа этой команды, с местом для каждого участника его команд и этой", + "billingTransferNeedsOwner": "Сначала сделайте его владельцем", + "billingTransferForbidden": "Передать это может только основной владелец", + "failedToTransferBilling": "Не удалось сменить основного владельца: {{status}}", "failedToRemoveRole": "Не удалось удалить роль: {{status}}", "insufficientPermissionSetMemberPermissions": "У вас нет прав на изменение разрешений этого участника", "insufficientPermissionNameMembers": "У вас нет прав на изменение имён участников", diff --git a/src/i18n/locales/ru/members.json b/src/i18n/locales/ru/members.json index 1ef9a8d36..4b981af2d 100644 --- a/src/i18n/locales/ru/members.json +++ b/src/i18n/locales/ru/members.json @@ -5,6 +5,29 @@ "memberSince": "Участник с", "invitedBy": "Кем приглашён", "dangerZone": "Опасная зона", + "ownership": { + "title": "Владение", + "primary": "Основной владелец", + "primaryHintBilling": "Оплачивает эту команду и единственный может её удалить.", + "primaryHintSelfHosted": "Единственный, кто может удалить эту команду.", + "makePrimary": "Сделать основным владельцем", + "leaveBlocked": "Прежде чем покинуть команду, сделайте основным владельцем другого владельца.", + "confirmPrimary": { + "title": "Сделать {{name}} основным владельцем?", + "bodyBilling": "С этого момента команду оплачивает собственная подписка Teams или Business пользователя {{name}}, и только он сможет удалить команду. Ваша подписка не меняется: уменьшите число мест или отмените её сами.", + "bodySelfHosted": "Только {{name}} сможет удалить эту команду. Вы остаётесь владельцем и сможете затем выйти." + }, + "grant": { + "title": "Сделать {{name}} владельцем?", + "body": "У владельцев есть все права, включая добавление и удаление других владельцев.", + "confirm": "Сделать владельцем" + }, + "revoke": { + "title": "Снять роль владельца с {{name}}?", + "body": "Остальные роли {{name}} сохранятся.", + "confirm": "Снять роль" + } + }, "confirmRemoval": "Нажмите ещё раз для подтверждения удаления", "removeFromTeam": "Удалить из команды", "newRole": "Новая роль", @@ -143,6 +166,8 @@ "connect-only": "Люди с доступом только для подключения могут открывать соединения, но никогда не видят учётные данные за ними." }, "toast": { + "makingPrimaryOwner": "{{name}} становится основным владельцем…", + "primaryOwnerChanged": "{{name}} теперь основной владелец", "removingRoleFrom": "Удаление роли {{role}} у {{name}}...", "roleRemovedFrom": "Роль {{role}} удалена у {{name}}", "assigningRoleTo": "Назначение роли {{role}} пользователю {{name}}...", @@ -166,7 +191,7 @@ "leftTeam": "Вы вышли из команды" }, "error": { - "cannotRemoveOwnerRole": "Нельзя удалить роль владельца у владельца команды", + "cannotRemoveOwnerRole": "Сначала сделайте основным владельцем другого владельца", "failedToUpdateRole": "Не удалось обновить роль", "failedToUpdatePermissions": "Не удалось обновить разрешения", "failedToRemoveMember": "Не удалось удалить участника", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 8165f5e63..9e9cf48f0 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -135,6 +135,10 @@ "failedToAddMember": "Üye eklenemedi: {{status}}", "failedToRemoveMember": "Üye kaldırılamadı: {{status}}", "failedToAssignRole": "Rol atanamadı: {{status}}", + "billingTransferNeedsPlan": "Bu ekibin planından düşük olmayan, kendi ekipleri ve bu ekipteki herkese yetecek koltuklu kendi Teams veya Business planı gerekir", + "billingTransferNeedsOwner": "Önce onu sahip yapın", + "billingTransferForbidden": "Bunu yalnızca birincil sahip devredebilir", + "failedToTransferBilling": "Birincil sahip değiştirilemedi: {{status}}", "failedToRemoveRole": "Rol kaldırılamadı: {{status}}", "insufficientPermissionSetMemberPermissions": "Bu üyenin izinlerini değiştirme yetkiniz yok", "insufficientPermissionNameMembers": "Üyeleri adlandırma izniniz yok", diff --git a/src/i18n/locales/tr/members.json b/src/i18n/locales/tr/members.json index a6a4056be..2fe069e8a 100644 --- a/src/i18n/locales/tr/members.json +++ b/src/i18n/locales/tr/members.json @@ -5,6 +5,29 @@ "memberSince": "Üyelik başlangıcı", "invitedBy": "Davet eden", "dangerZone": "Tehlikeli bölge", + "ownership": { + "title": "Sahiplik", + "primary": "Birincil sahip", + "primaryHintBilling": "Bu ekibin ödemesini yapar ve ekibi silebilen tek kişidir.", + "primaryHintSelfHosted": "Bu ekibi silebilen tek kişi.", + "makePrimary": "Birincil sahip yap", + "leaveBlocked": "Bu ekipten ayrılmadan önce başka bir sahibi birincil sahip yapın.", + "confirmPrimary": { + "title": "{{name}} birincil sahip yapılsın mı?", + "bodyBilling": "Bundan sonra bu ekibin ödemesini {{name}} kişisinin kendi Teams veya Business aboneliği yapar ve ekibi yalnızca o silebilir. Aboneliğiniz değişmez: koltuklarınızı azaltın veya kendiniz iptal edin.", + "bodySelfHosted": "Bu ekibi yalnızca {{name}} silebilecek. Sahip olarak kalırsınız ve sonra ayrılabilirsiniz." + }, + "grant": { + "title": "{{name}} sahip yapılsın mı?", + "body": "Sahipler, başka sahip ekleme ve kaldırma dahil tüm izinlere sahiptir.", + "confirm": "Sahip yap" + }, + "revoke": { + "title": "{{name}} kişisinden sahip rolü kaldırılsın mı?", + "body": "{{name}} diğer rollerini korur.", + "confirm": "Rolü kaldır" + } + }, "confirmRemoval": "Kaldırmayı onaylamak için tekrar tıklayın", "removeFromTeam": "Ekipten kaldır", "newRole": "Yeni rol", @@ -145,6 +168,8 @@ "connect-only": "Yalnızca bağlanabilen kişiler bağlantıları açabilir ancak arkasındaki kimlik bilgilerini hiçbir zaman göremez." }, "toast": { + "makingPrimaryOwner": "{{name}} birincil sahip yapılıyor…", + "primaryOwnerChanged": "{{name}} artık birincil sahip", "removingRoleFrom": "{{role}} rolü kaldırılıyor: {{name}}...", "roleRemovedFrom": "{{role}} rolü kaldırıldı: {{name}}", "assigningRoleTo": "{{role}} rolü atanıyor: {{name}}...", @@ -168,7 +193,7 @@ "leftTeam": "Ekipten ayrıldınız" }, "error": { - "cannotRemoveOwnerRole": "Sahip rolü ekip sahibinden kaldırılamaz", + "cannotRemoveOwnerRole": "Önce başka bir sahibi birincil sahip yapın", "failedToUpdateRole": "Rol güncellenemedi", "failedToUpdatePermissions": "İzinler güncellenemedi", "failedToRemoveMember": "Üye kaldırılamadı", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 835345c69..456c675fa 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -135,6 +135,10 @@ "failedToAddMember": "添加成员失败:{{status}}", "failedToRemoveMember": "移除成员失败:{{status}}", "failedToAssignRole": "分配角色失败:{{status}}", + "billingTransferNeedsPlan": "对方需要自己的 Teams 或 Business 订阅,等级不低于此团队,并为其所有团队及此团队的每位成员提供席位", + "billingTransferNeedsOwner": "请先将其设为所有者", + "billingTransferForbidden": "只有主要所有者可以转交", + "failedToTransferBilling": "更改主要所有者失败:{{status}}", "failedToRemoveRole": "移除角色失败:{{status}}", "insufficientPermissionSetMemberPermissions": "您没有权限修改该成员的权限", "insufficientPermissionNameMembers": "你没有为成员命名的权限", diff --git a/src/i18n/locales/zh/members.json b/src/i18n/locales/zh/members.json index 381cfabe7..b8eccf980 100644 --- a/src/i18n/locales/zh/members.json +++ b/src/i18n/locales/zh/members.json @@ -5,6 +5,29 @@ "memberSince": "加入时间", "invitedBy": "邀请人", "dangerZone": "危险区域", + "ownership": { + "title": "所有权", + "primary": "主要所有者", + "primaryHintBilling": "为此团队付费,且是唯一可以删除它的人。", + "primaryHintSelfHosted": "唯一可以删除此团队的人。", + "makePrimary": "设为主要所有者", + "leaveBlocked": "离开此团队前,请先将另一位所有者设为主要所有者。", + "confirmPrimary": { + "title": "将 {{name}} 设为主要所有者?", + "bodyBilling": "从现在起,此团队由 {{name}} 自己的 Teams 或 Business 订阅付费,且只有其可以删除团队。你的订阅保持不变:请自行减少席位或取消订阅。", + "bodySelfHosted": "只有 {{name}} 可以删除此团队。你仍是所有者,之后可以离开。" + }, + "grant": { + "title": "将 {{name}} 设为所有者?", + "body": "所有者拥有全部权限,包括添加和移除其他所有者。", + "confirm": "设为所有者" + }, + "revoke": { + "title": "移除 {{name}} 的所有者角色?", + "body": "{{name}} 保留其其他角色。", + "confirm": "移除角色" + } + }, "confirmRemoval": "再次点击确认移除", "removeFromTeam": "从团队中移除", "newRole": "新建角色", @@ -141,6 +164,8 @@ "connect-only": "仅连接权限的人可以打开连接,但永远无法看到其背后的凭据。" }, "toast": { + "makingPrimaryOwner": "正在将 {{name}} 设为主要所有者…", + "primaryOwnerChanged": "{{name}} 现在是主要所有者", "removingRoleFrom": "正在从 {{name}} 移除 {{role}}…", "roleRemovedFrom": "已从 {{name}} 移除 {{role}}", "assigningRoleTo": "正在为 {{name}} 分配 {{role}}…", @@ -164,7 +189,7 @@ "leftTeam": "你已退出团队" }, "error": { - "cannotRemoveOwnerRole": "无法从团队所有者移除所有者角色", + "cannotRemoveOwnerRole": "请先将另一位所有者设为主要所有者", "failedToUpdateRole": "更新角色失败", "failedToUpdatePermissions": "更新权限失败", "failedToRemoveMember": "移除成员失败", diff --git a/src/services/permissions.test.ts b/src/services/permissions.test.ts index a205c0cf2..4ea99ac39 100644 --- a/src/services/permissions.test.ts +++ b/src/services/permissions.test.ts @@ -1,6 +1,6 @@ import { test, expect, describe, it } from "vitest"; import { - resolveCan, PERM_BITS, effectivePermissions, isTeamOwner, crossesVaultKeyGate, resolveMemberReadOnlyReason, + resolveCan, PERM_BITS, effectivePermissions, isPrimaryOwner, crossesVaultKeyGate, resolveMemberReadOnlyReason, PERMISSION_GROUPS, resolveObjectPermissions, ALL_PERMISSION_BITS, OBJECT_RULE_BITS, OBJECT_RULE_ROWS, lostAccessToLapse, canEditConnectionsIn, planLapsedFor, type Permission, type PermissionSnapshot, type RuleEntry, @@ -331,11 +331,11 @@ describe("resolveCan with an object id", () => { }); }); -test("isTeamOwner compares the team's owner_id with the signed-in user", () => { - expect(isTeamOwner({ owner_id: "u1" }, "u1")).toBe(true); - expect(isTeamOwner({ owner_id: "u1" }, "u2")).toBe(false); - expect(isTeamOwner({ owner_id: "" }, "")).toBe(false); - expect(isTeamOwner(undefined, "u1")).toBe(false); +test("isPrimaryOwner compares the team's owner_id with the signed-in user", () => { + expect(isPrimaryOwner({ owner_id: "u1" }, "u1")).toBe(true); + expect(isPrimaryOwner({ owner_id: "u1" }, "u2")).toBe(false); + expect(isPrimaryOwner({ owner_id: "" }, "")).toBe(false); + expect(isPrimaryOwner(undefined, "u1")).toBe(false); }); describe("lapse principle (mirror of server lapse math)", () => { diff --git a/src/services/permissions.ts b/src/services/permissions.ts index dc61dc87c..e6b76c455 100644 --- a/src/services/permissions.ts +++ b/src/services/permissions.ts @@ -147,7 +147,7 @@ export const OBJECT_RULE_ROWS: Record = { snippet_folder: ["VIEW", "EDIT_FOLDERS", "EDIT_SNIPPETS", "MANAGE_ROLES"], }; -export function isTeamOwner(team: { owner_id: string } | undefined, myUserId: string): boolean { +export function isPrimaryOwner(team: { owner_id: string } | undefined, myUserId: string): boolean { return !!myUserId && team?.owner_id === myUserId; } diff --git a/src/services/serverCapabilities.ts b/src/services/serverCapabilities.ts index b6ff77436..505b37738 100644 --- a/src/services/serverCapabilities.ts +++ b/src/services/serverCapabilities.ts @@ -17,6 +17,7 @@ interface ServerCapabilities { passkeys: boolean; wrappedSecretsCas: boolean; billingEnabled: boolean; + multiOwner: boolean; } const UNKNOWN: ServerCapabilities = { @@ -26,6 +27,7 @@ const UNKNOWN: ServerCapabilities = { passkeys: false, wrappedSecretsCas: false, billingEnabled: false, + multiOwner: false, }; const useServerCapabilities = create(() => UNKNOWN); @@ -34,7 +36,7 @@ let inFlight: Promise | null = null; async function loadCapabilities(): Promise { try { - const { team_secret_types: types, two_factor, passkeys, wrapped_secrets_cas, billing_enabled } = await fetchServerMeta( + const { team_secret_types: types, two_factor, passkeys, wrapped_secrets_cas, billing_enabled, multi_owner } = await fetchServerMeta( "common.error.failedToLoadServerCapabilities", ); fetchedAt = Date.now(); @@ -43,6 +45,7 @@ async function loadCapabilities(): Promise { passkeys: passkeys === true, wrappedSecretsCas: wrapped_secrets_cas === true, billingEnabled: billing_enabled === true, + multiOwner: multi_owner === true, ...(Array.isArray(types) ? { advertisedSecretTypes: types.filter((t): t is string => typeof t === "string"), refusedSecretTypes: [] } : { advertisedSecretTypes: null }), @@ -102,6 +105,12 @@ export function useServerBillingEnabled(): boolean { return useServerCapabilities((s) => s.billingEnabled); } +/** Whether this server lets several members hold Owner and hand billing between them. */ +export function useServerMultiOwner(): boolean { + useEffect(() => void refreshServerCapabilities(), []); + return useServerCapabilities((s) => s.multiOwner); +} + export async function serverSupportsWrappedSecretsCas(): Promise { await refreshServerCapabilities(); return useServerCapabilities.getState().wrappedSecretsCas; diff --git a/src/services/teamOwnership.test.ts b/src/services/teamOwnership.test.ts new file mode 100644 index 000000000..66e9abafc --- /dev/null +++ b/src/services/teamOwnership.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "vitest"; +import { memberOwnership, type OwnershipContext } from "@/services/teamOwnership"; + +const ctx = (over: Partial = {}): OwnershipContext => ({ + multiOwner: true, + billingEnabled: true, + billingOwnerId: "primary", + viewerIsOwner: false, + viewerIsPrimary: false, + ...over, +}); +const of = (c: OwnershipContext, id: string, isOwner: boolean, me = "me") => + memberOwnership(c, { userId: id, isMe: id === me, isOwner }); + +describe("memberOwnership", () => { + test("the primary owner is out of everyone's reach and cannot leave", () => { + for (const viewerIsOwner of [false, true]) { + const o = of(ctx({ viewerIsOwner }), "primary", true); + expect(o.isPrimaryOwner).toBe(true); + expect(o.protectedFromViewer).toBe(true); + expect(o.canGrantOwner).toBe(false); + expect(o.canMakePrimary).toBe(false); + } + expect(of(ctx({ viewerIsPrimary: true, viewerIsOwner: true }), "primary", true, "primary").canLeave).toBe(false); + }); + + test("a co-owner is protected from non-owners only", () => { + expect(of(ctx(), "bob", true).protectedFromViewer).toBe(true); + expect(of(ctx({ viewerIsOwner: true }), "bob", true).protectedFromViewer).toBe(false); + expect(of(ctx({ viewerIsOwner: true }), "bob", true).canGrantOwner).toBe(true); + expect(of(ctx(), "bob", false).canGrantOwner).toBe(false); + }); + + test("a co-owner may leave on a multi-owner server", () => { + expect(of(ctx({ viewerIsOwner: true }), "bob", true, "bob").canLeave).toBe(true); + expect(of(ctx({ viewerIsOwner: true, multiOwner: false }), "bob", true, "bob").canLeave).toBe(false); + }); + + test("on the cloud only the primary owner hands billing over", () => { + expect(of(ctx({ viewerIsOwner: true }), "bob", true).canMakePrimary).toBe(false); + expect(of(ctx({ viewerIsPrimary: true, viewerIsOwner: true }), "bob", true).canMakePrimary).toBe(true); + expect(of(ctx({ viewerIsPrimary: true, viewerIsOwner: true }), "bob", false).canMakePrimary).toBe(false); + }); + + test("self-hosted, any owner can make another owner, or themselves, primary", () => { + const c = ctx({ billingEnabled: false, viewerIsOwner: true }); + expect(of(c, "bob", true).canMakePrimary).toBe(true); + expect(of(c, "me", true).canMakePrimary).toBe(true); + }); + + test("an old server keeps the single-owner rules", () => { + const c = ctx({ multiOwner: false, viewerIsOwner: true, viewerIsPrimary: true }); + expect(of(c, "bob", true).protectedFromViewer).toBe(true); + expect(of(c, "bob", true).canGrantOwner).toBe(false); + expect(of(c, "bob", true).canMakePrimary).toBe(false); + expect(of(c, "carol", false).protectedFromViewer).toBe(false); + }); +}); diff --git a/src/services/teamOwnership.ts b/src/services/teamOwnership.ts new file mode 100644 index 000000000..7a0b51a17 --- /dev/null +++ b/src/services/teamOwnership.ts @@ -0,0 +1,37 @@ +export interface OwnershipContext { + multiOwner: boolean; + billingEnabled: boolean; + billingOwnerId: string | undefined; + viewerIsOwner: boolean; + viewerIsPrimary: boolean; +} + +export interface OwnershipTarget { + userId: string; + isMe: boolean; + isOwner: boolean; +} + +export interface MemberOwnership { + isOwner: boolean; + isPrimaryOwner: boolean; + /** Out of the viewer's reach: no removal, no Owner-role change. */ + protectedFromViewer: boolean; + canLeave: boolean; + canGrantOwner: boolean; + canMakePrimary: boolean; +} + +export function memberOwnership(ctx: OwnershipContext, target: OwnershipTarget): MemberOwnership { + const isPrimaryOwner = !!ctx.billingOwnerId && target.userId === ctx.billingOwnerId; + const ownerPeer = ctx.multiOwner && ctx.viewerIsOwner; + return { + isOwner: target.isOwner, + isPrimaryOwner, + protectedFromViewer: isPrimaryOwner || (target.isOwner && !ownerPeer), + canLeave: target.isMe && !isPrimaryOwner && (ctx.multiOwner || !target.isOwner), + canGrantOwner: ownerPeer && !target.isMe && !isPrimaryOwner, + canMakePrimary: ctx.multiOwner && target.isOwner && !isPrimaryOwner + && (ctx.viewerIsPrimary || (!ctx.billingEnabled && ctx.viewerIsOwner)), + }; +} diff --git a/src/services/teamService.ts b/src/services/teamService.ts index 834eac357..91e7cd044 100644 --- a/src/services/teamService.ts +++ b/src/services/teamService.ts @@ -241,6 +241,20 @@ export async function removeMemberRole( } } +export async function transferBillingOwner(teamId: string, userId: string): Promise { + const serverUrl = await getServerUrl(); + if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); + const res = await fetchAuth(`${serverUrl}/v1/teams/${teamId}/billing-owner`, { + method: "PUT", + body: JSON.stringify({ user_id: userId }), + }); + if (res.ok) return; + if (res.status === 402) throw new Error(i18n.t("common.error.billingTransferNeedsPlan")); + if (res.status === 409) throw new Error(i18n.t("common.error.billingTransferNeedsOwner")); + if (res.status === 403) throw new Error(i18n.t("common.error.billingTransferForbidden")); + throw new Error(i18n.t("common.error.failedToTransferBilling", { status: res.status })); +} + export async function setMemberPermissions( teamId: string, userId: string, diff --git a/src/stores/teamStore.ts b/src/stores/teamStore.ts index 7d02d4adc..b740418b6 100644 --- a/src/stores/teamStore.ts +++ b/src/stores/teamStore.ts @@ -43,6 +43,7 @@ interface TeamStore { deleteRole: (teamId: string, roleId: string) => Promise; assignMemberRole: (teamId: string, userId: string, roleId: string) => Promise; removeMemberRole: (teamId: string, userId: string, roleId: string) => Promise; + transferBillingOwner: (teamId: string, userId: string) => Promise; setMemberPermissions: (teamId: string, userId: string, allow: number, deny: number) => Promise; } @@ -287,6 +288,12 @@ export const useTeamStore = create()( })); }, + transferBillingOwner: async (teamId, userId) => { + await api.transferBillingOwner(teamId, userId); + set((s) => ({ teams: s.teams.map((t) => (t.id === teamId ? { ...t, owner_id: userId } : t)) })); + await get().loadTeams(); + }, + setMemberName: async (teamId, userId, name) => { await api.setMemberName(teamId, userId, name); await get().loadMembers(teamId);