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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- [EE] Added multi-owner support with promote/demote actions. [#988](https://github.com/sourcebot-dev/sourcebot/pull/988)

## [4.15.3] - 2026-03-10

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/configuration/audit-logs.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,8 +122,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `chat.shared_with_users` | `user` | `chat` |
| `chat.unshared_with_user` | `user` | `chat` |
| `chat.visibility_updated` | `user` | `chat` |
| `org.ownership_transfer_failed` | `user` | `org` |
| `org.ownership_transferred` | `user` | `org` |
| `user.created_ask_chat` | `user` | `org` |
| `user.creation_failed` | `user` | `user` |
| `user.delete` | `user` | `user` |
Expand All@@ -144,6 +142,8 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `user.read` | `user` | `user` |
| `user.signed_in` | `user` | `user` |
| `user.signed_out` | `user` | `user` |
| `org.member_promoted_to_owner` | `user` | `user` |
| `org.owner_demoted_to_member` | `user` | `user` |


## Response schema
Expand Down
8 changes: 4 additions & 4 deletions docs/docs/configuration/auth/access-settings.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,20 +17,20 @@ When accessing Sourcebot anonymously, a user's permissions are limited to that o

# Member Approval

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

### Configuration
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.
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.

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

### Managing Requests

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
until this request is approved by the owner.
until this request is approved by an owner.

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

## Invite link

Expand Down
42 changes: 38 additions & 4 deletions docs/docs/configuration/auth/roles-and-permissions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,46 @@ title: Roles and Permissions
sidebarTitle: Roles and permissions
---

<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>

Each member has a role which defines their permissions within an organization:

| Role | Permission |
| :--- | :--------- |
| `Owner` | Each organization has a single `Owner`. This user has full access rights, including: connection management, organization management, and inviting new members. |
| `Owner` | An organization can have one or more `Owner`s. Owners have full access rights, including: connection management, organization management, and inviting new members. |
| `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. |
| `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. |
| `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. |

## Managing owners

import LicenseKeyRequired from '/snippets/license-key-required.mdx'

<LicenseKeyRequired feature="Multiple owners" />

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**.

<Frame>
<img src="/images/managing_owners.png" alt="Members settings page showing team members and their roles" />
</Frame>

### Promoting a member to owner

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.

<Frame>
<img src="/images/promote_to_owner.png" alt="Dropdown menu showing Promote to owner option for a member" />
</Frame>

### Demoting an owner to member

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.

<Frame>
<img src="/images/demote_to_member.png" alt="Dropdown menu showing Demote to member option for an owner" />
</Frame>

### Leaving an organization as an owner

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.

<Frame>
<img src="/images/owner_leave_org.png" alt="Dropdown menu showing Leave organization option for an owner" />
</Frame>
1 change: 1 addition & 0 deletions docs/docs/license-key.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ docker run \
| [Audit logs](/docs/configuration/audit-logs) | 🛑 | ✅ |
| [Analytics](/docs/features/analytics) | 🛑 | ✅ |
| [MCP OAuth](/docs/features/mcp-server#oauth-2-0) | 🛑 | ✅ |
| [Multiple owners](/docs/configuration/auth/roles-and-permissions#managing-owners) | 🛑 | ✅ |


## Questions?
Expand Down
Binary file addeddocs/images/demote_to_member.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/managing_owners.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/owner_leave_org.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/promote_to_owner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
172 changes: 1 addition & 171 deletions packages/web/src/actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@ import { createTransport } from "nodemailer";
import { Octokit } from "octokit";
import { auth } from "./auth";
import { getOrgFromDomain } from "./data/org";
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
import InviteUserEmail from "./emails/inviteUserEmail";
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
Expand DownExpand Up@@ -1150,102 +1150,6 @@ export const getInviteInfo = async (inviteId: string) => sew(() =>
}
}));

export const transferOwnership = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const currentUserId = userId;

const failAuditCallback = async (error: string) => {
await auditService.createAudit({
action: "org.ownership_transfer_failed",
actor: {
id: currentUserId,
type: "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: error
}
})
}
if (newOwnerId === currentUserId) {
await failAuditCallback("User is already the owner of this org");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "You're already the owner of this org",
} satisfies ServiceError;
}

const newOwner = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
userId: newOwnerId,
orgId: org.id,
},
},
});

if (!newOwner) {
await failAuditCallback("The user you're trying to make the owner doesn't exist");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "The user you're trying to make the owner doesn't exist",
} satisfies ServiceError;
}

await prisma.$transaction([
prisma.userToOrg.update({
where: {
orgId_userId: {
userId: newOwnerId,
orgId: org.id,
},
},
data: {
role: "OWNER",
}
}),
prisma.userToOrg.update({
where: {
orgId_userId: {
userId: currentUserId,
orgId: org.id,
},
},
data: {
role: "MEMBER",
}
})
]);

await auditService.createAudit({
action: "org.ownership_transferred",
actor: {
id: currentUserId,
type: "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: `Ownership transferred from ${currentUserId} to ${newOwnerId}`
}
});

return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));

export const checkIfOrgDomainExists = async (domain: string): Promise<boolean | ServiceError> => sew(() =>
withAuth(async () => {
const org = await prisma.org.findFirst({
Expand All@@ -1257,80 +1161,6 @@ export const checkIfOrgDomainExists = async (domain: string): Promise<boolean |
return !!org;
}));

export const removeMemberFromOrg = async (memberId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const targetMember = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
orgId: org.id,
userId: memberId,
}
}
});

if (!targetMember) {
return notFound();
}

await prisma.$transaction(async (tx) => {
await tx.userToOrg.delete({
where: {
orgId_userId: {
orgId: org.id,
userId: memberId,
}
}
});

if (IS_BILLING_ENABLED) {
const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) {
throw result;
}
}
});

return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));

export const leaveOrg = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org, userRole }) => {
if (userRole === OrgRole.OWNER) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.OWNER_CANNOT_LEAVE_ORG,
message: "Organization owners cannot leave their own organization",
} satisfies ServiceError;
}

await prisma.$transaction(async (tx) => {
await tx.userToOrg.delete({
where: {
orgId_userId: {
orgId: org.id,
userId: userId,
}
}
});

if (IS_BILLING_ENABLED) {
const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) {
throw result;
}
}
});

return {
success: true,
}
})
));

export const getOrgMembers = async (domain: string) => sew(() =>
withAuth(async (userId) =>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- [EE] Added multi-owner support with promote/demote actions. [#988](https://github.com/sourcebot-dev/sourcebot/pull/988)

## [4.15.3] - 2026-03-10

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/configuration/audit-logs.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,8 +122,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `chat.shared_with_users` | `user` | `chat` |
| `chat.unshared_with_user` | `user` | `chat` |
| `chat.visibility_updated` | `user` | `chat` |
| `org.ownership_transfer_failed` | `user` | `org` |
| `org.ownership_transferred` | `user` | `org` |
| `user.created_ask_chat` | `user` | `org` |
| `user.creation_failed` | `user` | `user` |
| `user.delete` | `user` | `user` |
Expand All@@ -144,6 +142,8 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `user.read` | `user` | `user` |
| `user.signed_in` | `user` | `user` |
| `user.signed_out` | `user` | `user` |
| `org.member_promoted_to_owner` | `user` | `user` |
| `org.owner_demoted_to_member` | `user` | `user` |


## Response schema
Expand Down
8 changes: 4 additions & 4 deletions docs/docs/configuration/auth/access-settings.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,20 +17,20 @@ When accessing Sourcebot anonymously, a user's permissions are limited to that o

# Member Approval

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

### Configuration
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.
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.

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

### Managing Requests

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
until this request is approved by the owner.
until this request is approved by an owner.

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

## Invite link

Expand Down
42 changes: 38 additions & 4 deletions docs/docs/configuration/auth/roles-and-permissions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,46 @@ title: Roles and Permissions
sidebarTitle: Roles and permissions
---

<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>

Each member has a role which defines their permissions within an organization:

| Role | Permission |
| :--- | :--------- |
| `Owner` | Each organization has a single `Owner`. This user has full access rights, including: connection management, organization management, and inviting new members. |
| `Owner` | An organization can have one or more `Owner`s. Owners have full access rights, including: connection management, organization management, and inviting new members. |
| `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. |
| `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. |
| `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. |

## Managing owners

import LicenseKeyRequired from '/snippets/license-key-required.mdx'

<LicenseKeyRequired feature="Multiple owners" />

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**.

<Frame>
<img src="/images/managing_owners.png" alt="Members settings page showing team members and their roles" />
</Frame>

### Promoting a member to owner

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.

<Frame>
<img src="/images/promote_to_owner.png" alt="Dropdown menu showing Promote to owner option for a member" />
</Frame>

### Demoting an owner to member

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.

<Frame>
<img src="/images/demote_to_member.png" alt="Dropdown menu showing Demote to member option for an owner" />
</Frame>

### Leaving an organization as an owner

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.

<Frame>
<img src="/images/owner_leave_org.png" alt="Dropdown menu showing Leave organization option for an owner" />
</Frame>
1 change: 1 addition & 0 deletions docs/docs/license-key.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ docker run \
| [Audit logs](/docs/configuration/audit-logs) | 🛑 | ✅ |
| [Analytics](/docs/features/analytics) | 🛑 | ✅ |
| [MCP OAuth](/docs/features/mcp-server#oauth-2-0) | 🛑 | ✅ |
| [Multiple owners](/docs/configuration/auth/roles-and-permissions#managing-owners) | 🛑 | ✅ |


## Questions?
Expand Down
Binary file addeddocs/images/demote_to_member.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/managing_owners.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/owner_leave_org.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/promote_to_owner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
172 changes: 1 addition & 171 deletions packages/web/src/actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@ import { createTransport } from "nodemailer";
import { Octokit } from "octokit";
import { auth } from "./auth";
import { getOrgFromDomain } from "./data/org";
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
import InviteUserEmail from "./emails/inviteUserEmail";
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
Expand DownExpand Up@@ -1150,102 +1150,6 @@ export const getInviteInfo = async (inviteId: string) => sew(() =>
}
}));

export const transferOwnership = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const currentUserId = userId;

const failAuditCallback = async (error: string) => {
await auditService.createAudit({
action: "org.ownership_transfer_failed",
actor: {
id: currentUserId,
type: "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: error
}
})
}
if (newOwnerId === currentUserId) {
await failAuditCallback("User is already the owner of this org");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "You're already the owner of this org",
} satisfies ServiceError;
}

const newOwner = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
userId: newOwnerId,
orgId: org.id,
},
},
});

if (!newOwner) {
await failAuditCallback("The user you're trying to make the owner doesn't exist");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "The user you're trying to make the owner doesn't exist",
} satisfies ServiceError;
}

await prisma.$transaction([
prisma.userToOrg.update({
where: {
orgId_userId: {
userId: newOwnerId,
orgId: org.id,
},
},
data: {
role: "OWNER",
}
}),
prisma.userToOrg.update({
where: {
orgId_userId: {
userId: currentUserId,
orgId: org.id,
},
},
data: {
role: "MEMBER",
}
})
]);

await auditService.createAudit({
action: "org.ownership_transferred",
actor: {
id: currentUserId,
type: "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: `Ownership transferred from ${currentUserId} to ${newOwnerId}`
}
});

return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));

export const checkIfOrgDomainExists = async (domain: string): Promise<boolean | ServiceError> => sew(() =>
withAuth(async () => {
const org = await prisma.org.findFirst({
Expand All@@ -1257,80 +1161,6 @@ export const checkIfOrgDomainExists = async (domain: string): Promise<boolean |
return !!org;
}));

export const removeMemberFromOrg = async (memberId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const targetMember = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
orgId: org.id,
userId: memberId,
}
}
});

if (!targetMember) {
return notFound();
}

await prisma.$transaction(async (tx) => {
await tx.userToOrg.delete({
where: {
orgId_userId: {
orgId: org.id,
userId: memberId,
}
}
});

if (IS_BILLING_ENABLED) {
const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) {
throw result;
}
}
});

return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));

export const leaveOrg = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org, userRole }) => {
if (userRole === OrgRole.OWNER) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.OWNER_CANNOT_LEAVE_ORG,
message: "Organization owners cannot leave their own organization",
} satisfies ServiceError;
}

await prisma.$transaction(async (tx) => {
await tx.userToOrg.delete({
where: {
orgId_userId: {
orgId: org.id,
userId: userId,
}
}
});

if (IS_BILLING_ENABLED) {
const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) {
throw result;
}
}
});

return {
success: true,
}
})
));

export const getOrgMembers = async (domain: string) => sew(() =>
withAuth(async (userId) =>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- [EE] Added multi-owner support with promote/demote actions. [#988](https://github.com/sourcebot-dev/sourcebot/pull/988)

## [4.15.3] - 2026-03-10

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/configuration/audit-logs.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,8 +122,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `chat.shared_with_users` | `user` | `chat` |
| `chat.unshared_with_user` | `user` | `chat` |
| `chat.visibility_updated` | `user` | `chat` |
| `org.ownership_transfer_failed` | `user` | `org` |
| `org.ownership_transferred` | `user` | `org` |
| `user.created_ask_chat` | `user` | `org` |
| `user.creation_failed` | `user` | `user` |
| `user.delete` | `user` | `user` |
Expand All@@ -144,6 +142,8 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `user.read` | `user` | `user` |
| `user.signed_in` | `user` | `user` |
| `user.signed_out` | `user` | `user` |
| `org.member_promoted_to_owner` | `user` | `user` |
| `org.owner_demoted_to_member` | `user` | `user` |


## Response schema
Expand Down
8 changes: 4 additions & 4 deletions docs/docs/configuration/auth/access-settings.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,20 +17,20 @@ When accessing Sourcebot anonymously, a user's permissions are limited to that o

# Member Approval

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

### Configuration
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.
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.

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

### Managing Requests

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
until this request is approved by the owner.
until this request is approved by an owner.

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

## Invite link

Expand Down
42 changes: 38 additions & 4 deletions docs/docs/configuration/auth/roles-and-permissions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,46 @@ title: Roles and Permissions
sidebarTitle: Roles and permissions
---

<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>

Each member has a role which defines their permissions within an organization:

| Role | Permission |
| :--- | :--------- |
| `Owner` | Each organization has a single `Owner`. This user has full access rights, including: connection management, organization management, and inviting new members. |
| `Owner` | An organization can have one or more `Owner`s. Owners have full access rights, including: connection management, organization management, and inviting new members. |
| `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. |
| `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. |
| `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. |

## Managing owners

import LicenseKeyRequired from '/snippets/license-key-required.mdx'

<LicenseKeyRequired feature="Multiple owners" />

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**.

<Frame>
<img src="/images/managing_owners.png" alt="Members settings page showing team members and their roles" />
</Frame>

### Promoting a member to owner

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.

<Frame>
<img src="/images/promote_to_owner.png" alt="Dropdown menu showing Promote to owner option for a member" />
</Frame>

### Demoting an owner to member

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.

<Frame>
<img src="/images/demote_to_member.png" alt="Dropdown menu showing Demote to member option for an owner" />
</Frame>

### Leaving an organization as an owner

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.

<Frame>
<img src="/images/owner_leave_org.png" alt="Dropdown menu showing Leave organization option for an owner" />
</Frame>
1 change: 1 addition & 0 deletions docs/docs/license-key.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ docker run \
| [Audit logs](/docs/configuration/audit-logs) | 🛑 | ✅ |
| [Analytics](/docs/features/analytics) | 🛑 | ✅ |
| [MCP OAuth](/docs/features/mcp-server#oauth-2-0) | 🛑 | ✅ |
| [Multiple owners](/docs/configuration/auth/roles-and-permissions#managing-owners) | 🛑 | ✅ |


## Questions?
Expand Down
Binary file addeddocs/images/demote_to_member.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/managing_owners.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/owner_leave_org.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/promote_to_owner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
172 changes: 1 addition & 171 deletions packages/web/src/actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@ import { createTransport } from "nodemailer";
import { Octokit } from "octokit";
import { auth } from "./auth";
import { getOrgFromDomain } from "./data/org";
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
import InviteUserEmail from "./emails/inviteUserEmail";
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
Expand DownExpand Up@@ -1150,102 +1150,6 @@ export const getInviteInfo = async (inviteId: string) => sew(() =>
}
}));

export const transferOwnership = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const currentUserId = userId;

const failAuditCallback = async (error: string) => {
await auditService.createAudit({
action: "org.ownership_transfer_failed",
actor: {
id: currentUserId,
type: "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: error
}
})
}
if (newOwnerId === currentUserId) {
await failAuditCallback("User is already the owner of this org");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "You're already the owner of this org",
} satisfies ServiceError;
}

const newOwner = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
userId: newOwnerId,
orgId: org.id,
},
},
});

if (!newOwner) {
await failAuditCallback("The user you're trying to make the owner doesn't exist");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "The user you're trying to make the owner doesn't exist",
} satisfies ServiceError;
}

await prisma.$transaction([
prisma.userToOrg.update({
where: {
orgId_userId: {
userId: newOwnerId,
orgId: org.id,
},
},
data: {
role: "OWNER",
}
}),
prisma.userToOrg.update({
where: {
orgId_userId: {
userId: currentUserId,
orgId: org.id,
},
},
data: {
role: "MEMBER",
}
})
]);

await auditService.createAudit({
action: "org.ownership_transferred",
actor: {
id: currentUserId,
type: "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: `Ownership transferred from ${currentUserId} to ${newOwnerId}`
}
});

return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));

export const checkIfOrgDomainExists = async (domain: string): Promise<boolean | ServiceError> => sew(() =>
withAuth(async () => {
const org = await prisma.org.findFirst({
Expand All@@ -1257,80 +1161,6 @@ export const checkIfOrgDomainExists = async (domain: string): Promise<boolean |
return !!org;
}));

export const removeMemberFromOrg = async (memberId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const targetMember = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
orgId: org.id,
userId: memberId,
}
}
});

if (!targetMember) {
return notFound();
}

await prisma.$transaction(async (tx) => {
await tx.userToOrg.delete({
where: {
orgId_userId: {
orgId: org.id,
userId: memberId,
}
}
});

if (IS_BILLING_ENABLED) {
const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) {
throw result;
}
}
});

return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));

export const leaveOrg = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org, userRole }) => {
if (userRole === OrgRole.OWNER) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.OWNER_CANNOT_LEAVE_ORG,
message: "Organization owners cannot leave their own organization",
} satisfies ServiceError;
}

await prisma.$transaction(async (tx) => {
await tx.userToOrg.delete({
where: {
orgId_userId: {
orgId: org.id,
userId: userId,
}
}
});

if (IS_BILLING_ENABLED) {
const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) {
throw result;
}
}
});

return {
success: true,
}
})
));

export const getOrgMembers = async (domain: string) => sew(() =>
withAuth(async (userId) =>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- [EE] Added multi-owner support with promote/demote actions. [#988](https://github.com/sourcebot-dev/sourcebot/pull/988)

## [4.15.3] - 2026-03-10

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/configuration/audit-logs.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,8 +122,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `chat.shared_with_users` | `user` | `chat` |
| `chat.unshared_with_user` | `user` | `chat` |
| `chat.visibility_updated` | `user` | `chat` |
| `org.ownership_transfer_failed` | `user` | `org` |
| `org.ownership_transferred` | `user` | `org` |
| `user.created_ask_chat` | `user` | `org` |
| `user.creation_failed` | `user` | `user` |
| `user.delete` | `user` | `user` |
Expand All@@ -144,6 +142,8 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `user.read` | `user` | `user` |
| `user.signed_in` | `user` | `user` |
| `user.signed_out` | `user` | `user` |
| `org.member_promoted_to_owner` | `user` | `user` |
| `org.owner_demoted_to_member` | `user` | `user` |


## Response schema
Expand Down
8 changes: 4 additions & 4 deletions docs/docs/configuration/auth/access-settings.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,20 +17,20 @@ When accessing Sourcebot anonymously, a user's permissions are limited to that o

# Member Approval

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

### Configuration
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.
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.

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

### Managing Requests

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
until this request is approved by the owner.
until this request is approved by an owner.

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

## Invite link

Expand Down
42 changes: 38 additions & 4 deletions docs/docs/configuration/auth/roles-and-permissions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,46 @@ title: Roles and Permissions
sidebarTitle: Roles and permissions
---

<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>

Each member has a role which defines their permissions within an organization:

| Role | Permission |
| :--- | :--------- |
| `Owner` | Each organization has a single `Owner`. This user has full access rights, including: connection management, organization management, and inviting new members. |
| `Owner` | An organization can have one or more `Owner`s. Owners have full access rights, including: connection management, organization management, and inviting new members. |
| `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. |
| `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. |
| `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. |

## Managing owners

import LicenseKeyRequired from '/snippets/license-key-required.mdx'

<LicenseKeyRequired feature="Multiple owners" />

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**.

<Frame>
<img src="/images/managing_owners.png" alt="Members settings page showing team members and their roles" />
</Frame>

### Promoting a member to owner

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.

<Frame>
<img src="/images/promote_to_owner.png" alt="Dropdown menu showing Promote to owner option for a member" />
</Frame>

### Demoting an owner to member

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.

<Frame>
<img src="/images/demote_to_member.png" alt="Dropdown menu showing Demote to member option for an owner" />
</Frame>

### Leaving an organization as an owner

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.

<Frame>
<img src="/images/owner_leave_org.png" alt="Dropdown menu showing Leave organization option for an owner" />
</Frame>
1 change: 1 addition & 0 deletions docs/docs/license-key.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ docker run \
| [Audit logs](/docs/configuration/audit-logs) | 🛑 | ✅ |
| [Analytics](/docs/features/analytics) | 🛑 | ✅ |
| [MCP OAuth](/docs/features/mcp-server#oauth-2-0) | 🛑 | ✅ |
| [Multiple owners](/docs/configuration/auth/roles-and-permissions#managing-owners) | 🛑 | ✅ |


## Questions?
Expand Down
Binary file addeddocs/images/demote_to_member.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/managing_owners.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/owner_leave_org.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/promote_to_owner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
172 changes: 1 addition & 171 deletions packages/web/src/actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@ import { createTransport } from "nodemailer";
import { Octokit } from "octokit";
import { auth } from "./auth";
import { getOrgFromDomain } from "./data/org";
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
import InviteUserEmail from "./emails/inviteUserEmail";
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
Expand DownExpand Up@@ -1150,102 +1150,6 @@ export const getInviteInfo = async (inviteId: string) => sew(() =>
}
}));

export const transferOwnership = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const currentUserId = userId;

const failAuditCallback = async (error: string) => {
await auditService.createAudit({
action: "org.ownership_transfer_failed",
actor: {
id: currentUserId,
type: "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: error
}
})
}
if (newOwnerId === currentUserId) {
await failAuditCallback("User is already the owner of this org");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "You're already the owner of this org",
} satisfies ServiceError;
}

const newOwner = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
userId: newOwnerId,
orgId: org.id,
},
},
});

if (!newOwner) {
await failAuditCallback("The user you're trying to make the owner doesn't exist");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "The user you're trying to make the owner doesn't exist",
} satisfies ServiceError;
}

await prisma.$transaction([
prisma.userToOrg.update({
where: {
orgId_userId: {
userId: newOwnerId,
orgId: org.id,
},
},
data: {
role: "OWNER",
}
}),
prisma.userToOrg.update({
where: {
orgId_userId: {
userId: currentUserId,
orgId: org.id,
},
},
data: {
role: "MEMBER",
}
})
]);

await auditService.createAudit({
action: "org.ownership_transferred",
actor: {
id: currentUserId,
type: "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: `Ownership transferred from ${currentUserId} to ${newOwnerId}`
}
});

return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));

export const checkIfOrgDomainExists = async (domain: string): Promise<boolean | ServiceError> => sew(() =>
withAuth(async () => {
const org = await prisma.org.findFirst({
Expand All@@ -1257,80 +1161,6 @@ export const checkIfOrgDomainExists = async (domain: string): Promise<boolean |
return !!org;
}));

export const removeMemberFromOrg = async (memberId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const targetMember = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
orgId: org.id,
userId: memberId,
}
}
});

if (!targetMember) {
return notFound();
}

await prisma.$transaction(async (tx) => {
await tx.userToOrg.delete({
where: {
orgId_userId: {
orgId: org.id,
userId: memberId,
}
}
});

if (IS_BILLING_ENABLED) {
const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) {
throw result;
}
}
});

return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));

export const leaveOrg = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org, userRole }) => {
if (userRole === OrgRole.OWNER) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.OWNER_CANNOT_LEAVE_ORG,
message: "Organization owners cannot leave their own organization",
} satisfies ServiceError;
}

await prisma.$transaction(async (tx) => {
await tx.userToOrg.delete({
where: {
orgId_userId: {
orgId: org.id,
userId: userId,
}
}
});

if (IS_BILLING_ENABLED) {
const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) {
throw result;
}
}
});

return {
success: true,
}
})
));

export const getOrgMembers = async (domain: string) => sew(() =>
withAuth(async (userId) =>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- [EE] Added multi-owner support with promote/demote actions. [#988](https://github.com/sourcebot-dev/sourcebot/pull/988)

## [4.15.3] - 2026-03-10

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/configuration/audit-logs.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,8 +122,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `chat.shared_with_users` | `user` | `chat` |
| `chat.unshared_with_user` | `user` | `chat` |
| `chat.visibility_updated` | `user` | `chat` |
| `org.ownership_transfer_failed` | `user` | `org` |
| `org.ownership_transferred` | `user` | `org` |
| `user.created_ask_chat` | `user` | `org` |
| `user.creation_failed` | `user` | `user` |
| `user.delete` | `user` | `user` |
Expand All@@ -144,6 +142,8 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `user.read` | `user` | `user` |
| `user.signed_in` | `user` | `user` |
| `user.signed_out` | `user` | `user` |
| `org.member_promoted_to_owner` | `user` | `user` |
| `org.owner_demoted_to_member` | `user` | `user` |


## Response schema
Expand Down
8 changes: 4 additions & 4 deletions docs/docs/configuration/auth/access-settings.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,20 +17,20 @@ When accessing Sourcebot anonymously, a user's permissions are limited to that o

# Member Approval

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

### Configuration
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.
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.

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

### Managing Requests

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
until this request is approved by the owner.
until this request is approved by an owner.

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

## Invite link

Expand Down
42 changes: 38 additions & 4 deletions docs/docs/configuration/auth/roles-and-permissions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,46 @@ title: Roles and Permissions
sidebarTitle: Roles and permissions
---

<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>

Each member has a role which defines their permissions within an organization:

| Role | Permission |
| :--- | :--------- |
| `Owner` | Each organization has a single `Owner`. This user has full access rights, including: connection management, organization management, and inviting new members. |
| `Owner` | An organization can have one or more `Owner`s. Owners have full access rights, including: connection management, organization management, and inviting new members. |
| `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. |
| `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. |
| `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. |

## Managing owners

import LicenseKeyRequired from '/snippets/license-key-required.mdx'

<LicenseKeyRequired feature="Multiple owners" />

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**.

<Frame>
<img src="/images/managing_owners.png" alt="Members settings page showing team members and their roles" />
</Frame>

### Promoting a member to owner

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.

<Frame>
<img src="/images/promote_to_owner.png" alt="Dropdown menu showing Promote to owner option for a member" />
</Frame>

### Demoting an owner to member

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.

<Frame>
<img src="/images/demote_to_member.png" alt="Dropdown menu showing Demote to member option for an owner" />
</Frame>

### Leaving an organization as an owner

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.

<Frame>
<img src="/images/owner_leave_org.png" alt="Dropdown menu showing Leave organization option for an owner" />
</Frame>
1 change: 1 addition & 0 deletions docs/docs/license-key.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ docker run \
| [Audit logs](/docs/configuration/audit-logs) | 🛑 | ✅ |
| [Analytics](/docs/features/analytics) | 🛑 | ✅ |
| [MCP OAuth](/docs/features/mcp-server#oauth-2-0) | 🛑 | ✅ |
| [Multiple owners](/docs/configuration/auth/roles-and-permissions#managing-owners) | 🛑 | ✅ |


## Questions?
Expand Down
Binary file addeddocs/images/demote_to_member.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/managing_owners.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/owner_leave_org.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/promote_to_owner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
172 changes: 1 addition & 171 deletions packages/web/src/actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@ import { createTransport } from "nodemailer";
import { Octokit } from "octokit";
import { auth } from "./auth";
import { getOrgFromDomain } from "./data/org";
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
import InviteUserEmail from "./emails/inviteUserEmail";
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
Expand DownExpand Up@@ -1150,102 +1150,6 @@ export const getInviteInfo = async (inviteId: string) => sew(() =>
}
}));

export const transferOwnership = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const currentUserId = userId;

const failAuditCallback = async (error: string) => {
await auditService.createAudit({
action: "org.ownership_transfer_failed",
actor: {
id: currentUserId,
type: "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: error
}
})
}
if (newOwnerId === currentUserId) {
await failAuditCallback("User is already the owner of this org");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "You're already the owner of this org",
} satisfies ServiceError;
}

const newOwner = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
userId: newOwnerId,
orgId: org.id,
},
},
});

if (!newOwner) {
await failAuditCallback("The user you're trying to make the owner doesn't exist");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "The user you're trying to make the owner doesn't exist",
} satisfies ServiceError;
}

await prisma.$transaction([
prisma.userToOrg.update({
where: {
orgId_userId: {
userId: newOwnerId,
orgId: org.id,
},
},
data: {
role: "OWNER",
}
}),
prisma.userToOrg.update({
where: {
orgId_userId: {
userId: currentUserId,
orgId: org.id,
},
},
data: {
role: "MEMBER",
}
})
]);

await auditService.createAudit({
action: "org.ownership_transferred",
actor: {
id: currentUserId,
type: "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: `Ownership transferred from ${currentUserId} to ${newOwnerId}`
}
});

return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));

export const checkIfOrgDomainExists = async (domain: string): Promise<boolean | ServiceError> => sew(() =>
withAuth(async () => {
const org = await prisma.org.findFirst({
Expand All@@ -1257,80 +1161,6 @@ export const checkIfOrgDomainExists = async (domain: string): Promise<boolean |
return !!org;
}));

export const removeMemberFromOrg = async (memberId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const targetMember = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
orgId: org.id,
userId: memberId,
}
}
});

if (!targetMember) {
return notFound();
}

await prisma.$transaction(async (tx) => {
await tx.userToOrg.delete({
where: {
orgId_userId: {
orgId: org.id,
userId: memberId,
}
}
});

if (IS_BILLING_ENABLED) {
const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) {
throw result;
}
}
});

return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));

export const leaveOrg = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org, userRole }) => {
if (userRole === OrgRole.OWNER) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.OWNER_CANNOT_LEAVE_ORG,
message: "Organization owners cannot leave their own organization",
} satisfies ServiceError;
}

await prisma.$transaction(async (tx) => {
await tx.userToOrg.delete({
where: {
orgId_userId: {
orgId: org.id,
userId: userId,
}
}
});

if (IS_BILLING_ENABLED) {
const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) {
throw result;
}
}
});

return {
success: true,
}
})
));

export const getOrgMembers = async (domain: string) => sew(() =>
withAuth(async (userId) =>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- [EE] Added multi-owner support with promote/demote actions. [#988](https://github.com/sourcebot-dev/sourcebot/pull/988)

## [4.15.3] - 2026-03-10

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/configuration/audit-logs.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,8 +122,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `chat.shared_with_users` | `user` | `chat` |
| `chat.unshared_with_user` | `user` | `chat` |
| `chat.visibility_updated` | `user` | `chat` |
| `org.ownership_transfer_failed` | `user` | `org` |
| `org.ownership_transferred` | `user` | `org` |
| `user.created_ask_chat` | `user` | `org` |
| `user.creation_failed` | `user` | `user` |
| `user.delete` | `user` | `user` |
Expand All@@ -144,6 +142,8 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `user.read` | `user` | `user` |
| `user.signed_in` | `user` | `user` |
| `user.signed_out` | `user` | `user` |
| `org.member_promoted_to_owner` | `user` | `user` |
| `org.owner_demoted_to_member` | `user` | `user` |


## Response schema
Expand Down
8 changes: 4 additions & 4 deletions docs/docs/configuration/auth/access-settings.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,20 +17,20 @@ When accessing Sourcebot anonymously, a user's permissions are limited to that o

# Member Approval

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

### Configuration
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.
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.

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

### Managing Requests

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
until this request is approved by the owner.
until this request is approved by an owner.

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

## Invite link

Expand Down
42 changes: 38 additions & 4 deletions docs/docs/configuration/auth/roles-and-permissions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,46 @@ title: Roles and Permissions
sidebarTitle: Roles and permissions
---

<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>

Each member has a role which defines their permissions within an organization:

| Role | Permission |
| :--- | :--------- |
| `Owner` | Each organization has a single `Owner`. This user has full access rights, including: connection management, organization management, and inviting new members. |
| `Owner` | An organization can have one or more `Owner`s. Owners have full access rights, including: connection management, organization management, and inviting new members. |
| `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. |
| `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. |
| `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. |

## Managing owners

import LicenseKeyRequired from '/snippets/license-key-required.mdx'

<LicenseKeyRequired feature="Multiple owners" />

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**.

<Frame>
<img src="/images/managing_owners.png" alt="Members settings page showing team members and their roles" />
</Frame>

### Promoting a member to owner

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.

<Frame>
<img src="/images/promote_to_owner.png" alt="Dropdown menu showing Promote to owner option for a member" />
</Frame>

### Demoting an owner to member

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.

<Frame>
<img src="/images/demote_to_member.png" alt="Dropdown menu showing Demote to member option for an owner" />
</Frame>

### Leaving an organization as an owner

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.

<Frame>
<img src="/images/owner_leave_org.png" alt="Dropdown menu showing Leave organization option for an owner" />
</Frame>
1 change: 1 addition & 0 deletions docs/docs/license-key.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ docker run \
| [Audit logs](/docs/configuration/audit-logs) | 🛑 | ✅ |
| [Analytics](/docs/features/analytics) | 🛑 | ✅ |
| [MCP OAuth](/docs/features/mcp-server#oauth-2-0) | 🛑 | ✅ |
| [Multiple owners](/docs/configuration/auth/roles-and-permissions#managing-owners) | 🛑 | ✅ |


## Questions?
Expand Down
Binary file addeddocs/images/demote_to_member.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/managing_owners.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/owner_leave_org.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/promote_to_owner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
172 changes: 1 addition & 171 deletions packages/web/src/actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@ import { createTransport } from "nodemailer";
import { Octokit } from "octokit";
import { auth } from "./auth";
import { getOrgFromDomain } from "./data/org";
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
import InviteUserEmail from "./emails/inviteUserEmail";
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
Expand DownExpand Up@@ -1150,102 +1150,6 @@ export const getInviteInfo = async (inviteId: string) => sew(() =>
}
}));

export const transferOwnership = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const currentUserId = userId;

const failAuditCallback = async (error: string) => {
await auditService.createAudit({
action: "org.ownership_transfer_failed",
actor: {
id: currentUserId,
type: "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: error
}
})
}
if (newOwnerId === currentUserId) {
await failAuditCallback("User is already the owner of this org");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "You're already the owner of this org",
} satisfies ServiceError;
}

const newOwner = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
userId: newOwnerId,
orgId: org.id,
},
},
});

if (!newOwner) {
await failAuditCallback("The user you're trying to make the owner doesn't exist");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "The user you're trying to make the owner doesn't exist",
} satisfies ServiceError;
}

await prisma.$transaction([
prisma.userToOrg.update({
where: {
orgId_userId: {
userId: newOwnerId,
orgId: org.id,
},
},
data: {
role: "OWNER",
}
}),
prisma.userToOrg.update({
where: {
orgId_userId: {
userId: currentUserId,
orgId: org.id,
},
},
data: {
role: "MEMBER",
}
})
]);

await auditService.createAudit({
action: "org.ownership_transferred",
actor: {
id: currentUserId,
type: "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: `Ownership transferred from ${currentUserId} to ${newOwnerId}`
}
});

return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));

export const checkIfOrgDomainExists = async (domain: string): Promise<boolean | ServiceError> => sew(() =>
withAuth(async () => {
const org = await prisma.org.findFirst({
Expand All@@ -1257,80 +1161,6 @@ export const checkIfOrgDomainExists = async (domain: string): Promise<boolean |
return !!org;
}));

export const removeMemberFromOrg = async (memberId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const targetMember = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
orgId: org.id,
userId: memberId,
}
}
});

if (!targetMember) {
return notFound();
}

await prisma.$transaction(async (tx) => {
await tx.userToOrg.delete({
where: {
orgId_userId: {
orgId: org.id,
userId: memberId,
}
}
});

if (IS_BILLING_ENABLED) {
const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) {
throw result;
}
}
});

return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));

export const leaveOrg = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org, userRole }) => {
if (userRole === OrgRole.OWNER) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.OWNER_CANNOT_LEAVE_ORG,
message: "Organization owners cannot leave their own organization",
} satisfies ServiceError;
}

await prisma.$transaction(async (tx) => {
await tx.userToOrg.delete({
where: {
orgId_userId: {
orgId: org.id,
userId: userId,
}
}
});

if (IS_BILLING_ENABLED) {
const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) {
throw result;
}
}
});

return {
success: true,
}
})
));

export const getOrgMembers = async (domain: string) => sew(() =>
withAuth(async (userId) =>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- [EE] Added multi-owner support with promote/demote actions. [#988](https://github.com/sourcebot-dev/sourcebot/pull/988)

## [4.15.3] - 2026-03-10

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/configuration/audit-logs.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,8 +122,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `chat.shared_with_users` | `user` | `chat` |
| `chat.unshared_with_user` | `user` | `chat` |
| `chat.visibility_updated` | `user` | `chat` |
| `org.ownership_transfer_failed` | `user` | `org` |
| `org.ownership_transferred` | `user` | `org` |
| `user.created_ask_chat` | `user` | `org` |
| `user.creation_failed` | `user` | `user` |
| `user.delete` | `user` | `user` |
Expand All@@ -144,6 +142,8 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `user.read` | `user` | `user` |
| `user.signed_in` | `user` | `user` |
| `user.signed_out` | `user` | `user` |
| `org.member_promoted_to_owner` | `user` | `user` |
| `org.owner_demoted_to_member` | `user` | `user` |


## Response schema
Expand Down
8 changes: 4 additions & 4 deletions docs/docs/configuration/auth/access-settings.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,20 +17,20 @@ When accessing Sourcebot anonymously, a user's permissions are limited to that o

# Member Approval

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

### Configuration
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.
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.

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

### Managing Requests

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
until this request is approved by the owner.
until this request is approved by an owner.

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

## Invite link

Expand Down
42 changes: 38 additions & 4 deletions docs/docs/configuration/auth/roles-and-permissions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,46 @@ title: Roles and Permissions
sidebarTitle: Roles and permissions
---

<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>

Each member has a role which defines their permissions within an organization:

| Role | Permission |
| :--- | :--------- |
| `Owner` | Each organization has a single `Owner`. This user has full access rights, including: connection management, organization management, and inviting new members. |
| `Owner` | An organization can have one or more `Owner`s. Owners have full access rights, including: connection management, organization management, and inviting new members. |
| `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. |
| `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. |
| `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. |

## Managing owners

import LicenseKeyRequired from '/snippets/license-key-required.mdx'

<LicenseKeyRequired feature="Multiple owners" />

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**.

<Frame>
<img src="/images/managing_owners.png" alt="Members settings page showing team members and their roles" />
</Frame>

### Promoting a member to owner

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.

<Frame>
<img src="/images/promote_to_owner.png" alt="Dropdown menu showing Promote to owner option for a member" />
</Frame>

### Demoting an owner to member

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.

<Frame>
<img src="/images/demote_to_member.png" alt="Dropdown menu showing Demote to member option for an owner" />
</Frame>

### Leaving an organization as an owner

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.

<Frame>
<img src="/images/owner_leave_org.png" alt="Dropdown menu showing Leave organization option for an owner" />
</Frame>
1 change: 1 addition & 0 deletions docs/docs/license-key.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ docker run \
| [Audit logs](/docs/configuration/audit-logs) | 🛑 | ✅ |
| [Analytics](/docs/features/analytics) | 🛑 | ✅ |
| [MCP OAuth](/docs/features/mcp-server#oauth-2-0) | 🛑 | ✅ |
| [Multiple owners](/docs/configuration/auth/roles-and-permissions#managing-owners) | 🛑 | ✅ |


## Questions?
Expand Down
Binary file addeddocs/images/demote_to_member.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/managing_owners.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/owner_leave_org.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/promote_to_owner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
172 changes: 1 addition & 171 deletions packages/web/src/actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@ import { createTransport } from "nodemailer";
import { Octokit } from "octokit";
import { auth } from "./auth";
import { getOrgFromDomain } from "./data/org";
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
import InviteUserEmail from "./emails/inviteUserEmail";
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
Expand DownExpand Up@@ -1150,102 +1150,6 @@ export const getInviteInfo = async (inviteId: string) => sew(() =>
}
}));

export const transferOwnership = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const currentUserId = userId;

const failAuditCallback = async (error: string) => {
await auditService.createAudit({
action: "org.ownership_transfer_failed",
actor: {
id: currentUserId,
type: "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: error
}
})
}
if (newOwnerId === currentUserId) {
await failAuditCallback("User is already the owner of this org");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "You're already the owner of this org",
} satisfies ServiceError;
}

const newOwner = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
userId: newOwnerId,
orgId: org.id,
},
},
});

if (!newOwner) {
await failAuditCallback("The user you're trying to make the owner doesn't exist");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "The user you're trying to make the owner doesn't exist",
} satisfies ServiceError;
}

await prisma.$transaction([
prisma.userToOrg.update({
where: {
orgId_userId: {
userId: newOwnerId,
orgId: org.id,
},
},
data: {
role: "OWNER",
}
}),
prisma.userToOrg.update({
where: {
orgId_userId: {
userId: currentUserId,
orgId: org.id,
},
},
data: {
role: "MEMBER",
}
})
]);

await auditService.createAudit({
action: "org.ownership_transferred",
actor: {
id: currentUserId,
type: "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: `Ownership transferred from ${currentUserId} to ${newOwnerId}`
}
});

return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));

export const checkIfOrgDomainExists = async (domain: string): Promise<boolean | ServiceError> => sew(() =>
withAuth(async () => {
const org = await prisma.org.findFirst({
Expand All@@ -1257,80 +1161,6 @@ export const checkIfOrgDomainExists = async (domain: string): Promise<boolean |
return !!org;
}));

export const removeMemberFromOrg = async (memberId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const targetMember = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
orgId: org.id,
userId: memberId,
}
}
});

if (!targetMember) {
return notFound();
}

await prisma.$transaction(async (tx) => {
await tx.userToOrg.delete({
where: {
orgId_userId: {
orgId: org.id,
userId: memberId,
}
}
});

if (IS_BILLING_ENABLED) {
const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) {
throw result;
}
}
});

return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));

export const leaveOrg = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org, userRole }) => {
if (userRole === OrgRole.OWNER) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.OWNER_CANNOT_LEAVE_ORG,
message: "Organization owners cannot leave their own organization",
} satisfies ServiceError;
}

await prisma.$transaction(async (tx) => {
await tx.userToOrg.delete({
where: {
orgId_userId: {
orgId: org.id,
userId: userId,
}
}
});

if (IS_BILLING_ENABLED) {
const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) {
throw result;
}
}
});

return {
success: true,
}
})
));

export const getOrgMembers = async (domain: string) => sew(() =>
withAuth(async (userId) =>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- [EE] Added multi-owner support with promote/demote actions. [#988](https://github.com/sourcebot-dev/sourcebot/pull/988)

## [4.15.3] - 2026-03-10

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/configuration/audit-logs.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,8 +122,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `chat.shared_with_users` | `user` | `chat` |
| `chat.unshared_with_user` | `user` | `chat` |
| `chat.visibility_updated` | `user` | `chat` |
| `org.ownership_transfer_failed` | `user` | `org` |
| `org.ownership_transferred` | `user` | `org` |
| `user.created_ask_chat` | `user` | `org` |
| `user.creation_failed` | `user` | `user` |
| `user.delete` | `user` | `user` |
Expand All@@ -144,6 +142,8 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `user.read` | `user` | `user` |
| `user.signed_in` | `user` | `user` |
| `user.signed_out` | `user` | `user` |
| `org.member_promoted_to_owner` | `user` | `user` |
| `org.owner_demoted_to_member` | `user` | `user` |


## Response schema
Expand Down
8 changes: 4 additions & 4 deletions docs/docs/configuration/auth/access-settings.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,20 +17,20 @@ When accessing Sourcebot anonymously, a user's permissions are limited to that o

# Member Approval

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

### Configuration
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.
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.

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

### Managing Requests

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
until this request is approved by the owner.
until this request is approved by an owner.

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

## Invite link

Expand Down
42 changes: 38 additions & 4 deletions docs/docs/configuration/auth/roles-and-permissions.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,12 +3,46 @@ title: Roles and Permissions
sidebarTitle: Roles and permissions
---

<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>

Each member has a role which defines their permissions within an organization:

| Role | Permission |
| :--- | :--------- |
| `Owner` | Each organization has a single `Owner`. This user has full access rights, including: connection management, organization management, and inviting new members. |
| `Owner` | An organization can have one or more `Owner`s. Owners have full access rights, including: connection management, organization management, and inviting new members. |
| `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. |
| `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. |
| `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. |

## Managing owners

import LicenseKeyRequired from '/snippets/license-key-required.mdx'

<LicenseKeyRequired feature="Multiple owners" />

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**.

<Frame>
<img src="/images/managing_owners.png" alt="Members settings page showing team members and their roles" />
</Frame>

### Promoting a member to owner

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.

<Frame>
<img src="/images/promote_to_owner.png" alt="Dropdown menu showing Promote to owner option for a member" />
</Frame>

### Demoting an owner to member

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.

<Frame>
<img src="/images/demote_to_member.png" alt="Dropdown menu showing Demote to member option for an owner" />
</Frame>

### Leaving an organization as an owner

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.

<Frame>
<img src="/images/owner_leave_org.png" alt="Dropdown menu showing Leave organization option for an owner" />
</Frame>
1 change: 1 addition & 0 deletions docs/docs/license-key.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ docker run \
| [Audit logs](/docs/configuration/audit-logs) | 🛑 | ✅ |
| [Analytics](/docs/features/analytics) | 🛑 | ✅ |
| [MCP OAuth](/docs/features/mcp-server#oauth-2-0) | 🛑 | ✅ |
| [Multiple owners](/docs/configuration/auth/roles-and-permissions#managing-owners) | 🛑 | ✅ |


## Questions?
Expand Down
Binary file addeddocs/images/demote_to_member.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/managing_owners.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/owner_leave_org.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addeddocs/images/promote_to_owner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
172 changes: 1 addition & 171 deletions packages/web/src/actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@ import { createTransport } from "nodemailer";
import { Octokit } from "octokit";
import { auth } from "./auth";
import { getOrgFromDomain } from "./data/org";
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
import InviteUserEmail from "./emails/inviteUserEmail";
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
Expand DownExpand Up@@ -1150,102 +1150,6 @@ export const getInviteInfo = async (inviteId: string) => sew(() =>
}
}));

export const transferOwnership = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const currentUserId = userId;

const failAuditCallback = async (error: string) => {
await auditService.createAudit({
action: "org.ownership_transfer_failed",
actor: {
id: currentUserId,
type: "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: error
}
})
}
if (newOwnerId === currentUserId) {
await failAuditCallback("User is already the owner of this org");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "You're already the owner of this org",
} satisfies ServiceError;
}

const newOwner = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
userId: newOwnerId,
orgId: org.id,
},
},
});

if (!newOwner) {
await failAuditCallback("The user you're trying to make the owner doesn't exist");
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "The user you're trying to make the owner doesn't exist",
} satisfies ServiceError;
}

await prisma.$transaction([
prisma.userToOrg.update({
where: {
orgId_userId: {
userId: newOwnerId,
orgId: org.id,
},
},
data: {
role: "OWNER",
}
}),
prisma.userToOrg.update({
where: {
orgId_userId: {
userId: currentUserId,
orgId: org.id,
},
},
data: {
role: "MEMBER",
}
})
]);

await auditService.createAudit({
action: "org.ownership_transferred",
actor: {
id: currentUserId,
type: "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: `Ownership transferred from ${currentUserId} to ${newOwnerId}`
}
});

return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));

export const checkIfOrgDomainExists = async (domain: string): Promise<boolean | ServiceError> => sew(() =>
withAuth(async () => {
const org = await prisma.org.findFirst({
Expand All@@ -1257,80 +1161,6 @@ export const checkIfOrgDomainExists = async (domain: string): Promise<boolean |
return !!org;
}));

export const removeMemberFromOrg = async (memberId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const targetMember = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
orgId: org.id,
userId: memberId,
}
}
});

if (!targetMember) {
return notFound();
}

await prisma.$transaction(async (tx) => {
await tx.userToOrg.delete({
where: {
orgId_userId: {
orgId: org.id,
userId: memberId,
}
}
});

if (IS_BILLING_ENABLED) {
const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) {
throw result;
}
}
});

return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));

export const leaveOrg = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org, userRole }) => {
if (userRole === OrgRole.OWNER) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.OWNER_CANNOT_LEAVE_ORG,
message: "Organization owners cannot leave their own organization",
} satisfies ServiceError;
}

await prisma.$transaction(async (tx) => {
await tx.userToOrg.delete({
where: {
orgId_userId: {
orgId: org.id,
userId: userId,
}
}
});

if (IS_BILLING_ENABLED) {
const result = await decrementOrgSeatCount(org.id, tx);
if (isServiceError(result)) {
throw result;
}
}
});

return {
success: true,
}
})
));

export const getOrgMembers = async (domain: string) => sew(() =>
withAuth(async (userId) =>
Expand Down
Loading