From d45417a21cb33705e927175b4e52aeb6d310b290 Mon Sep 17 00:00:00 2001 From: Muntasir Mallik <73852736+muntaxir4@users.noreply.github.com> Date: Fri, 13 Dec 2024 09:56:38 +0530 Subject: [PATCH 1/9] feat(api): Add endpoint to fetch all workspace invitations for a user (#586) Co-authored-by: Rajdip Bhattacharya --- .../Get all invitations of user.bru | 21 ++++ .../create.environment/create.environment.ts | 1 - .../src/environment/environment.e2e.spec.ts | 5 +- .../migration.sql | 2 + apps/api/src/prisma/schema.prisma | 1 + .../service/workspace-membership.service.ts | 5 +- .../workspace-membership.e2e.spec.ts | 6 +- .../controller/workspace.controller.ts | 20 ++++ .../workspace/service/workspace.service.ts | 105 ++++++++++++++++ apps/api/src/workspace/workspace.e2e.spec.ts | 112 +++++++++++++++++- 10 files changed, 270 insertions(+), 8 deletions(-) create mode 100644 api-collection/Workspace Controller/Get all invitations of user.bru create mode 100644 apps/api/src/prisma/migrations/20241211205448_add_created_on_in_workspace_member/migration.sql diff --git a/api-collection/Workspace Controller/Get all invitations of user.bru b/api-collection/Workspace Controller/Get all invitations of user.bru new file mode 100644 index 00000000..7912da15 --- /dev/null +++ b/api-collection/Workspace Controller/Get all invitations of user.bru @@ -0,0 +1,21 @@ +meta { + name: Get all invitations of user to workspaces + type: http + seq: 3 +} + +get { + url: {{BASE_URL}}/api/workspace/invitations?page=0&limit=10 + body: none + auth: bearer +} + +auth:bearer { + token: {{JWT}} +} + +docs { + ## Description + + Fetches all the workspaces where the user is invited to. +} diff --git a/apps/api/src/environment/dto/create.environment/create.environment.ts b/apps/api/src/environment/dto/create.environment/create.environment.ts index ffa62e3f..ae2b7db8 100644 --- a/apps/api/src/environment/dto/create.environment/create.environment.ts +++ b/apps/api/src/environment/dto/create.environment/create.environment.ts @@ -3,7 +3,6 @@ import { IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator' export class CreateEnvironment { @IsString() @IsNotEmpty() - @Matches(/^[a-zA-Z0-9-_]{1,64}$/) name: string @IsString() diff --git a/apps/api/src/environment/environment.e2e.spec.ts b/apps/api/src/environment/environment.e2e.spec.ts index ad949f1c..607705ef 100644 --- a/apps/api/src/environment/environment.e2e.spec.ts +++ b/apps/api/src/environment/environment.e2e.spec.ts @@ -28,6 +28,7 @@ import { UserModule } from '@/user/user.module' import { UserService } from '@/user/service/user.service' import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' import { fetchEvents } from '@/common/event' +import { ValidationPipe } from '@nestjs/common' describe('Environment Controller Tests', () => { let app: NestFastifyApplication @@ -65,7 +66,7 @@ describe('Environment Controller Tests', () => { environmentService = moduleRef.get(EnvironmentService) userService = moduleRef.get(UserService) - app.useGlobalPipes(new QueryTransformPipe()) + app.useGlobalPipes(new ValidationPipe(), new QueryTransformPipe()) await app.init() await app.getHttpAdapter().getInstance().ready() @@ -184,7 +185,7 @@ describe('Environment Controller Tests', () => { 'x-e2e-user-email': user1.email } }) - + expect(response.statusCode).toBe(400) expect(response.json().message).toContain('name should not be empty') }) diff --git a/apps/api/src/prisma/migrations/20241211205448_add_created_on_in_workspace_member/migration.sql b/apps/api/src/prisma/migrations/20241211205448_add_created_on_in_workspace_member/migration.sql new file mode 100644 index 00000000..ded8dff4 --- /dev/null +++ b/apps/api/src/prisma/migrations/20241211205448_add_created_on_in_workspace_member/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "WorkspaceMember" ADD COLUMN "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 5aaa434c..d64780ef 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -339,6 +339,7 @@ model WorkspaceMember { workspaceId String invitationAccepted Boolean @default(false) roles WorkspaceMemberRoleAssociation[] + createdOn DateTime @default(now()) @@unique([workspaceId, userId]) } 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 d10032c5..6bcf5d89 100644 --- a/apps/api/src/workspace-membership/service/workspace-membership.service.ts +++ b/apps/api/src/workspace-membership/service/workspace-membership.service.ts @@ -879,11 +879,14 @@ export class WorkspaceMembershipService { roleSet.add(role) } + const invitedOn = new Date() + // Create the workspace membership const createMembership = this.prisma.workspaceMember.create({ data: { workspaceId: workspace.id, userId, + createdOn: invitedOn, roles: { create: Array.from(roleSet).map((role) => ({ role: { @@ -904,7 +907,7 @@ export class WorkspaceMembershipService { workspace.name, `${process.env.WORKSPACE_FRONTEND_URL}/workspace/${workspace.slug}/join`, currentUser.name, - new Date().toISOString(), + invitedOn.toISOString(), true ) 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 a606a17d..9c1222c6 100644 --- a/apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts +++ b/apps/api/src/workspace-membership/workspace-membership.e2e.spec.ts @@ -425,7 +425,8 @@ describe('Workspace Membership Controller Tests', () => { id: expect.any(String), userId: user2.id, workspaceId: workspace1.id, - invitationAccepted: false + invitationAccepted: false, + createdOn: expect.any(Date) }) }) @@ -909,7 +910,8 @@ describe('Workspace Membership Controller Tests', () => { id: expect.any(String), userId: user2.id, workspaceId: workspace1.id, - invitationAccepted: true + invitationAccepted: true, + createdOn: expect.any(Date) }) }) diff --git a/apps/api/src/workspace/controller/workspace.controller.ts b/apps/api/src/workspace/controller/workspace.controller.ts index ac11b504..5356caa7 100644 --- a/apps/api/src/workspace/controller/workspace.controller.ts +++ b/apps/api/src/workspace/controller/workspace.controller.ts @@ -44,6 +44,26 @@ export class WorkspaceController { return this.workspaceService.deleteWorkspace(user, workspaceSlug) } + @Get('invitations') + @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) + async getAllInvitationsOfUser( + @CurrentUser() user: User, + @Query('page') page: number = 0, + @Query('limit') limit: number = 10, + @Query('sort') sort: string = 'name', + @Query('order') order: string = 'asc', + @Query('search') search: string = '' + ) { + return this.workspaceService.getAllWorkspaceInvitations( + user, + page, + limit, + sort, + order, + search + ) + } + @Get(':workspaceSlug') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) async getWorkspace( diff --git a/apps/api/src/workspace/service/workspace.service.ts b/apps/api/src/workspace/service/workspace.service.ts index 2a7ec59d..b965a7d3 100644 --- a/apps/api/src/workspace/service/workspace.service.ts +++ b/apps/api/src/workspace/service/workspace.service.ts @@ -385,6 +385,111 @@ export class WorkspaceService { return { projects, environments, secrets, variables } } + /** + * Gets all the invitations a user has to various workspaces, paginated. + * @param user The user to get the workspaces for + * @param page The page number to get + * @param limit The number of items per page to get + * @param sort The field to sort by + * @param order The order to sort in + * @param search The search string to filter by + * @returns The workspace invitations of the user, paginated, with metadata + */ + async getAllWorkspaceInvitations( + user: User, + page: number, + limit: number, + sort: string, + order: string, + search: string + ) { + // fetch all workspaces of user where they are not admin + const items = await this.prisma.workspaceMember.findMany({ + skip: page * limit, + take: limitMaxItemsPerPage(Number(limit)), + orderBy: { + workspace: { + [sort]: order + } + }, + where: { + userId: user.id, + invitationAccepted: false, + workspace: { + name: { + contains: search + } + }, + roles: { + none: { + role: { + hasAdminAuthority: true + } + } + } + }, + select: { + workspace: { + select: { + id: true, + name: true, + slug: true, + icon: true + } + }, + roles: { + select: { + role: { + select: { + name: true, + colorCode: true + } + } + } + }, + createdOn: true + } + }) + + // get total count of workspaces of the user + const totalCount = await this.prisma.workspaceMember.count({ + where: { + userId: user.id, + invitationAccepted: false, + workspace: { + name: { + contains: search + } + }, + roles: { + none: { + role: { + hasAdminAuthority: true + } + } + } + } + }) + + //calculate metadata for pagination + const metadata = paginate(totalCount, `/workspace/invitations`, { + page, + limit: limitMaxItemsPerPage(limit), + sort, + order, + search + }) + + return { + items: items.map((item) => ({ + ...item, + invitedOn: item.createdOn, + createdOn: undefined + })), + metadata + } + } + /** * Gets a list of project IDs that the user has access to READ. * The user has access to a project if the project is global or if the user has the READ_PROJECT authority. diff --git a/apps/api/src/workspace/workspace.e2e.spec.ts b/apps/api/src/workspace/workspace.e2e.spec.ts index 5eba8c47..30960866 100644 --- a/apps/api/src/workspace/workspace.e2e.spec.ts +++ b/apps/api/src/workspace/workspace.e2e.spec.ts @@ -35,6 +35,8 @@ import { SecretService } from '@/secret/service/secret.service' import { VariableService } from '@/variable/service/variable.service' import { WorkspaceRoleService } from '@/workspace-role/service/workspace-role.service' import { WorkspaceRoleModule } from '@/workspace-role/workspace-role.module' +import { WorkspaceMembershipService } from '@/workspace-membership/service/workspace-membership.service' +import { WorkspaceMembershipModule } from '@/workspace-membership/workspace-membership.module' import { fetchEvents } from '@/common/event' const createMembership = async ( @@ -71,6 +73,7 @@ describe('Workspace Controller Tests', () => { let secretService: SecretService let variableService: VariableService let workspaceRoleService: WorkspaceRoleService + let workspaceMembershipService: WorkspaceMembershipService let user1: User, user2: User let workspace1: Workspace, workspace2: Workspace @@ -87,7 +90,8 @@ describe('Workspace Controller Tests', () => { EnvironmentModule, SecretModule, VariableModule, - WorkspaceRoleModule + WorkspaceRoleModule, + WorkspaceMembershipModule ] }) .overrideProvider(MAIL_SERVICE) @@ -106,6 +110,7 @@ describe('Workspace Controller Tests', () => { secretService = moduleRef.get(SecretService) variableService = moduleRef.get(VariableService) workspaceRoleService = moduleRef.get(WorkspaceRoleService) + workspaceMembershipService = moduleRef.get(WorkspaceMembershipService) app.useGlobalPipes(new QueryTransformPipe()) @@ -179,6 +184,7 @@ describe('Workspace Controller Tests', () => { expect(secretService).toBeDefined() expect(variableService).toBeDefined() expect(workspaceRoleService).toBeDefined() + expect(workspaceMembershipService).toBeDefined() }) describe('Create Workspace Tests', () => { @@ -305,7 +311,8 @@ describe('Workspace Controller Tests', () => { id: expect.any(String), userId: user1.id, workspaceId: workspace1.id, - invitationAccepted: true + invitationAccepted: true, + createdOn: expect.any(Date) }) }) }) @@ -481,6 +488,107 @@ describe('Workspace Controller Tests', () => { }) }) + describe('Get All Workspace Invitations Tests', () => { + it('should be able to fetch all the non accepted invitations of the user', async () => { + //invite user2 to workspace1 + await createMembership(memberRole.id, user2.id, workspace1.id, prisma) + + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/invitations` + }) + + const body = response.json() + + expect(body.items).toHaveLength(1) + expect(body.items[0].workspace.slug).not.toBe(workspace2.slug) + expect(body.items[0]).toEqual({ + invitedOn: expect.any(String), + workspace: { + icon: workspace1.icon, + id: workspace1.id, + name: workspace1.name, + slug: workspace1.slug + }, + roles: [ + { + role: { + name: memberRole.name, + colorCode: memberRole.colorCode + } + } + ] + }) + expect(body.metadata.totalCount).toBe(1) + expect(body.metadata.links.self).toEqual( + `/workspace/invitations?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(body.metadata.links.first).toEqual( + `/workspace/invitations?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(body.metadata.links.previous).toBeNull() + expect(body.metadata.links.next).toBeNull() + expect(body.metadata.links.last).toEqual( + `/workspace/invitations?page=0&limit=10&sort=name&order=asc&search=` + ) + }) + + it('should be able to fetch empty list of workspace invitations for the user once all invitations are accepted', async () => { + //invite user2 to workspace1 + await createMembership(memberRole.id, user2.id, workspace1.id, prisma) + + // accept the invitation for user2 to workspace1 + await workspaceMembershipService.acceptInvitation(user2, workspace1.slug) + + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/invitations` + }) + + const body = response.json() + expect(body.items).toHaveLength(0) + expect(body.metadata).toEqual({}) + }) + + it('should be able to fetch empty list of workspace invitations for the user if ownership is transferred', async () => { + //create a new workspace for user 1 + const workspace3 = await workspaceService.createWorkspace(user1, { + name: 'Workspace 3' + }) + + //invite user2 to workspace3 + await createMembership(memberRole.id, user2.id, workspace3.id, prisma) + + //accept the invitation for user2 to workspace3 + await workspaceMembershipService.acceptInvitation(user2, workspace3.slug) + + //transfer ownership of workspace1 to user2 + await workspaceMembershipService.transferOwnership( + user1, + workspace3.slug, + user2.email + ) + + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/invitations` + }) + + const body = response.json() + expect(body.items).toHaveLength(0) + expect(body.metadata).toEqual({}) + }) + }) + describe('Export Data Tests', () => { it('should not be able to export data of a non-existing workspace', async () => { const response = await app.inject({ From a92925f874d04bd60bad516c5d3de818453c2689 Mon Sep 17 00:00:00 2001 From: Sameer Poswal <106386145+poswalsameer@users.noreply.github.com> Date: Fri, 13 Dec 2024 10:15:36 +0530 Subject: [PATCH 2/9] refactor(web): Changed the text in the hero section of the web application (#579) Co-authored-by: Rajdip Bhattacharya --- apps/web/src/components/hero/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/hero/index.tsx b/apps/web/src/components/hero/index.tsx index 179b1ec9..4c4a53e1 100644 --- a/apps/web/src/components/hero/index.tsx +++ b/apps/web/src/components/hero/index.tsx @@ -88,9 +88,9 @@ function Hero(): React.JSX.Element { Unleash Security, Embrace Simplicity - - Your Go-To, Secure, and Easy-to-Use Configuration Management Tool - for the Developers, and by the Developers. + +

The better .env file replacement

+

Built for developers, by developers

From 89aa84f151eed76ec9ec4c9c224be5d39c2e3b0c Mon Sep 17 00:00:00 2001 From: Allan Joston Fernandes <54631653+Allan2000-Git@users.noreply.github.com> Date: Sat, 14 Dec 2024 13:02:01 +0530 Subject: [PATCH 3/9] feat(platofrm): Added online/offline status detection in the platform (#585) --- apps/platform/src/app/layout.tsx | 6 +++- .../common/online-status-handler.tsx | 10 ++++++ apps/platform/src/hooks/use-online-status.ts | 33 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 apps/platform/src/components/common/online-status-handler.tsx create mode 100644 apps/platform/src/hooks/use-online-status.ts diff --git a/apps/platform/src/app/layout.tsx b/apps/platform/src/app/layout.tsx index d9e2a617..011b6b19 100644 --- a/apps/platform/src/app/layout.tsx +++ b/apps/platform/src/app/layout.tsx @@ -1,6 +1,7 @@ import { Toaster } from '@/components/ui/sonner' import './global.css' import JotaiProvider from '@/components/jotaiProvider' +import OnlineStatusHandler from '@/components/common/online-status-handler' export const metadata = { title: 'Keyshade', @@ -15,7 +16,10 @@ export default function RootLayout({ return ( - {children} + + + {children} + diff --git a/apps/platform/src/components/common/online-status-handler.tsx b/apps/platform/src/components/common/online-status-handler.tsx new file mode 100644 index 00000000..f5685750 --- /dev/null +++ b/apps/platform/src/components/common/online-status-handler.tsx @@ -0,0 +1,10 @@ +'use client' + +import { useOnlineStatus } from "@/hooks/use-online-status"; + +function OnlineStatusHandler() { + useOnlineStatus(); + return null; +} + +export default OnlineStatusHandler; diff --git a/apps/platform/src/hooks/use-online-status.ts b/apps/platform/src/hooks/use-online-status.ts new file mode 100644 index 00000000..e6758e1f --- /dev/null +++ b/apps/platform/src/hooks/use-online-status.ts @@ -0,0 +1,33 @@ +import { useEffect, useRef } from "react" +import { toast } from "sonner"; + +export const useOnlineStatus = () => { + const statusTimeout = useRef(null); + + const statusHandler = () => { + if (statusTimeout.current) { + clearTimeout(statusTimeout.current); + } + + statusTimeout.current = setTimeout(() => { + if (navigator.onLine) { + toast.success("You are back online! Refreshing..."); + setTimeout(() => { + window.location.reload(); + }, 1000); + } else { + toast.error("You are offline"); + } + }, 1000); + }; + + useEffect(() => { + window.addEventListener("online", statusHandler); + window.addEventListener("offline", statusHandler); + + return () => { + window.removeEventListener("online", statusHandler); + window.removeEventListener("offline", statusHandler); + } + }, []); +} \ No newline at end of file From 0fde62b1925d6365483ce23339ac5a5c687aaa55 Mon Sep 17 00:00:00 2001 From: Muntasir Mallik <73852736+muntaxir4@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:01:53 +0530 Subject: [PATCH 4/9] feat(cli): Add functionality to operate on Workspace Membership (#589) Co-authored-by: Rajdip Bhattacharya --- .../emails/components/base-email-template.tsx | 12 ++-- .../src/mail/emails/workspace-invitation.tsx | 15 ++--- .../api/src/mail/emails/workspace-removal.tsx | 6 +- apps/cli/src/commands/workspace.command.ts | 4 +- .../workspace/membership.workspace.ts | 34 ++++++++++ .../accept-invitation.membership.ts | 49 ++++++++++++++ .../cancel-invitation.membership.ts | 55 ++++++++++++++++ .../decline-invitation.membership.ts | 49 ++++++++++++++ .../membership/get-all-members.membership.ts | 64 ++++++++++++++++++ .../workspace/membership/invite.membership.ts | 65 +++++++++++++++++++ .../workspace/membership/leave.membership.ts | 49 ++++++++++++++ .../workspace/membership/remove.membership.ts | 55 ++++++++++++++++ .../transfer-ownership.membership copy.ts | 55 ++++++++++++++++ .../membership/update-role.membership.ts | 61 +++++++++++++++++ .../commands/workspace/role/update.role.ts | 4 +- apps/cli/src/util/controller-instance.ts | 15 ++++- package.json | 2 +- 17 files changed, 571 insertions(+), 23 deletions(-) create mode 100644 apps/cli/src/commands/workspace/membership.workspace.ts create mode 100644 apps/cli/src/commands/workspace/membership/accept-invitation.membership.ts create mode 100644 apps/cli/src/commands/workspace/membership/cancel-invitation.membership.ts create mode 100644 apps/cli/src/commands/workspace/membership/decline-invitation.membership.ts create mode 100644 apps/cli/src/commands/workspace/membership/get-all-members.membership.ts create mode 100644 apps/cli/src/commands/workspace/membership/invite.membership.ts create mode 100644 apps/cli/src/commands/workspace/membership/leave.membership.ts create mode 100644 apps/cli/src/commands/workspace/membership/remove.membership.ts create mode 100644 apps/cli/src/commands/workspace/membership/transfer-ownership.membership copy.ts create mode 100644 apps/cli/src/commands/workspace/membership/update-role.membership.ts diff --git a/apps/api/src/mail/emails/components/base-email-template.tsx b/apps/api/src/mail/emails/components/base-email-template.tsx index 891ddfe0..07dcf422 100644 --- a/apps/api/src/mail/emails/components/base-email-template.tsx +++ b/apps/api/src/mail/emails/components/base-email-template.tsx @@ -42,13 +42,13 @@ export const BaseEmailTemplate: React.FC = ({ {heading} {children} - If you believe this action was taken in error or have any - questions regarding this change, please contact your project - administrator or our support team. + If you believe this action was taken in error or have any + questions regarding this change, please contact your project + administrator or our support team. - We appreciate your understanding and thank you for your - contributions to the project. + We appreciate your understanding and thank you for your + contributions to the project. Cheers, @@ -81,4 +81,4 @@ export const BaseEmailTemplate: React.FC = ({ ) } -export default BaseEmailTemplate \ No newline at end of file +export default BaseEmailTemplate diff --git a/apps/api/src/mail/emails/workspace-invitation.tsx b/apps/api/src/mail/emails/workspace-invitation.tsx index 37b4fc0f..5fab5df7 100644 --- a/apps/api/src/mail/emails/workspace-invitation.tsx +++ b/apps/api/src/mail/emails/workspace-invitation.tsx @@ -33,14 +33,11 @@ export const WorkspaceInvitationEmail = ({ : 'You are Invited to Join the Workspace' return ( - + Dear User, - We're excited to inform you that you've been invited to join a - workspace on Keyshade. Here are the details of your invitation: + We're excited to inform you that you've been invited to join a workspace + on Keyshade. Here are the details of your invitation:
@@ -54,8 +51,8 @@ export const WorkspaceInvitationEmail = ({
- Join the project by clicking the button below - we're excited to - have you! + Join the project by clicking the button below - we're excited to have + you!