diff --git a/CHANGELOG.md b/CHANGELOG.md index e336b863a..59264a06f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added support for GitHub Apps for service auth. [#570](https://github.com/sourcebot-dev/sourcebot/pull/570) - Added prometheus metrics for repo index manager. [#571](https://github.com/sourcebot-dev/sourcebot/pull/571) - Added experimental environment variable to disable API key creation for non-admin users. [#577](https://github.com/sourcebot-dev/sourcebot/pull/577) +- [Experimental][Sourcebot EE] Added REST API to get users and delete a user. [#578](https://github.com/sourcebot-dev/sourcebot/pull/578) ### Fixed - Fixed "dubious ownership" errors when cloning / fetching repos. [#553](https://github.com/sourcebot-dev/sourcebot/pull/553) diff --git a/LICENSE.md b/LICENSE.md index 315bde810..1a9a34693 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -2,7 +2,7 @@ Copyright (c) 2025 Taqla Inc. Portions of this software are licensed as follows: -- All content that resides under the "ee/", "packages/web/src/ee/", "packages/backend/src/ee/", and "packages/shared/src/ee/" directories of this repository, if these directories exist, is licensed under the license defined in "ee/LICENSE". +- All content located within any folder or subfolder named “ee” in this repository is licensed under the terms specified in “ee/LICENSE”, - All third party components incorporated into the Sourcebot Software are licensed under the original license provided by the owner of the applicable component. - Content outside of the above mentioned directories or restrictions above is available under the "Functional Source License" as defined below. diff --git a/packages/web/src/app/api/(server)/ee/user/route.ts b/packages/web/src/app/api/(server)/ee/user/route.ts new file mode 100644 index 000000000..e308924b6 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/user/route.ts @@ -0,0 +1,93 @@ +'use server'; + +import { withAuthV2, withMinimumOrgRole } from "@/withAuthV2"; +import { OrgRole } from "@sourcebot/db"; +import { isServiceError } from "@/lib/utils"; +import { serviceErrorResponse, missingQueryParam, notFound } from "@/lib/serviceError"; +import { createLogger } from "@sourcebot/logger"; +import { NextRequest } from "next/server"; +import { StatusCodes } from "http-status-codes"; +import { ErrorCode } from "@/lib/errorCodes"; +import { getAuditService } from "@/ee/features/audit/factory"; + +const logger = createLogger('ee-user-api'); +const auditService = getAuditService(); + +export const DELETE = async (request: NextRequest) => { + const url = new URL(request.url); + const userId = url.searchParams.get('userId'); + + if (!userId) { + return serviceErrorResponse(missingQueryParam('userId')); + } + + const result = await withAuthV2(async ({ org, role, user: currentUser, prisma }) => { + 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'); + } + + await auditService.createAudit({ + action: "user.delete", + actor: { + id: currentUser.id, + type: "user" + }, + target: { + id: userId, + type: "user" + }, + orgId: org.id, + }); + + // Delete the user (cascade will handle all related records) + await prisma.user.delete({ + where: { + id: userId, + }, + }); + + 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; + } + }); + }); + + if (isServiceError(result)) { + return serviceErrorResponse(result); + } + + return Response.json(result, { status: StatusCodes.OK }); +}; + diff --git a/packages/web/src/app/api/(server)/ee/users/route.ts b/packages/web/src/app/api/(server)/ee/users/route.ts new file mode 100644 index 000000000..cc37c4199 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/users/route.ts @@ -0,0 +1,81 @@ +'use server'; + +import { withAuthV2, withMinimumOrgRole } from "@/withAuthV2"; +import { OrgRole } from "@sourcebot/db"; +import { isServiceError } from "@/lib/utils"; +import { serviceErrorResponse } from "@/lib/serviceError"; +import { createLogger } from "@sourcebot/logger"; +import { getAuditService } from "@/ee/features/audit/factory"; + +const logger = createLogger('ee-users-api'); +const auditService = getAuditService(); + +export const GET = async () => { + const result = await withAuthV2(async ({ prisma, org, role, user }) => { + return withMinimumOrgRole(role, OrgRole.OWNER, async () => { + try { + const memberships = await prisma.userToOrg.findMany({ + where: { + orgId: org.id, + }, + include: { + user: true, + }, + }); + + 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, + createdAt: membership.user.createdAt, + lastActivityAt: lastActivity?.timestamp ?? null, + }; + }) + ); + + await auditService.createAudit({ + action: "user.list", + actor: { + id: user.id, + type: "user" + }, + target: { + id: org.id.toString(), + type: "org" + }, + orgId: org.id + }); + + logger.info('Fetched users list', { count: usersWithActivity.length }); + return usersWithActivity; + } catch (error) { + logger.error('Error fetching users', { error }); + throw error; + } + }); + }); + + if (isServiceError(result)) { + return serviceErrorResponse(result); + } + + return Response.json(result); +}; +