diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index fdf19438..c4ee6c77 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -24,6 +24,7 @@ import { EnvSchema } from '@/common/env/env.schema' import { IntegrationModule } from '@/integration/integration.module' import { FeedbackModule } from '@/feedback/feedback.module' import { CacheModule } from '@/cache/cache.module' +import { WorkspaceMembershipModule } from '@/workspace-membership/workspace-membership.module' @Module({ controllers: [AppController], @@ -55,7 +56,8 @@ import { CacheModule } from '@/cache/cache.module' ProviderModule, IntegrationModule, FeedbackModule, - CacheModule + CacheModule, + WorkspaceMembershipModule ], providers: [ { diff --git a/apps/api/src/auth/auth.types.ts b/apps/api/src/auth/auth.types.ts index 74e8350d..c6d5e353 100644 --- a/apps/api/src/auth/auth.types.ts +++ b/apps/api/src/auth/auth.types.ts @@ -1,10 +1,12 @@ -import { Authority, User } from '@prisma/client' +import { UserWithWorkspace } from '@/user/user.types' +import { Authority, User, Workspace } from '@prisma/client' -export type UserAuthenticatedResponse = User & { +export interface UserAuthenticatedResponse extends UserWithWorkspace { token: string } export type AuthenticatedUserContext = User & { isAuthViaApiKey?: boolean apiKeyAuthorities?: Set + defaultWorkspace: Workspace } diff --git a/apps/api/src/auth/guard/auth/auth.guard.ts b/apps/api/src/auth/guard/auth/auth.guard.ts index 0b470c9d..f864a49d 100644 --- a/apps/api/src/auth/guard/auth/auth.guard.ts +++ b/apps/api/src/auth/guard/auth/auth.guard.ts @@ -14,6 +14,7 @@ import { AuthenticatedUserContext } from '../../auth.types' import { EnvSchema } from '@/common/env/env.schema' import { CacheService } from '@/cache/cache.service' import { toSHA256 } from '@/common/cryptography' +import { getUserByEmailOrId } from '@/common/user' const X_E2E_USER_EMAIL = 'x-e2e-user-email' const X_KEYSHADE_TOKEN = 'x-keyshade-token' @@ -75,11 +76,7 @@ export class AuthGuard implements CanActivate { throw new ForbiddenException() } - user = await this.prisma.user.findUnique({ - where: { - email - } - }) + user = await getUserByEmailOrId(email, this.prisma) } else { const request = context.switchToHttp().getRequest() @@ -102,7 +99,17 @@ export class AuthGuard implements CanActivate { throw new ForbiddenException('Invalid API key') } - user = apiKey.user + const defaultWorkspace = await this.prisma.workspace.findFirst({ + where: { + ownerId: apiKey.userId, + isDefault: true + } + }) + + user = { + ...apiKey.user, + defaultWorkspace + } user.isAuthViaApiKey = true user.apiKeyAuthorities = new Set(apiKey.authorities) } else if (authType === 'JWT') { @@ -118,11 +125,7 @@ export class AuthGuard implements CanActivate { const cachedUser = await this.cache.getUser(payload['id']) if (cachedUser) user = cachedUser else { - user = await this.prisma.user.findUnique({ - where: { - id: payload['id'] - } - }) + user = await getUserByEmailOrId(payload['id'], this.prisma) } } catch { throw new ForbiddenException() diff --git a/apps/api/src/auth/service/auth.service.ts b/apps/api/src/auth/service/auth.service.ts index f680dfb3..7363154b 100644 --- a/apps/api/src/auth/service/auth.service.ts +++ b/apps/api/src/auth/service/auth.service.ts @@ -15,7 +15,8 @@ import { PrismaService } from '@/prisma/prisma.service' import { AuthProvider } from '@prisma/client' import { CacheService } from '@/cache/cache.service' import { generateOtp } from '@/common/util' -import { createUser } from '@/common/user' +import { createUser, getUserByEmailOrId } from '@/common/user' +import { UserWithWorkspace } from '@/user/user.types' @Injectable() export class AuthService { @@ -62,7 +63,7 @@ export class AuthService { email: string, otp: string ): Promise { - const user = await this.findUserByEmail(email) + const user = await getUserByEmailOrId(email, this.prisma) if (!user) { this.logger.error(`User not found: ${email}`) throw new NotFoundException('User not found') @@ -175,7 +176,11 @@ export class AuthService { name?: string, profilePictureUrl?: string ) { - let user = await this.findUserByEmail(email) + let user: UserWithWorkspace | null + + try { + user = await getUserByEmailOrId(email, this.prisma) + } catch (ignored) {} // We need to create the user if it doesn't exist yet if (!user) { @@ -204,12 +209,4 @@ export class AuthService { private async generateToken(id: string) { return await this.jwt.signAsync({ id }) } - - private async findUserByEmail(email: string) { - return await this.prisma.user.findUnique({ - where: { - email - } - }) - } } diff --git a/apps/api/src/cache/cache.service.ts b/apps/api/src/cache/cache.service.ts index ba61176a..899efbfb 100644 --- a/apps/api/src/cache/cache.service.ts +++ b/apps/api/src/cache/cache.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common' import { RedisClientType } from 'redis' -import { User } from '@prisma/client' import { REDIS_CLIENT } from '@/provider/redis.provider' +import { UserWithWorkspace } from '@/user/user.types' @Injectable() export class CacheService implements OnModuleDestroy { @@ -15,7 +15,10 @@ export class CacheService implements OnModuleDestroy { return `${CacheService.USER_PREFIX}${userId}` } - async setUser(user: User, expirationInSeconds?: number): Promise { + async setUser( + user: UserWithWorkspace, + expirationInSeconds?: number + ): Promise { const key = this.getUserKey(user.id) const userJson = JSON.stringify(user) if (expirationInSeconds) { @@ -25,11 +28,11 @@ export class CacheService implements OnModuleDestroy { } } - async getUser(userId: string): Promise { + async getUser(userId: string): Promise { const key = this.getUserKey(userId) const userData = await this.redisClient.publisher.get(key) if (userData) { - return JSON.parse(userData) as User + return JSON.parse(userData) as UserWithWorkspace } return null } diff --git a/apps/api/src/common/user.ts b/apps/api/src/common/user.ts index 15a6125f..9dec9dbc 100644 --- a/apps/api/src/common/user.ts +++ b/apps/api/src/common/user.ts @@ -1,8 +1,9 @@ -import { AuthProvider, User, Workspace } from '@prisma/client' +import { AuthProvider, User } from '@prisma/client' import { PrismaService } from '@/prisma/prisma.service' import { CreateUserDto } from '@/user/dto/create.user/create.user' import { Logger, NotFoundException } from '@nestjs/common' import { createWorkspace } from './workspace' +import { UserWithWorkspace } from '@/user/user.types' /** * Creates a new user and optionally creates a default workspace for them. @@ -11,14 +12,15 @@ import { createWorkspace } from './workspace' * @returns The created user and, if the user is not an admin, a default workspace. */ export async function createUser( - dto: Partial & { authProvider: AuthProvider }, + dto: Partial & { authProvider: AuthProvider; id?: User['id'] }, prisma: PrismaService -): Promise { +): Promise { const logger = new Logger('createUser') // Create the user const user = await prisma.user.create({ data: { + id: dto.id, email: dto.email, name: dto.name, profilePictureUrl: dto.profilePictureUrl, @@ -31,7 +33,10 @@ export async function createUser( if (user.isAdmin) { logger.log(`Created admin user ${user.id}`) - return user + return { + ...user, + defaultWorkspace: null + } } // Create the user's default workspace @@ -51,26 +56,41 @@ export async function createUser( } /** - * Finds a user by their email address. - * - * @param email The email address to search for. - * @param prisma The Prisma client instance. - * @returns The user with the given email address, or null if no user is found. - * @throws NotFoundException if no user is found with the given email address. + * Finds a user by their email or ID. + * @param input The email or ID of the user to find. + * @param prisma The Prisma client to use for the database operation. + * @throws {NotFoundException} If the user is not found. + * @returns The user with their default workspace. */ -export async function getUserByEmail( - email: User['email'], +export async function getUserByEmailOrId( + input: User['email'] | User['id'], prisma: PrismaService -): Promise { - const user = await prisma.user.findUnique({ +): Promise { + const user = + (await prisma.user.findUnique({ + where: { + email: input + } + })) ?? + (await prisma.user.findUnique({ + where: { + id: input + } + })) + + if (!user) { + throw new NotFoundException(`User ${input} not found`) + } + + const defaultWorkspace = await prisma.workspace.findFirst({ where: { - email + ownerId: user.id, + isDefault: true } }) - if (!user) { - throw new NotFoundException(`User ${email} not found`) + return { + ...user, + defaultWorkspace } - - return user } diff --git a/apps/api/src/common/util.ts b/apps/api/src/common/util.ts index a92200fb..6ec09439 100644 --- a/apps/api/src/common/util.ts +++ b/apps/api/src/common/util.ts @@ -1,4 +1,5 @@ import { UserAuthenticatedResponse } from '@/auth/auth.types' +import { UserWithWorkspace } from '@/user/user.types' import { Otp, PrismaClient, User } from '@prisma/client' import { Response } from 'express' @@ -26,7 +27,7 @@ export const limitMaxItemsPerPage = ( export const setCookie = ( response: Response, data: UserAuthenticatedResponse -): User => { +): UserWithWorkspace => { const { token, ...user } = data response.cookie('token', `Bearer ${token}`, { domain: process.env.DOMAIN ?? 'localhost', diff --git a/apps/api/src/decorators/user.decorator.ts b/apps/api/src/decorators/user.decorator.ts index 0f122a72..4c397a5b 100644 --- a/apps/api/src/decorators/user.decorator.ts +++ b/apps/api/src/decorators/user.decorator.ts @@ -1,10 +1,10 @@ +import { UserWithWorkspace } from '@/user/user.types' import { createParamDecorator, ExecutionContext } from '@nestjs/common' -import { User as DBUser } from '@prisma/client' export const CurrentUser = createParamDecorator< unknown, ExecutionContext, - DBUser + UserWithWorkspace >((_: unknown, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest() return request.user diff --git a/apps/api/src/user/controller/user.controller.ts b/apps/api/src/user/controller/user.controller.ts index 084aad18..1255fbd9 100644 --- a/apps/api/src/user/controller/user.controller.ts +++ b/apps/api/src/user/controller/user.controller.ts @@ -12,13 +12,14 @@ import { } from '@nestjs/common' import { UserService } from '../service/user.service' import { CurrentUser } from '@/decorators/user.decorator' -import { Authority, User } from '@prisma/client' +import { Authority } from '@prisma/client' import { UpdateUserDto } from '../dto/update.user/update.user' import { AdminGuard } from '@/auth/guard/admin/admin.guard' import { CreateUserDto } from '../dto/create.user/create.user' import { BypassOnboarding } from '@/decorators/bypass-onboarding.decorator' import { RequiredApiKeyAuthorities } from '@/decorators/required-api-key-authorities.decorator' import { ForbidApiKey } from '@/decorators/forbid-api-key.decorator' +import { UserWithWorkspace } from '../user.types' @Controller('user') export class UserController { @@ -27,21 +28,24 @@ export class UserController { @Get() @BypassOnboarding() @RequiredApiKeyAuthorities(Authority.READ_SELF) - async getCurrentUser(@CurrentUser() user: User) { + async getCurrentUser(@CurrentUser() user: UserWithWorkspace) { return this.userService.getSelf(user) } @Put() @BypassOnboarding() @RequiredApiKeyAuthorities(Authority.UPDATE_SELF) - async updateSelf(@CurrentUser() user: User, @Body() dto: UpdateUserDto) { + async updateSelf( + @CurrentUser() user: UserWithWorkspace, + @Body() dto: UpdateUserDto + ) { return await this.userService.updateSelf(user, dto) } @Delete() @HttpCode(204) @ForbidApiKey() - async deleteSelf(@CurrentUser() user: User) { + async deleteSelf(@CurrentUser() user: UserWithWorkspace) { await this.userService.deleteSelf(user) } @@ -87,14 +91,14 @@ export class UserController { @Post('validate-email-change-otp') async validateEmailChangeOtp( - @CurrentUser() user: User, + @CurrentUser() user: UserWithWorkspace, @Query('otp') otp: string ) { return await this.userService.validateEmailChangeOtp(user, otp.trim()) } @Post('resend-email-change-otp') - async resendEmailChangeOtp(@CurrentUser() user: User) { + async resendEmailChangeOtp(@CurrentUser() user: UserWithWorkspace) { return await this.userService.resendEmailChangeOtp(user) } } diff --git a/apps/api/src/user/service/user.service.ts b/apps/api/src/user/service/user.service.ts index ac4247cd..eb0cb0a8 100644 --- a/apps/api/src/user/service/user.service.ts +++ b/apps/api/src/user/service/user.service.ts @@ -14,6 +14,7 @@ import { EnvSchema } from '@/common/env/env.schema' import { generateOtp, limitMaxItemsPerPage } from '@/common/util' import { createUser } from '@/common/user' import { CacheService } from '@/cache/cache.service' +import { UserWithWorkspace } from '../user.types' @Injectable() export class UserService { @@ -30,7 +31,7 @@ export class UserService { await this.createDummyUser() } - async getSelf(user: User) { + async getSelf(user: UserWithWorkspace) { const defaultWorkspace: Workspace | null = await this.prisma.workspace.findFirst({ where: { @@ -42,7 +43,7 @@ export class UserService { return { ...user, defaultWorkspace } } - async updateSelf(user: User, dto: UpdateUserDto) { + async updateSelf(user: UserWithWorkspace, dto: UpdateUserDto) { const data = { name: dto?.name, profilePictureUrl: dto?.profilePictureUrl, @@ -84,7 +85,10 @@ export class UserService { }, data }) - await this.cache.setUser(updatedUser) + await this.cache.setUser({ + ...updatedUser, + defaultWorkspace: user.defaultWorkspace + }) this.log.log(`Updated user ${user.id} with data ${dto}`) @@ -133,7 +137,10 @@ export class UserService { }) } - async validateEmailChangeOtp(user: User, otpCode: string): Promise { + async validateEmailChangeOtp( + user: UserWithWorkspace, + otpCode: string + ): Promise { const otp = await this.prisma.otp.findUnique({ where: { userId: user.id, @@ -186,7 +193,7 @@ export class UserService { return results[2] } - async resendEmailChangeOtp(user: User) { + async resendEmailChangeOtp(user: UserWithWorkspace) { const oldOtp = await this.prisma.otp.findUnique({ where: { userId: user.id @@ -248,7 +255,7 @@ export class UserService { }) } - async deleteSelf(user: User) { + async deleteSelf(user: UserWithWorkspace) { await this.deleteUserById(user.id) } diff --git a/apps/api/src/user/user.e2e.spec.ts b/apps/api/src/user/user.e2e.spec.ts index 80dba297..2beb48a5 100644 --- a/apps/api/src/user/user.e2e.spec.ts +++ b/apps/api/src/user/user.e2e.spec.ts @@ -153,7 +153,7 @@ describe('User Controller Tests', () => { profilePictureUrl: null }) - expect(createAdminUserResponse.defaultWorkspace).toBeUndefined() + expect(createAdminUserResponse.defaultWorkspace).toBeNull() const workspace = await prisma.workspace.findFirst({ where: { diff --git a/apps/api/src/user/user.types.ts b/apps/api/src/user/user.types.ts new file mode 100644 index 00000000..a0bc4822 --- /dev/null +++ b/apps/api/src/user/user.types.ts @@ -0,0 +1,5 @@ +import { User, Workspace } from '@prisma/client' + +export interface UserWithWorkspace extends User { + defaultWorkspace: Workspace +} diff --git a/apps/api/src/workspace-membership/service/workspace-membership.service.ts b/apps/api/src/workspace-membership/service/workspace-membership.service.ts index 2d574b75..f2153859 100644 --- a/apps/api/src/workspace-membership/service/workspace-membership.service.ts +++ b/apps/api/src/workspace-membership/service/workspace-membership.service.ts @@ -1,6 +1,6 @@ import { AuthorityCheckerService } from '@/common/authority-checker.service' import { paginate } from '@/common/paginate' -import { getUserByEmail } from '@/common/user' +import { createUser, getUserByEmailOrId } from '@/common/user' import { IMailService, MAIL_SERVICE } from '@/mail/services/interface.service' import { PrismaService } from '@/prisma/prisma.service' import { @@ -15,6 +15,7 @@ import { import { JwtService } from '@nestjs/jwt' import { Authority, + AuthProvider, EventSource, EventType, User, @@ -63,7 +64,7 @@ export class WorkspaceMembershipService { prisma: this.prisma }) - const otherUser = await getUserByEmail(otherUserEmail, this.prisma) + const otherUser = await getUserByEmailOrId(otherUserEmail, this.prisma) if (otherUser.id === user.id) { throw new BadRequestException( @@ -91,6 +92,13 @@ export class WorkspaceMembershipService { ) } + // Check if the user has accepted the invitation + if (!workspaceMembership.invitationAccepted) { + throw new BadRequestException( + `${otherUser.email} has not accepted the invitation to workspace ${workspace.name} (${workspace.slug})` + ) + } + const currentUserMembership = await this.getWorkspaceMembership( workspace.id, user.id @@ -322,7 +330,7 @@ export class WorkspaceMembershipService { otherUserEmail: User['email'], roleSlugs: WorkspaceRole['slug'][] ): Promise { - const otherUser = await getUserByEmail(otherUserEmail, this.prisma) + const otherUser = await getUserByEmailOrId(otherUserEmail, this.prisma) const workspace = await this.authorityCheckerService.checkAuthorityOverWorkspace({ @@ -604,7 +612,7 @@ export class WorkspaceMembershipService { workspaceSlug: Workspace['slug'], inviteeEmail: User['email'] ): Promise { - const inviteeUser = await getUserByEmail(inviteeEmail, this.prisma) + const inviteeUser = await getUserByEmailOrId(inviteeEmail, this.prisma) const workspace = await this.authorityCheckerService.checkAuthorityOverWorkspace({ @@ -755,7 +763,13 @@ export class WorkspaceMembershipService { workspaceSlug: Workspace['slug'], otherUserEmail: User['email'] ): Promise { - const otherUser = await getUserByEmail(otherUserEmail, this.prisma) + let otherUser: User | null = null + + try { + otherUser = await getUserByEmailOrId(otherUserEmail, this.prisma) + } catch (e) { + return false + } const workspace = await this.authorityCheckerService.checkAuthorityOverWorkspace({ @@ -827,13 +841,13 @@ export class WorkspaceMembershipService { ) { this.log.warn( `User ${ - memberUser.name ?? 'NO_NAME_YET' + memberUser.name || memberUser.email } (${userId}) is already a member of workspace ${workspace.name} (${ workspace.slug }). Skipping.` ) throw new ConflictException( - `User ${memberUser.name} (${userId}) is already a member of workspace ${workspace.name} (${workspace.slug})` + `User ${memberUser.name || memberUser.email} (${userId}) is already a member of workspace ${workspace.name} (${workspace.slug})` ) } @@ -876,7 +890,7 @@ export class WorkspaceMembershipService { this.mailService.workspaceInvitationMailForUsers( member.email, workspace.name, - `${process.env.WORKSPACE_FRONTEND_URL}/workspace/${workspace.id}/join`, + `${process.env.WORKSPACE_FRONTEND_URL}/workspace/${workspace.slug}/join`, currentUser.name, true ) @@ -885,15 +899,17 @@ export class WorkspaceMembershipService { `Sent workspace invitation mail to registered user ${memberUser}` ) } else { - const createMember = this.prisma.user.create({ - data: { + // Create the user + await createUser( + { id: userId, email: member.email, - isOnboardingFinished: false - } - }) + authProvider: AuthProvider.EMAIL_OTP + }, + this.prisma + ) - await this.prisma.$transaction([createMember, createMembership]) + await this.prisma.$transaction([createMembership]) this.log.debug(`Created non-registered user ${memberUser}`) diff --git a/apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts b/apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts index deecaca3..9a28da49 100644 --- a/apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts +++ b/apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts @@ -27,6 +27,7 @@ import { import { Test } from '@nestjs/testing' import { Authority, + AuthProvider, EventSeverity, EventSource, EventTriggerer, @@ -237,6 +238,30 @@ describe('Workspace Membership Controller Tests', () => { }) }) + it('should not be able to transfer ownership to a member who did not accept the invitation', async () => { + const newWorkspace = await workspaceService.createWorkspace(user1, { + name: 'Workspace 2', + description: 'Workspace 2 description' + }) + + // Create membership + await createMembership(memberRole.id, user3.id, newWorkspace.id, prisma) + + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace-membership/${newWorkspace.slug}/transfer-ownership/${user3.email}` + }) + + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `${user3.email} has not accepted the invitation to workspace ${newWorkspace.name} (${newWorkspace.slug})` + }) + }) + it('should be able to transfer the ownership of the workspace', async () => { const newWorkspace = await workspaceService.createWorkspace(user1, { name: 'Workspace 2', @@ -246,6 +271,19 @@ describe('Workspace Membership Controller Tests', () => { // Create membership await createMembership(memberRole.id, user2.id, newWorkspace.id, prisma) + // Set the membership accepted status to true + await prisma.workspaceMember.update({ + where: { + workspaceId_userId: { + userId: user2.id, + workspaceId: newWorkspace.id + } + }, + data: { + invitationAccepted: true + } + }) + const response = await app.inject({ method: 'PUT', headers: { @@ -473,6 +511,8 @@ describe('Workspace Membership Controller Tests', () => { }) expect(user).toBeDefined() + expect(user.email).toBe('joy@keyshade.xyz') + expect(user.authProvider).toBe(AuthProvider.EMAIL_OTP) }) }) diff --git a/apps/api/src/workspace/dto/create.workspace/create.workspace.ts b/apps/api/src/workspace/dto/create.workspace/create.workspace.ts index 032206bb..71f81dba 100644 --- a/apps/api/src/workspace/dto/create.workspace/create.workspace.ts +++ b/apps/api/src/workspace/dto/create.workspace/create.workspace.ts @@ -1,4 +1,3 @@ -import { WorkspaceRole } from '@prisma/client' import { IsNotEmpty, IsOptional, IsString } from 'class-validator' export class CreateWorkspace { @@ -9,4 +8,4 @@ export class CreateWorkspace { @IsString() @IsOptional() description?: string -} \ No newline at end of file +} diff --git a/apps/api/src/workspace/service/workspace.service.ts b/apps/api/src/workspace/service/workspace.service.ts index 21bc24d8..4daea549 100644 --- a/apps/api/src/workspace/service/workspace.service.ts +++ b/apps/api/src/workspace/service/workspace.service.ts @@ -4,9 +4,7 @@ import { createEvent } from '@/common/event' import { paginate } from '@/common/paginate' import generateEntitySlug from '@/common/slug-generator' import { limitMaxItemsPerPage } from '@/common/util' -import { - createWorkspace -} from '@/common/workspace' +import { createWorkspace } from '@/common/workspace' import { IMailService, MAIL_SERVICE } from '@/mail/services/interface.service' import { PrismaService } from '@/prisma/prisma.service' import { @@ -29,9 +27,7 @@ import { Variable, Workspace } from '@prisma/client' -import { - CreateWorkspace, -} from '../dto/create.workspace/create.workspace' +import { CreateWorkspace } from '../dto/create.workspace/create.workspace' import { UpdateWorkspace } from '../dto/update.workspace/update.workspace' @Injectable() diff --git a/packages/api-client/src/controllers/workspace.ts b/packages/api-client/src/controllers/workspace.ts new file mode 100644 index 00000000..500081df --- /dev/null +++ b/packages/api-client/src/controllers/workspace.ts @@ -0,0 +1,117 @@ +import { APIClient } from '@api-client/core/client' +import { parseResponse } from '@api-client/core/response-parser' +import { ClientResponse } from '@api-client/types/index.types' +import { + CreateWorkspaceRequest, + CreateWorkspaceResponse, + DeleteWorkspaceRequest, + DeleteWorkspaceResponse, + ExportDataRequest, + ExportDataResponse, + GetAllWorkspacesOfUserRequest, + GetAllWorkspacesOfUserResponse, + GetWorkspaceRequest, + GetWorkspaceResponse, + GlobalSearchRequest, + GlobalSearchResponse, + UpdateWorkspaceRequest, + UpdateWorkspaceResponse +} from '@api-client/types/workspace.types' + +export default class WorkspaceController { + private apiClient: APIClient + + constructor(private readonly backendUrl: string) { + this.apiClient = new APIClient(this.backendUrl) + } + + async createWorkspace( + request: CreateWorkspaceRequest, + headers?: Record + ): Promise> { + const response = await this.apiClient.post( + `/api/workspace`, + request, + headers + ) + + return await parseResponse(response) + } + + async updateWorkspace( + request: UpdateWorkspaceRequest, + headers?: Record + ): Promise> { + const response = await this.apiClient.put( + `/api/workspace/${request.workspaceSlug}`, + request, + headers + ) + + return await parseResponse(response) + } + + async deleteWorkspace( + request: DeleteWorkspaceRequest, + headers?: Record + ): Promise> { + const response = await this.apiClient.delete( + `/api/workspace/${request.workspaceSlug}`, + headers + ) + + return await parseResponse(response) + } + + async getWorkspace( + request: GetWorkspaceRequest, + headers?: Record + ): Promise> { + const response = await this.apiClient.get( + `/api/workspace/${request.workspaceSlug}`, + headers + ) + + return await parseResponse(response) + } + + async getWorkspacesOfUser( + request: GetAllWorkspacesOfUserRequest, + headers?: Record + ): Promise> { + let url = `/api/workspace?` + request.page && (url += `page=${request.page}&`) + request.limit && (url += `limit=${request.limit}&`) + request.sort && (url += `sort=${request.sort}&`) + request.order && (url += `order=${request.order}&`) + request.search && (url += `search=${request.search}&`) + + const response = await this.apiClient.get(url, headers) + + return await parseResponse(response) + } + + async exportWorkspaceData( + request: ExportDataRequest, + headers?: Record + ): Promise> { + const response = await this.apiClient.get( + `/api/workspace/${request.workspaceSlug}/export-data`, + headers + ) + + return await parseResponse(response) + } + + async globalSearch( + request: GlobalSearchRequest, + headers?: Record + ): Promise> { + const response = await this.apiClient.get( + `/api/workspace/${request.workspaceSlug}/global-search/${request.search}`, + headers + ) + + return await parseResponse(response) + } +} diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index c2785808..05d9b163 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -4,6 +4,7 @@ import EventController from '@api-client/controllers/event' import IntegrationController from '@api-client/controllers/integration' import ProjectController from '@api-client/controllers/project' import VariableController from '@api-client/controllers/variable' +import WorkspaceController from './controllers/workspace' export { EnvironmentController, @@ -11,5 +12,6 @@ export { EventController, IntegrationController, ProjectController, - VariableController + VariableController, + WorkspaceController } diff --git a/packages/api-client/src/types/workspace.types.d.ts b/packages/api-client/src/types/workspace.types.d.ts new file mode 100644 index 00000000..e9747032 --- /dev/null +++ b/packages/api-client/src/types/workspace.types.d.ts @@ -0,0 +1,123 @@ +import { Page } from './index.types' + +interface Workspace { + id: string + name: string + slug: string + description: string + isFreeTier: boolean + createdAt: string + updatedAt: string + ownerId: string + isDefault: boolean + lastUpdatedBy: string +} + +export interface CreateWorkspaceRequest { + name: string + description?: string +} + +export interface CreateWorkspaceResponse extends Workspace {} + +export interface UpdateWorkspaceRequest + extends Partial { + workspaceSlug: string +} + +export interface UpdateWorkspaceResponse extends Workspace {} + +export interface DeleteWorkspaceRequest { + workspaceSlug: string +} + +export interface DeleteWorkspaceResponse {} + +export interface GetWorkspaceRequest { + workspaceSlug: string +} + +export interface GetWorkspaceResponse extends Workspace {} + +export interface GetAllWorkspacesOfUserRequest { + page?: number + limit?: number + sort?: string + order?: string + search?: string +} + +export interface GetAllWorkspacesOfUserResponse extends Page {} + +export interface ExportDataRequest { + workspaceSlug: string +} + +export interface ExportDataResponse { + name: string + description: string + workspaceRoles: { + name: string + description: string + colorCode: string + hasAdminAuthority: boolean + authorities: string[] + }[] + projects: { + name: string + description: string + publicKey: string + privateKey: string + storePrivateKey: boolean + accessLevel: 'GLOBAL' | 'PRIVATE' | 'INTERNAL' + environments: { + name: string + description: string + }[] + secrets: { + name: string + note: string + rotateAt: string + versions: { + value: string + version: number + }[] + }[] + variables: { + name: string + note: string + versions: { + value: string + version: number + }[] + }[] + }[] +} + +export interface GlobalSearchRequest { + workspaceSlug: string + search: string +} + +export interface GlobalSearchResponse { + projects: { + id: string + name: string + description: string + }[] + environments: { + id: string + name: string + description: string + }[] + secrets: { + id: string + name: string + note: string + }[] + variables: { + id: string + name: string + note: string + }[] +} diff --git a/packages/api-client/tests/config/setup.ts b/packages/api-client/tests/config/setup.ts index c81847ed..1a4e0f20 100644 --- a/packages/api-client/tests/config/setup.ts +++ b/packages/api-client/tests/config/setup.ts @@ -53,6 +53,6 @@ function startAPI(): Promise { setTimeout(() => { console.log('API launched') resolve() - }, 15000) + }, 10000) }) } diff --git a/packages/api-client/tests/workspace.spec.ts b/packages/api-client/tests/workspace.spec.ts new file mode 100644 index 00000000..a961ab77 --- /dev/null +++ b/packages/api-client/tests/workspace.spec.ts @@ -0,0 +1,204 @@ +import { APIClient } from '@api-client/core/client' +import WorkspaceController from '@api-client/controllers/workspace' + +describe('Workspaces Controller Tests', () => { + const backendUrl = process.env.BACKEND_URL + + const client = new APIClient(backendUrl) + const workspaceController = new WorkspaceController(backendUrl) + + const email = 'johndoe@example.com' + let workspaceSlug: string | null + + beforeEach(async () => { + // Create the user's workspace + const workspaceResponse = (await ( + await client.post( + '/api/workspace', + { + name: 'My Workspace' + }, + { + 'x-e2e-user-email': email + } + ) + ).json()) as any + + workspaceSlug = workspaceResponse.slug + }) + + afterEach(async () => { + // Delete the workspace + await client.delete(`/api/workspace/${workspaceSlug}`, { + 'x-e2e-user-email': email + }) + }) + + it('should return a list of workspaces for the user', async () => { + const workspaces = ( + await workspaceController.getWorkspacesOfUser( + { + page: 0, + limit: 10 + }, + { + 'x-e2e-user-email': email + } + ) + ).data + + expect(workspaces.items).toHaveLength(1) + expect(workspaces.items[0].name).toBe('My Workspace') + + // Check metadata + expect(workspaces.metadata.totalCount).toEqual(1) + expect(workspaces.metadata.links.self).toBe( + `/workspace?page=0&limit=10&sort=name&order=asc&search=` + ) + }) + + it('should be able to fetch workspace by slug', async () => { + const workspaceResponse = ( + await workspaceController.getWorkspace( + { + workspaceSlug + }, + { + 'x-e2e-user-email': email + } + ) + ).data + + expect(workspaceResponse.slug).toBe(workspaceSlug) + expect(workspaceResponse.name).toBe('My Workspace') + }) + + it('should be able to create a new workspace', async () => { + const createWorkspaceResponse = ( + await workspaceController.createWorkspace( + { + name: 'New Workspace', + description: 'This is a new workspace' + }, + { + 'x-e2e-user-email': email + } + ) + ).data + + expect(createWorkspaceResponse.name).toBe('New Workspace') + + // Fetch the created workspace + const fetchWorkspaceResponse = (await ( + await client.get(`/api/workspace/${createWorkspaceResponse.slug}`, { + 'x-e2e-user-email': email + }) + ).json()) as any + + expect(fetchWorkspaceResponse.name).toBe('New Workspace') + + // Delete the created workspace + await client.delete(`/api/workspace/${createWorkspaceResponse.slug}`, { + 'x-e2e-user-email': email + }) + }) + + it('should be able to update the workspace', async () => { + const updateWorkspaceResponse = ( + await workspaceController.updateWorkspace( + { + workspaceSlug, + name: 'Updated Workspace' + }, + { + 'x-e2e-user-email': email + } + ) + ).data + + expect(updateWorkspaceResponse.name).toBe('Updated Workspace') + + // Fetch the updated workspace + const fetchWorkspaceResponse = (await ( + await client.get(`/api/workspace/${updateWorkspaceResponse.slug}`, { + 'x-e2e-user-email': email + }) + ).json()) as any + + expect(fetchWorkspaceResponse.name).toBe('Updated Workspace') + }) + + it('should be able to delete the workspace', async () => { + // Create a workspace to delete + const createWorkspaceResponse = (await ( + await client.post( + '/api/workspace', + { + name: 'Workspace to Delete' + }, + { + 'x-e2e-user-email': email + } + ) + ).json()) as any + + await workspaceController.deleteWorkspace( + { + workspaceSlug: createWorkspaceResponse.slug + }, + { + 'x-e2e-user-email': email + } + ) + + // Verify that the workspace has been deleted + const workspace = ( + await workspaceController.getWorkspace( + { + workspaceSlug: createWorkspaceResponse.slug + }, + { + 'x-e2e-user-email': email + } + ) + ).data + + expect(workspace).toBeNull() + }) + + it('should be able to export workspace data', async () => { + const exportDataResponse = ( + await workspaceController.exportWorkspaceData( + { + workspaceSlug + }, + { + 'x-e2e-user-email': email + } + ) + ).data + + expect(exportDataResponse.name).toBe('My Workspace') + expect(exportDataResponse.projects).toHaveLength(0) + expect(exportDataResponse.workspaceRoles).toHaveLength(1) + }) + + it('should be able to perform a global search in the workspace', async () => { + const globalSearchResponse = ( + await workspaceController.globalSearch( + { + workspaceSlug, + search: 'work' + }, + { + 'x-e2e-user-email': email + } + ) + ).data + + expect(globalSearchResponse.projects).toHaveLength(0) + expect(globalSearchResponse.environments).toHaveLength(0) + expect(globalSearchResponse.secrets).toHaveLength(0) + expect(globalSearchResponse.variables).toHaveLength(0) + }) +})