Skip to content

Commit fc90833

Browse files
feat(web): add multi-owner support with promote/demote actions (#988)
* 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> * docs: update roles, access settings, and audit docs for multi-owner support - Update roles-and-permissions to document multiple owners and promote/demote workflows - Update access-settings to use plural owner references - Add new audit events: org.member_promoted_to_owner, org.owner_demoted_to_member Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feedback * feedback * changelog * docs * docs * docs --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5dea84f commit fc90833

File tree

17 files changed

+492
-229
lines changed

17 files changed

+492
-229
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- [EE] Added multi-owner support with promote/demote actions. [#988](https://github.com/sourcebot-dev/sourcebot/pull/988)
12+
1013
## [4.15.3] - 2026-03-10
1114

1215
### Fixed

docs/docs/configuration/audit-logs.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
122122
| `chat.shared_with_users` | `user` | `chat` |
123123
| `chat.unshared_with_user` | `user` | `chat` |
124124
| `chat.visibility_updated` | `user` | `chat` |
125-
| `org.ownership_transfer_failed` | `user` | `org` |
126-
| `org.ownership_transferred` | `user` | `org` |
127125
| `user.created_ask_chat` | `user` | `org` |
128126
| `user.creation_failed` | `user` | `user` |
129127
| `user.delete` | `user` | `user` |
@@ -144,6 +142,8 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
144142
| `user.read` | `user` | `user` |
145143
| `user.signed_in` | `user` | `user` |
146144
| `user.signed_out` | `user` | `user` |
145+
| `org.member_promoted_to_owner` | `user` | `user` |
146+
| `org.owner_demoted_to_member` | `user` | `user` |
147147

148148

149149
## Response schema

docs/docs/configuration/auth/access-settings.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,20 @@ When accessing Sourcebot anonymously, a user's permissions are limited to that o
1717

1818
# Member Approval
1919

20-
By default, Sourcebot requires new members to be approved by the owner of the deployment. This section explains how approvals work and how
20+
By default, Sourcebot requires new members to be approved by an owner of the deployment. This section explains how approvals work and how
2121
to configure this behavior.
2222

2323
### Configuration
24-
Member approval can be configured by the owner of the deployment by navigating to **Settings -> Access**, or by setting the `REQUIRE_APPROVAL_NEW_MEMBERS` environment variable. When the environment variable is set, the UI toggle is disabled and the setting is controlled by the environment variable.
24+
Member approval can be configured by an owner of the deployment by navigating to **Settings -> Access**, or by setting the `REQUIRE_APPROVAL_NEW_MEMBERS` environment variable. When the environment variable is set, the UI toggle is disabled and the setting is controlled by the environment variable.
2525

2626
![Member Approval Toggle](/images/member_approval_toggle.png)
2727

2828
### Managing Requests
2929

3030
If member approval is enabled, new members will be asked to submit a join request after signing up. They will not have access to the Sourcebot deployment
31-
until this request is approved by the owner.
31+
until this request is approved by an owner.
3232

33-
The owner can see and manage all pending join requests by navigating to **Settings -> Members**.
33+
Owners can see and manage all pending join requests by navigating to **Settings -> Members**.
3434

3535
## Invite link
3636

docs/docs/configuration/auth/roles-and-permissions.mdx

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,46 @@ title: Roles and Permissions
33
sidebarTitle: Roles and permissions
44
---
55

6-
<Note>Looking to sync permissions with your identify provider? We're working on it - [reach out](https://www.sourcebot.dev/contact) to us to learn more</Note>
7-
86
Each member has a role which defines their permissions within an organization:
97

108
| Role | Permission |
119
| :--- | :--------- |
12-
| `Owner` | Each organization has a single `Owner`. This user has full access rights, including: connection management, organization management, and inviting new members. |
10+
| `Owner` | An organization can have one or more `Owner`s. Owners have full access rights, including: connection management, organization management, and inviting new members. |
1311
| `Member` | Read-only access to the organization. A `Member` can search across the repos indexed by an organization's connections, as well as view the organizations configuration and member list. However, they cannot modify this configuration or invite new members. |
14-
| `Guest` | When accessing Sourcebot [anonymously](/docs/configuration/auth/access-settings#anonymous-access), a user has the `Guest` role. `Guest`'s can search across repos indexed by an organization's connections, but cannot view any information regarding the organizations configuration or members. |
12+
| `Guest` | When accessing Sourcebot [anonymously](/docs/configuration/auth/access-settings#anonymous-access), a user has the `Guest` role. `Guest`'s can search across repos indexed by an organization's connections, but cannot view any information regarding the organizations configuration or members. |
13+
14+
## Managing owners
15+
16+
import LicenseKeyRequired from '/snippets/license-key-required.mdx'
17+
18+
<LicenseKeyRequired feature="Multiple owners" />
19+
20+
organizations support multiple owners, allowing you to share administrative responsibilities across your team. Owners can promote members to owner and demote other owners back to member from **Settings -> Members**.
21+
22+
<Frame>
23+
<img src="/images/managing_owners.png" alt="Members settings page showing team members and their roles" />
24+
</Frame>
25+
26+
### Promoting a member to owner
27+
28+
To promote a member, click the action menu (three dots) next to their name in the members list and select **Promote to owner**. The member will immediately gain full administrative access.
29+
30+
<Frame>
31+
<img src="/images/promote_to_owner.png" alt="Dropdown menu showing Promote to owner option for a member" />
32+
</Frame>
33+
34+
### Demoting an owner to member
35+
36+
To demote an owner, click the action menu next to their name and select **Demote to member**. Owners can also demote themselves to step down from the role. The last remaining owner of an organization cannot be demoted - at least one owner must exist at all times.
37+
38+
<Frame>
39+
<img src="/images/demote_to_member.png" alt="Dropdown menu showing Demote to member option for an owner" />
40+
</Frame>
41+
42+
### Leaving an organization as an owner
43+
44+
An owner can leave the organization as long as at least one other owner exists. If you are the last owner, you must promote another member to owner before leaving.
45+
46+
<Frame>
47+
<img src="/images/owner_leave_org.png" alt="Dropdown menu showing Leave organization option for an owner" />
48+
</Frame>

docs/docs/license-key.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ docker run \
4040
| [Audit logs](/docs/configuration/audit-logs) | 🛑 ||
4141
| [Analytics](/docs/features/analytics) | 🛑 ||
4242
| [MCP OAuth](/docs/features/mcp-server#oauth-2-0) | 🛑 ||
43+
| [Multiple owners](/docs/configuration/auth/roles-and-permissions#managing-owners) | 🛑 ||
4344

4445

4546
## Questions?

docs/images/demote_to_member.png

56.7 KB
Loading

docs/images/managing_owners.png

256 KB
Loading

docs/images/owner_leave_org.png

60.5 KB
Loading

docs/images/promote_to_owner.png

55.3 KB
Loading

packages/web/src/actions.ts

Lines changed: 1 addition & 171 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { createTransport } from "nodemailer";
2222
import { Octokit } from "octokit";
2323
import { auth } from "./auth";
2424
import { getOrgFromDomain } from "./data/org";
25-
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
25+
import { getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
2626
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
2727
import InviteUserEmail from "./emails/inviteUserEmail";
2828
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
@@ -1150,102 +1150,6 @@ export const getInviteInfo = async (inviteId: string) => sew(() =>
11501150
}
11511151
}));
11521152

1153-
export const transferOwnership = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
1154-
withAuth((userId) =>
1155-
withOrgMembership(userId, domain, async ({ org }) => {
1156-
const currentUserId = userId;
1157-
1158-
const failAuditCallback = async (error: string) => {
1159-
await auditService.createAudit({
1160-
action: "org.ownership_transfer_failed",
1161-
actor: {
1162-
id: currentUserId,
1163-
type: "user"
1164-
},
1165-
target: {
1166-
id: org.id.toString(),
1167-
type: "org"
1168-
},
1169-
orgId: org.id,
1170-
metadata: {
1171-
message: error
1172-
}
1173-
})
1174-
}
1175-
if (newOwnerId === currentUserId) {
1176-
await failAuditCallback("User is already the owner of this org");
1177-
return {
1178-
statusCode: StatusCodes.BAD_REQUEST,
1179-
errorCode: ErrorCode.INVALID_REQUEST_BODY,
1180-
message: "You're already the owner of this org",
1181-
} satisfies ServiceError;
1182-
}
1183-
1184-
const newOwner = await prisma.userToOrg.findUnique({
1185-
where: {
1186-
orgId_userId: {
1187-
userId: newOwnerId,
1188-
orgId: org.id,
1189-
},
1190-
},
1191-
});
1192-
1193-
if (!newOwner) {
1194-
await failAuditCallback("The user you're trying to make the owner doesn't exist");
1195-
return {
1196-
statusCode: StatusCodes.BAD_REQUEST,
1197-
errorCode: ErrorCode.INVALID_REQUEST_BODY,
1198-
message: "The user you're trying to make the owner doesn't exist",
1199-
} satisfies ServiceError;
1200-
}
1201-
1202-
await prisma.$transaction([
1203-
prisma.userToOrg.update({
1204-
where: {
1205-
orgId_userId: {
1206-
userId: newOwnerId,
1207-
orgId: org.id,
1208-
},
1209-
},
1210-
data: {
1211-
role: "OWNER",
1212-
}
1213-
}),
1214-
prisma.userToOrg.update({
1215-
where: {
1216-
orgId_userId: {
1217-
userId: currentUserId,
1218-
orgId: org.id,
1219-
},
1220-
},
1221-
data: {
1222-
role: "MEMBER",
1223-
}
1224-
})
1225-
]);
1226-
1227-
await auditService.createAudit({
1228-
action: "org.ownership_transferred",
1229-
actor: {
1230-
id: currentUserId,
1231-
type: "user"
1232-
},
1233-
target: {
1234-
id: org.id.toString(),
1235-
type: "org"
1236-
},
1237-
orgId: org.id,
1238-
metadata: {
1239-
message: `Ownership transferred from ${currentUserId} to ${newOwnerId}`
1240-
}
1241-
});
1242-
1243-
return {
1244-
success: true,
1245-
}
1246-
}, /* minRequiredRole = */ OrgRole.OWNER)
1247-
));
1248-
12491153
export const checkIfOrgDomainExists = async (domain: string): Promise<boolean | ServiceError> => sew(() =>
12501154
withAuth(async () => {
12511155
const org = await prisma.org.findFirst({
@@ -1257,80 +1161,6 @@ export const checkIfOrgDomainExists = async (domain: string): Promise<boolean |
12571161
return !!org;
12581162
}));
12591163

1260-
export const removeMemberFromOrg = async (memberId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
1261-
withAuth(async (userId) =>
1262-
withOrgMembership(userId, domain, async ({ org }) => {
1263-
const targetMember = await prisma.userToOrg.findUnique({
1264-
where: {
1265-
orgId_userId: {
1266-
orgId: org.id,
1267-
userId: memberId,
1268-
}
1269-
}
1270-
});
1271-
1272-
if (!targetMember) {
1273-
return notFound();
1274-
}
1275-
1276-
await prisma.$transaction(async (tx) => {
1277-
await tx.userToOrg.delete({
1278-
where: {
1279-
orgId_userId: {
1280-
orgId: org.id,
1281-
userId: memberId,
1282-
}
1283-
}
1284-
});
1285-
1286-
if (IS_BILLING_ENABLED) {
1287-
const result = await decrementOrgSeatCount(org.id, tx);
1288-
if (isServiceError(result)) {
1289-
throw result;
1290-
}
1291-
}
1292-
});
1293-
1294-
return {
1295-
success: true,
1296-
}
1297-
}, /* minRequiredRole = */ OrgRole.OWNER)
1298-
));
1299-
1300-
export const leaveOrg = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
1301-
withAuth(async (userId) =>
1302-
withOrgMembership(userId, domain, async ({ org, userRole }) => {
1303-
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-
}
1310-
1311-
await prisma.$transaction(async (tx) => {
1312-
await tx.userToOrg.delete({
1313-
where: {
1314-
orgId_userId: {
1315-
orgId: org.id,
1316-
userId: userId,
1317-
}
1318-
}
1319-
});
1320-
1321-
if (IS_BILLING_ENABLED) {
1322-
const result = await decrementOrgSeatCount(org.id, tx);
1323-
if (isServiceError(result)) {
1324-
throw result;
1325-
}
1326-
}
1327-
});
1328-
1329-
return {
1330-
success: true,
1331-
}
1332-
})
1333-
));
13341164

13351165
export const getOrgMembers = async (domain: string) => sew(() =>
13361166
withAuth(async (userId) =>

0 commit comments

Comments
 (0)