Skip to content

Commit ea79054

Browse files
feat(web): add multi-owner support with promote/demote actions
Allow owners to promote members to owner and demote owners to member, enabling multiple owners per organization. This is gated behind the org-management entitlement as an enterprise feature. - Add promoteToOwner and demoteToMember server actions in ee/features/userManagement - Update leaveOrg to allow non-last owners to leave - Replace "Transfer ownership" UI with "Promote to owner" / "Demote to member" - Support self-demotion (with last-owner protection) - Deprecate transferOwnership action Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d9d5b2a commit ea79054

7 files changed

Lines changed: 267 additions & 30 deletions

File tree

packages/web/src/actions.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,6 +1150,11 @@ export const getInviteInfo = async (inviteId: string) => sew(() =>
11501150
}
11511151
}));
11521152

1153+
/**
1154+
* @deprecated Use `promoteToOwner` and `demoteToMember` from `@/ee/features/userManagement/actions` instead.
1155+
* This function atomically promotes a member to OWNER and demotes the caller to MEMBER.
1156+
* In a multi-owner model, use promoteToOwner (and optionally leaveOrg) for equivalent behavior.
1157+
*/
11531158
export const transferOwnership = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
11541159
withAuth((userId) =>
11551160
withOrgMembership(userId, domain, async ({ org }) => {
@@ -1301,11 +1306,20 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S
13011306
withAuth(async (userId) =>
13021307
withOrgMembership(userId, domain, async ({ org, userRole }) => {
13031308
if (userRole === OrgRole.OWNER) {
1304-
return {
1305-
statusCode: StatusCodes.FORBIDDEN,
1306-
errorCode: ErrorCode.OWNER_CANNOT_LEAVE_ORG,
1307-
message: "Organization owners cannot leave their own organization",
1308-
} satisfies ServiceError;
1309+
const ownerCount = await prisma.userToOrg.count({
1310+
where: {
1311+
orgId: org.id,
1312+
role: OrgRole.OWNER,
1313+
},
1314+
});
1315+
1316+
if (ownerCount <= 1) {
1317+
return {
1318+
statusCode: StatusCodes.FORBIDDEN,
1319+
errorCode: ErrorCode.OWNER_CANNOT_LEAVE_ORG,
1320+
message: "You are the last owner of this organization. Promote another member to owner before leaving.",
1321+
} satisfies ServiceError;
1322+
}
13091323
}
13101324

13111325
await prisma.$transaction(async (tx) => {

packages/web/src/app/[domain]/settings/members/components/membersList.tsx

Lines changed: 86 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import { OrgRole } from "@prisma/client";
1111
import placeholderAvatar from "@/public/placeholder_avatar.png";
1212
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
1313
import { useDomain } from "@/hooks/useDomain";
14-
import { transferOwnership, removeMemberFromOrg, leaveOrg } from "@/actions";
14+
import { removeMemberFromOrg, leaveOrg } from "@/actions";
15+
import { promoteToOwner, demoteToMember } from "@/ee/features/userManagement/actions";
1516
import { isServiceError } from "@/lib/utils";
1617
import { useToast } from "@/components/hooks/use-toast";
1718
import { useRouter } from "next/navigation";
@@ -31,22 +32,27 @@ export interface MembersListProps {
3132
currentUserId: string,
3233
currentUserRole: OrgRole,
3334
orgName: string,
35+
hasOrgManagement: boolean,
3436
}
3537

36-
export const MembersList = ({ members, currentUserId, currentUserRole, orgName }: MembersListProps) => {
38+
export const MembersList = ({ members, currentUserId, currentUserRole, orgName, hasOrgManagement }: MembersListProps) => {
3739
const [searchQuery, setSearchQuery] = useState("")
3840
const [roleFilter, setRoleFilter] = useState<"all" | OrgRole>("all")
3941
const [dateSort, setDateSort] = useState<"newest" | "oldest">("newest")
4042
const [memberToRemove, setMemberToRemove] = useState<Member | null>(null)
41-
const [memberToTransfer, setMemberToTransfer] = useState<Member | null>(null)
43+
const [memberToPromote, setMemberToPromote] = useState<Member | null>(null)
44+
const [memberToDemote, setMemberToDemote] = useState<Member | null>(null)
4245
const domain = useDomain()
4346
const { toast } = useToast()
4447
const [isRemoveDialogOpen, setIsRemoveDialogOpen] = useState(false)
45-
const [isTransferOwnershipDialogOpen, setIsTransferOwnershipDialogOpen] = useState(false)
48+
const [isPromoteDialogOpen, setIsPromoteDialogOpen] = useState(false)
49+
const [isDemoteDialogOpen, setIsDemoteDialogOpen] = useState(false)
4650
const [isLeaveOrgDialogOpen, setIsLeaveOrgDialogOpen] = useState(false)
4751
const router = useRouter();
4852
const captureEvent = useCaptureEvent();
4953

54+
const ownerCount = useMemo(() => members.filter(m => m.role === OrgRole.OWNER).length, [members]);
55+
5056
const filteredMembers = useMemo(() => {
5157
return members
5258
.filter((member) => {
@@ -83,25 +89,45 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
8389
});
8490
}, [domain, toast, router, captureEvent]);
8591

86-
const onTransferOwnership = useCallback((memberId: string) => {
87-
transferOwnership(memberId, domain)
92+
const onPromoteToOwner = useCallback((memberId: string) => {
93+
promoteToOwner(memberId)
8894
.then((response) => {
8995
if (isServiceError(response)) {
9096
toast({
91-
description: `❌ Failed to transfer ownership. Reason: ${response.message}`
97+
description: `❌ Failed to promote member. Reason: ${response.message}`
9298
})
93-
captureEvent('wa_members_list_transfer_ownership_fail', {
99+
captureEvent('wa_members_list_promote_to_owner_fail', {
94100
errorCode: response.errorCode,
95101
})
96102
} else {
97103
toast({
98-
description: `✅ Ownership transferred successfully.`
104+
description: `✅ Member promoted to owner.`
99105
})
100-
captureEvent('wa_members_list_transfer_ownership_success', {})
106+
captureEvent('wa_members_list_promote_to_owner_success', {})
101107
router.refresh();
102108
}
103109
});
104-
}, [domain, toast, router, captureEvent]);
110+
}, [toast, router, captureEvent]);
111+
112+
const onDemoteToMember = useCallback((memberId: string) => {
113+
demoteToMember(memberId)
114+
.then((response) => {
115+
if (isServiceError(response)) {
116+
toast({
117+
description: `❌ Failed to demote owner. Reason: ${response.message}`
118+
})
119+
captureEvent('wa_members_list_demote_to_member_fail', {
120+
errorCode: response.errorCode,
121+
})
122+
} else {
123+
toast({
124+
description: `✅ Owner demoted to member.`
125+
})
126+
captureEvent('wa_members_list_demote_to_member_success', {})
127+
router.refresh();
128+
}
129+
});
130+
}, [toast, router, captureEvent]);
105131

106132
const onLeaveOrg = useCallback(() => {
107133
leaveOrg(domain)
@@ -207,15 +233,26 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
207233
>
208234
Copy email
209235
</DropdownMenuItem>
210-
{member.id !== currentUserId && currentUserRole === OrgRole.OWNER && (
236+
{hasOrgManagement && member.id !== currentUserId && currentUserRole === OrgRole.OWNER && member.role !== OrgRole.OWNER && (
237+
<DropdownMenuItem
238+
className="cursor-pointer"
239+
onClick={() => {
240+
setMemberToPromote(member);
241+
setIsPromoteDialogOpen(true);
242+
}}
243+
>
244+
Promote to owner
245+
</DropdownMenuItem>
246+
)}
247+
{hasOrgManagement && currentUserRole === OrgRole.OWNER && member.role === OrgRole.OWNER && (
211248
<DropdownMenuItem
212249
className="cursor-pointer"
213250
onClick={() => {
214-
setMemberToTransfer(member);
215-
setIsTransferOwnershipDialogOpen(true);
251+
setMemberToDemote(member);
252+
setIsDemoteDialogOpen(true);
216253
}}
217254
>
218-
Transfer ownership
255+
Demote to member
219256
</DropdownMenuItem>
220257
)}
221258
{member.id !== currentUserId && currentUserRole === OrgRole.OWNER && (
@@ -232,7 +269,7 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
232269
{member.id === currentUserId && (
233270
<DropdownMenuItem
234271
className="cursor-pointer text-destructive"
235-
disabled={currentUserRole === OrgRole.OWNER}
272+
disabled={currentUserRole === OrgRole.OWNER && ownerCount <= 1}
236273
onClick={() => {
237274
setIsLeaveOrgDialogOpen(true);
238275
}}
@@ -273,24 +310,51 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
273310
</AlertDialogContent>
274311
</AlertDialog>
275312
<AlertDialog
276-
open={isTransferOwnershipDialogOpen}
277-
onOpenChange={setIsTransferOwnershipDialogOpen}
313+
open={isPromoteDialogOpen}
314+
onOpenChange={setIsPromoteDialogOpen}
278315
>
279316
<AlertDialogContent>
280317
<AlertDialogHeader>
281-
<AlertDialogTitle>Transfer Ownership</AlertDialogTitle>
318+
<AlertDialogTitle>Promote to Owner</AlertDialogTitle>
282319
<AlertDialogDescription>
283-
{`Are you sure you want to transfer ownership of ${orgName} to ${memberToTransfer?.name ?? memberToTransfer?.email}?`}
320+
{`Are you sure you want to promote ${memberToPromote?.name ?? memberToPromote?.email} to owner? They will have full administrative access to ${orgName}.`}
284321
</AlertDialogDescription>
285322
</AlertDialogHeader>
286323
<AlertDialogFooter>
287324
<AlertDialogCancel>Cancel</AlertDialogCancel>
288325
<AlertDialogAction
289326
onClick={() => {
290-
onTransferOwnership(memberToTransfer?.id ?? "");
327+
onPromoteToOwner(memberToPromote?.id ?? "");
291328
}}
292329
>
293-
Transfer
330+
Promote
331+
</AlertDialogAction>
332+
</AlertDialogFooter>
333+
</AlertDialogContent>
334+
</AlertDialog>
335+
<AlertDialog
336+
open={isDemoteDialogOpen}
337+
onOpenChange={setIsDemoteDialogOpen}
338+
>
339+
<AlertDialogContent>
340+
<AlertDialogHeader>
341+
<AlertDialogTitle>Demote to Member</AlertDialogTitle>
342+
<AlertDialogDescription>
343+
{memberToDemote?.id === currentUserId
344+
? `Are you sure you want to step down as owner? You will lose administrative access to ${orgName}.`
345+
: `Are you sure you want to demote ${memberToDemote?.name ?? memberToDemote?.email} from owner to member? They will lose administrative access.`
346+
}
347+
</AlertDialogDescription>
348+
</AlertDialogHeader>
349+
<AlertDialogFooter>
350+
<AlertDialogCancel>Cancel</AlertDialogCancel>
351+
<AlertDialogAction
352+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
353+
onClick={() => {
354+
onDemoteToMember(memberToDemote?.id ?? "");
355+
}}
356+
>
357+
Demote
294358
</AlertDialogAction>
295359
</AlertDialogFooter>
296360
</AlertDialogContent>
@@ -321,4 +385,3 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
321385
</div>
322386
)
323387
}
324-

packages/web/src/app/[domain]/settings/members/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { InvitesList } from "./components/invitesList";
99
import { getOrgInvites, getMe, getOrgAccountRequests } from "@/actions";
1010
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
1111
import { ServiceErrorException } from "@/lib/serviceError";
12-
import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
12+
import { getSeats, hasEntitlement, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
1313
import { RequestsList } from "./components/requestsList";
1414
import { OrgRole } from "@prisma/client";
1515
import { redirect } from "next/navigation";
@@ -160,6 +160,7 @@ export default async function MembersSettingsPage(props: MembersSettingsPageProp
160160
currentUserId={me.id}
161161
currentUserRole={userRoleInOrg}
162162
orgName={org.name}
163+
hasOrgManagement={hasEntitlement('org-management')}
163164
/>
164165
</TabsContent>
165166

packages/web/src/ee/features/billing/components/manageSubscriptionButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export function ManageSubscriptionButton({ currentUserRole }: { currentUserRole:
3838
<Button
3939
onClick={redirectToCustomerPortal}
4040
disabled={isLoading || !isOwner}
41-
title={!isOwner ? "Only the owner of the org can manage the subscription" : undefined}
41+
title={!isOwner ? "Only owners of the org can manage the subscription" : undefined}
4242
>
4343
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
4444
Manage Subscription

0 commit comments

Comments
 (0)