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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [EE] Improved Ask Sourcebot prompt caching by splitting static and dynamic prompt sections and advancing cache breakpoints after every agent step instead of only after each message. [#1366](https://github.com/sourcebot-dev/sourcebot/pull/1366)
- Refactored Ask Sourcebot user message text extraction into a shared helper that robustly handles non-text message parts. [#1371](https://github.com/sourcebot-dev/sourcebot/pull/1371)
- Made the backend worker API address configurable via the `WORKER_API_URL` environment variable (default `http://localhost:3060`) instead of being hardcoded. [#1409](https://github.com/sourcebot-dev/sourcebot/pull/1409)
- [EE] Disabled `DELETE /api/ee/user` while SCIM provisioning is enabled, and switched it to an org-scoped membership removal (with last-owner protection) instead of a global account delete. [#1425](https://github.com/sourcebot-dev/sourcebot/pull/1425)
- [EE] Unified the `GET /api/ee/user` and `GET /api/ee/users` response shapes behind a shared mapper; the single-user endpoint is now scoped to org membership, and both include role, membership status, and last activity. [#1425](https://github.com/sourcebot-dev/sourcebot/pull/1425)

### Added
- Added per-step token cost tracking and estimated tool call token usage to Ask Sourcebot chat history. [#1353](https://github.com/sourcebot-dev/sourcebot/pull/1353)
Expand Down
83 changes: 31 additions & 52 deletions docs/api-reference/sourcebot-public.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1088,47 +1088,6 @@
}
},
"PublicEeUser": {
"type": "object",
"properties": {
"name": {
"type": "string",
"nullable": true
},
"email": {
"type": "string"
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"updatedAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
"name",
"email",
"createdAt",
"updatedAt"
]
},
"PublicEeDeleteUserResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"message": {
"type": "string"
}
},
"required": [
"success",
"message"
]
},
"PublicEeUserListItem": {
"type": "object",
"properties": {
"id": {
Expand Down Expand Up @@ -1157,6 +1116,10 @@
"type": "string",
"format": "date-time"
},
"updatedAt": {
"type": "string",
"format": "date-time"
},
"lastActivityAt": {
"type": "string",
"nullable": true,
Expand All @@ -1170,13 +1133,29 @@
"role",
"suspendedAt",
"createdAt",
"updatedAt",
"lastActivityAt"
]
},
"PublicEeDeleteUserResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"message": {
"type": "string"
}
},
"required": [
"success",
"message"
]
},
"PublicEeUsersResponse": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PublicEeUserListItem"
"$ref": "#/components/schemas/PublicEeUser"
}
},
"PublicEeAuditRecord": {
Expand Down Expand Up @@ -2291,7 +2270,7 @@
"Enterprise (EE)"
],
"summary": "Get a user",
"description": "Fetches profile details for a single organization member by `userId`. Only organization owners can access this endpoint.",
"description": "Fetches details for a single organization member by `userId`. Only organization owners can access this endpoint.",
"x-mint": {
"content": "<Note>\nThis API is only available with an active Sourcebot license. [More information](/docs/activating-a-subscription).\n</Note>"
},
Expand Down Expand Up @@ -2339,7 +2318,7 @@
}
},
"404": {
"description": "User not found.",
"description": "User is not a member of this organization.",
"content": {
"application/json": {
"schema": {
Expand All @@ -2365,26 +2344,26 @@
"tags": [
"Enterprise (EE)"
],
"summary": "Delete a user",
"description": "Permanently deletes a user and all associated records. Only organization owners can delete other users.",
"summary": "Remove a user from the organization",
"description": "Removes a user from the organization, revoking their access and their sessions. Only organization owners can access this endpoint.",
"x-mint": {
"content": "<Note>\nThis API is only available with an active Sourcebot license. [More information](/docs/activating-a-subscription).\n</Note>"
},
"parameters": [
{
"schema": {
"type": "string",
"description": "The ID of the user to delete."
"description": "The ID of the user to remove."
},
"required": true,
"description": "The ID of the user to delete.",
"description": "The ID of the user to remove.",
"name": "userId",
"in": "query"
}
],
"responses": {
"200": {
"description": "User deleted successfully.",
"description": "User removed successfully.",
"content": {
"application/json": {
"schema": {
Expand All @@ -2394,7 +2373,7 @@
}
},
"400": {
"description": "Missing userId parameter or attempting to delete own account.",
"description": "Missing userId parameter.",
"content": {
"application/json": {
"schema": {
Expand All @@ -2404,7 +2383,7 @@
}
},
"403": {
"description": "Insufficient permissions.",
"description": "Insufficient permissions, the last active owner cannot be removed, or SCIM provisioning is enabled (membership is managed through your identity provider).",
"content": {
"application/json": {
"schema": {
Expand All @@ -2414,7 +2393,7 @@
}
},
"404": {
"description": "User not found.",
"description": "User is not a member of this organization.",
"content": {
"application/json": {
"schema": {
Expand Down
98 changes: 34 additions & 64 deletions packages/web/src/app/api/(server)/ee/user/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
'use server';

import { createAudit } from "@/ee/features/audit/audit";
import { membershipManagedByIdpError } from "@/features/membership/errors";
import { removeMember } from "@/features/membership/membership.service";
import { isScimEnabled } from "@/features/scim/utils";
import { toPublicUser } from "../utils";
import { apiHandler } from "@/lib/apiHandler";
import { ErrorCode } from "@/lib/errorCodes";
import { serviceErrorResponse, missingQueryParam, notFound } from "@/lib/serviceError";
Expand Down Expand Up @@ -34,19 +38,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
const result = await withAuth(async ({ org, role, user, prisma }) => {
return withMinimumOrgRole(role, OrgRole.OWNER, async () => {
try {
const userData = await prisma.user.findUnique({
const membership = await prisma.userToOrg.findUnique({
where: {
id: userId,
orgId_userId: {
orgId: org.id,
userId,
},
},
select: {
name: true,
email: true,
createdAt: true,
updatedAt: true,
include: {
user: true,
},
});

if (!userData) {
if (!membership) {
return notFound('User not found');
}

Expand All @@ -63,7 +67,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
orgId: org.id,
});

return userData;
return toPublicUser(membership);
} catch (error) {
logger.error('Error fetching user info', { error, userId });
throw error;
Expand All @@ -86,66 +90,32 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
return serviceErrorResponse(missingQueryParam('userId'));
}

const result = await withAuth(async ({ org, role, user: currentUser, prisma }) => {
const result = await withAuth(async ({ org, role, user: currentUser }) => {
return withMinimumOrgRole(role, OrgRole.OWNER, async () => {
try {
if (currentUser.id === userId) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: 'Cannot delete your own user account',
};
}

const targetUser = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
});

if (!targetUser) {
return notFound('User not found');
}
// When SCIM is enabled the IdP is the source of truth for membership,
// so deleting users outside of it is disabled.
if (await isScimEnabled(org)) {
return membershipManagedByIdpError();
}

await createAudit({
action: "user.delete",
actor: {
id: currentUser.id,
type: "user"
},
target: {
id: userId,
type: "user"
},
orgId: org.id,
});
const error = await removeMember(org.id, userId, {
actor: { id: currentUser.id, type: "user" },
});

// Delete the user (cascade will handle all related records)
await prisma.user.delete({
where: {
id: userId,
},
});
if (isServiceError(error)) {
return error;
}

logger.info('User deleted successfully', {
deletedUserId: userId,
deletedByUserId: currentUser.id,
orgId: org.id
});
logger.info('User deleted successfully', {
deletedUserId: userId,
deletedByUserId: currentUser.id,
orgId: org.id,
});

return {
success: true,
message: 'User deleted successfully'
};
} catch (error) {
logger.error('Error deleting user', { error, userId });
throw error;
}
return {
success: true,
message: 'User deleted successfully',
};
});
});

Expand Down
33 changes: 4 additions & 29 deletions packages/web/src/app/api/(server)/ee/users/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use server';

import { createAudit } from "@/ee/features/audit/audit";
import { toPublicUser } from "../utils";
import { apiHandler } from "@/lib/apiHandler";
import { serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
Expand Down Expand Up @@ -35,33 +36,7 @@ export const GET = apiHandler(async () => {
},
});

const usersWithActivity = await Promise.all(
memberships.map(async (membership) => {
const lastActivity = await prisma.audit.findFirst({
where: {
actorId: membership.user.id,
actorType: 'user',
orgId: org.id,
},
orderBy: {
timestamp: 'desc',
},
select: {
timestamp: true,
},
});

return {
id: membership.user.id,
name: membership.user.name,
email: membership.user.email,
role: membership.role,
suspendedAt: membership.suspendedAt,
createdAt: membership.user.createdAt,
lastActivityAt: lastActivity?.timestamp ?? null,
};
})
);
const users = memberships.map((membership) => toPublicUser(membership));

await createAudit({
action: "user.list",
Expand All @@ -76,8 +51,8 @@ export const GET = apiHandler(async () => {
orgId: org.id
});

logger.info('Fetched users list', { count: usersWithActivity.length });
return usersWithActivity;
logger.info('Fetched users list', { count: users.length });
return users;
} catch (error) {
logger.error('Error fetching users', { error });
throw error;
Expand Down
14 changes: 14 additions & 0 deletions packages/web/src/app/api/(server)/ee/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Prisma } from "@sourcebot/db";

export type MembershipWithUser = Prisma.UserToOrgGetPayload<{ include: { user: true } }>;

export const toPublicUser = (membership: MembershipWithUser) => ({
id: membership.user.id,
name: membership.user.name,
email: membership.user.email,
role: membership.role,
suspendedAt: membership.suspendedAt,
createdAt: membership.user.createdAt,
updatedAt: membership.user.updatedAt,
lastActivityAt: membership.lastActiveAt,
});
Loading
Loading