From f52cbbd3dbfebc83483efb7beef3b4a990597713 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 18 Oct 2026 20:56:57 +0000 Subject: [PATCH] feat(teams): several owners per team, and a primary owner who can hand over (#652) Owners now see the Owner role in a member's roles and can grant or remove it, after a confirmation. A co-owner is no longer out of an Owner's reach: they can be removed, and can leave on their own. The team's creator becomes the primary owner, the account billing sits on. The member panel says so, explains that they must hand it over before leaving, and offers "Make primary owner" on another Owner. On a self-hosted server any Owner may do that. On the cloud only the primary owner may, and the confirmation says the new owner's own subscription pays from then on. The controls appear only when the server advertises multi_owner, so an older server keeps the single-owner behaviour. The ownership rules live in a pure memberOwnership model shared by the roster and the panel. isTeamOwner is renamed isPrimaryOwner, since owner_id now means exactly that. --- .../MembersPage.MemberDetailPanel.test.tsx | 1 + src/components/members/MembersPage.tsx | 12 +- .../MemberDetailPanel.memberName.test.tsx | 2 +- .../MemberDetailPanel.ownership.test.tsx | 147 ++++++++++++++++++ .../members/panels/MemberDetailPanel.tsx | 94 ++++++++++- src/components/team/TeamVaultStatePanel.tsx | 4 +- .../vault-admin/vaultAdminTarget.ts | 8 +- src/hooks/useBusinessLock.ts | 4 +- src/hooks/useOwnershipContext.ts | 19 +++ src/i18n/locales/cs/common.json | 4 + src/i18n/locales/cs/members.json | 27 +++- src/i18n/locales/en/common.json | 4 + src/i18n/locales/en/members.json | 27 +++- src/i18n/locales/fr/common.json | 4 + src/i18n/locales/fr/members.json | 27 +++- src/i18n/locales/ru/common.json | 4 + src/i18n/locales/ru/members.json | 27 +++- src/i18n/locales/tr/common.json | 4 + src/i18n/locales/tr/members.json | 27 +++- src/i18n/locales/zh/common.json | 4 + src/i18n/locales/zh/members.json | 27 +++- src/services/permissions.test.ts | 12 +- src/services/permissions.ts | 2 +- src/services/serverCapabilities.ts | 11 +- src/services/teamOwnership.test.ts | 58 +++++++ src/services/teamOwnership.ts | 37 +++++ src/services/teamService.ts | 14 ++ src/stores/teamStore.ts | 7 + 28 files changed, 585 insertions(+), 33 deletions(-) create mode 100644 src/components/members/panels/MemberDetailPanel.ownership.test.tsx create mode 100644 src/hooks/useOwnershipContext.ts create mode 100644 src/services/teamOwnership.test.ts create mode 100644 src/services/teamOwnership.ts 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);