From f3dfe254f4f41fa29fdaac4d76bf80bd3ae8c8c5 Mon Sep 17 00:00:00 2001 From: Rajdip Bhattacharya Date: Tue, 28 Jan 2025 14:21:37 +0530 Subject: [PATCH] Release v2.10.0 (#672) Co-authored-by: semantic-release-bot Co-authored-by: csehatt741 <77381875+csehatt741@users.noreply.github.com> Co-authored-by: kriptonian1 Co-authored-by: Allan Joston Fernandes <54631653+Allan2000-Git@users.noreply.github.com> Co-authored-by: Sawan Bhattacharya <74916308+kriptonian1@users.noreply.github.com> --- .github/workflows/release.yml | 81 ++++--- CHANGELOG.md | 78 +++++++ apps/api/package.json | 3 +- apps/api/src/common/secret.spec.ts | 39 ++++ apps/api/src/common/secret.ts | 36 +++ apps/api/src/common/util.ts | 2 +- .../src/environment/environment.e2e.spec.ts | 106 ++++++++- .../service/environment.service.ts | 59 +++++ .../migration.sql | 2 + apps/api/src/prisma/schema.prisma | 17 +- apps/api/src/secret/secret.e2e.spec.ts | 110 ++++++++++ apps/api/src/secret/service/secret.service.ts | 144 +++++++++++- apps/api/src/user/service/user.service.ts | 12 +- .../workspace/service/workspace.service.ts | 72 +++++- apps/api/src/workspace/workspace.e2e.spec.ts | 24 ++ apps/cli/package.json | 3 +- apps/platform/next.config.mjs | 3 + apps/platform/public/svg/dashboard/config.svg | 9 - .../public/svg/dashboard/environment.svg | 2 +- apps/platform/public/svg/dashboard/index.ts | 4 +- apps/platform/public/svg/dashboard/secret.svg | 2 +- .../public/svg/dashboard/variable.svg | 7 + apps/platform/src/app/(main)/layout.tsx | 8 +- apps/platform/src/app/(main)/page.tsx | 67 +++--- .../project/[project]/@environment/page.tsx | 124 +++++++++++ .../(main)/project/[project]/@secret/page.tsx | 40 +++- .../project/[project]/@variable/page.tsx | 24 +- .../app/(main)/project/[project]/layout.tsx | 6 +- .../src/app/(main)/settings/@profile/page.tsx | 2 +- .../addEnvironmentDialogue/index.tsx | 205 ++++++++++++++++++ .../confirmDeleteEnvironment/index.tsx | 136 ++++++++++++ .../editEnvironmentSheet/index.tsx | 185 ++++++++++++++++ .../environment/environmentCard/index.tsx | 95 ++++++++ .../project/confirmDeleteProject/index.tsx | 133 ++++++++++++ .../dashboard/project/projectCard/index.tsx | 33 ++- .../dashboard/secret/secretCard/index.tsx | 43 +++- .../variable/editVariableDialogue/index.tsx | 5 +- .../dashboard/variable/variableCard/index.tsx | 12 +- .../src/components/shared/navbar/index.tsx | 2 +- apps/platform/src/components/ui/combobox.tsx | 27 ++- apps/platform/src/store/index.ts | 17 +- apps/web/next.config.mjs | 2 +- package.json | 10 +- .../src/controllers/workspace-membership.ts | 2 +- packages/api-client/src/core/client.ts | 6 +- packages/schema/src/environment/index.ts | 19 +- packages/schema/src/workspace/index.ts | 7 +- packages/schema/tests/environment.spec.ts | 19 +- packages/schema/tests/workspace.spec.ts | 3 +- pnpm-lock.yaml | 5 +- 50 files changed, 1849 insertions(+), 203 deletions(-) create mode 100644 apps/api/src/common/secret.spec.ts create mode 100644 apps/api/src/prisma/migrations/20250123150220_add_rotate_after_in_secret/migration.sql delete mode 100644 apps/platform/public/svg/dashboard/config.svg create mode 100644 apps/platform/public/svg/dashboard/variable.svg create mode 100644 apps/platform/src/app/(main)/project/[project]/@environment/page.tsx create mode 100644 apps/platform/src/components/dashboard/environment/addEnvironmentDialogue/index.tsx create mode 100644 apps/platform/src/components/dashboard/environment/confirmDeleteEnvironment/index.tsx create mode 100644 apps/platform/src/components/dashboard/environment/editEnvironmentSheet/index.tsx create mode 100644 apps/platform/src/components/dashboard/environment/environmentCard/index.tsx create mode 100644 apps/platform/src/components/dashboard/project/confirmDeleteProject/index.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 196b7a3e6..6531e1881 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,6 @@ jobs: runs-on: ubuntu-latest outputs: environment: ${{ github.ref == 'refs/heads/main' && 'alpha' || 'stage' }} - latestTag: ${{ github.ref == 'refs/heads/main' && 'latest' || 'experimental' }} version: ${{ steps.fetch-tag.outputs.version }} steps: - name: Checkout @@ -79,18 +78,17 @@ jobs: env: ACR_REGISTRY_URL: ${{ vars.ACR_REGISTRY_URL }} REPOSITORY_NAME: api - LATEST_TAG: ${{ needs.release.outputs.latestTag }} VERSION: ${{ needs.release.outputs.version }} run: | # Build a docker container and push it to ACR + echo "Building image..." docker build \ -t $ACR_REGISTRY_URL/$REPOSITORY_NAME:$VERSION \ - -t $ACR_REGISTRY_URL/$REPOSITORY_NAME:$LATEST_TAG \ - -f ./apps/api/Dockerfile . + -f apps/api/Dockerfile . + + # Push the image echo "Pushing image to ACR..." - docker push $ACR_REGISTRY_URL/$REPOSITORY_NAME:$LATEST_TAG docker push $ACR_REGISTRY_URL/$REPOSITORY_NAME:$VERSION - echo "name=image::$ACR_REGISTRY_URL/$REPOSITORY_NAME:$LATEST_TAG" >> $GITHUB_OUTPUT - name: Azure Login action uses: azure/login@v2 @@ -105,14 +103,14 @@ jobs: API_CONTAINER_RG: ${{ vars.API_CONTAINER_RG }} ACR_REGISTRY_URL: ${{ vars.ACR_REGISTRY_URL }} REPOSITORY_NAME: api - LATEST_TAG: ${{ needs.release.outputs.latestTag }} + VERSION: ${{ needs.release.outputs.version }} with: azcliversion: latest inlineScript: | az containerapp update \ --name $API_CONTAINER \ --resource-group $API_CONTAINER_RG \ - --image $ACR_REGISTRY_URL/$REPOSITORY_NAME:$LATEST_TAG + --image $ACR_REGISTRY_URL/$REPOSITORY_NAME:$VERSION migrate-api: name: Apply Database Migrations @@ -152,6 +150,9 @@ jobs: - name: Install run: pnpm i + - name: Build + run: pnpm build:api + - name: Install Sentry CLI run: npm i -g @sentry/cli @@ -166,9 +167,8 @@ jobs: sentry-cli releases new $VERSION sentry-cli releases set-commits --auto $VERSION sentry-cli releases deploys $VERSION new -e $ENVIRONMENT - sentry-cli releases files $VERSION upload-sourcemaps apps/api/.dist \ - --validate \ - --rewrite + sentry-cli sourcemaps inject apps/api/dist + sentry-cli sourcemaps upload --release $VERSION -p $SENTRY_PROJECT apps/api/dist deploy-platform: needs: release @@ -193,7 +193,6 @@ jobs: env: ACR_REGISTRY_URL: ${{ vars.ACR_REGISTRY_URL }} REPOSITORY_NAME: platform - LATEST_TAG: ${{ needs.release.outputs.latestTag }} VERSION: ${{ needs.release.outputs.version }} NEXT_PUBLIC_BACKEND_URL: ${{ vars.NEXT_PUBLIC_BACKEND_URL }} NEXT_PUBLIC_SENTRY_PLATFORM_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_PLATFORM_DSN }} @@ -202,15 +201,15 @@ jobs: NEXT_PUBLIC_SENTRY_ENVIRONMENT: ${{ needs.release.outputs.environment }} run: | # Build a docker container and push it to ACR + echo "Building image..." docker build \ -t $ACR_REGISTRY_URL/$REPOSITORY_NAME:$VERSION \ - -t $ACR_REGISTRY_URL/$REPOSITORY_NAME:$LATEST_TAG \ --build-arg NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL \ - -f ./apps/platform/Dockerfile . + -f apps/platform/Dockerfile . + + # Push the image echo "Pushing image to ACR..." - docker push $ACR_REGISTRY_URL/$REPOSITORY_NAME:$LATEST_TAG docker push $ACR_REGISTRY_URL/$REPOSITORY_NAME:$VERSION - echo "name=image::$ACR_REGISTRY_URL/$REPOSITORY_NAME:$LATEST_TAG" >> $GITHUB_OUTPUT - name: Azure Login action uses: azure/login@v2 @@ -225,14 +224,14 @@ jobs: PLATFORM_CONTAINER_RG: ${{ vars.PLATFORM_CONTAINER_RG }} ACR_REGISTRY_URL: ${{ vars.ACR_REGISTRY_URL }} REPOSITORY_NAME: platform - LATEST_TAG: ${{ needs.release.outputs.latestTag }} + VERSION: ${{ needs.release.outputs.version }} with: azcliversion: latest inlineScript: | az containerapp update \ --name $PLATFORM_CONTAINER \ --resource-group $PLATFORM_CONTAINER_RG \ - --image $ACR_REGISTRY_URL/$REPOSITORY_NAME:$LATEST_TAG + --image $ACR_REGISTRY_URL/$REPOSITORY_NAME:$VERSION release-sentry-platform: name: Sentry Platform @@ -243,6 +242,15 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Install PNPM + run: npm i -g pnpm + + - name: Install + run: pnpm i + + - name: Build + run: pnpm build:api + - name: Install Sentry CLI run: npm i -g @sentry/cli @@ -257,7 +265,7 @@ jobs: sentry-cli releases new $VERSION sentry-cli releases set-commits --auto $VERSION sentry-cli releases deploys $VERSION new -e $ENVIRONMENT - sentry-cli releases files $VERSION upload-sourcemaps apps/platform/.next/static \ + sentry-cli releases files $VERSION upload-sourcemaps apps/platform/.next/static/chunks \ --url-prefix '~/_next' \ --validate \ --rewrite @@ -285,7 +293,6 @@ jobs: env: ACR_REGISTRY_URL: ${{ vars.ACR_REGISTRY_URL }} REPOSITORY_NAME: web - LATEST_TAG: ${{ needs.release.outputs.latestTag }} VERSION: ${{ needs.release.outputs.version }} NEXT_PUBLIC_GOOGLE_ANALYTICS_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_GOOGLE_ANALYTICS_MEASUREMENT_ID }} NEXT_PUBLIC_SENTRY_WEB_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_WEB_DSN }} @@ -294,19 +301,18 @@ jobs: NEXT_PUBLIC_SENTRY_ENVIRONMENT: ${{ needs.release.outputs.environment }} run: | # Build a docker container and push it to ACR + echo "Building image..." docker build \ -t $ACR_REGISTRY_URL/$REPOSITORY_NAME:$VERSION \ - -t $ACR_REGISTRY_URL/$REPOSITORY_NAME:$LATEST_TAG \ --build-arg NEXT_PUBLIC_GOOGLE_ANALYTICS_MEASUREMENT_ID=$NEXT_PUBLIC_GOOGLE_ANALYTICS_MEASUREMENT_ID \ --build-arg NEXT_PUBLIC_SENTRY_WEB_DSN=$NEXT_PUBLIC_SENTRY_WEB_DSN \ --build-arg NEXT_PUBLIC_SENTRY_WEB_PROFILE_SAMPLE_RATE=$NEXT_PUBLIC_SENTRY_WEB_PROFILE_SAMPLE_RATE \ --build-arg NEXT_PUBLIC_SENTRY_WEB_TRACES_SAMPLE_RATE=$NEXT_PUBLIC_SENTRY_WEB_TRACES_SAMPLE_RATE \ --build-arg NEXT_PUBLIC_SENTRY_ENVIRONMENT=$NEXT_PUBLIC_SENTRY_ENVIRONMENT \ -f ./apps/web/Dockerfile . + echo "Pushing image to ACR..." - docker push $ACR_REGISTRY_URL/$REPOSITORY_NAME:$LATEST_TAG docker push $ACR_REGISTRY_URL/$REPOSITORY_NAME:$VERSION - echo "name=image::$ACR_REGISTRY_URL/$REPOSITORY_NAME:$LATEST_TAG" >> $GITHUB_OUTPUT - name: Azure Login action uses: azure/login@v2 @@ -321,14 +327,14 @@ jobs: WEB_CONTAINER_RG: ${{ vars.WEB_CONTAINER_RG }} ACR_REGISTRY_URL: ${{ vars.ACR_REGISTRY_URL }} REPOSITORY_NAME: web - LATEST_TAG: ${{ needs.release.outputs.latestTag }} + VERSION: ${{ needs.release.outputs.version }} with: azcliversion: latest inlineScript: | az containerapp update \ --name $WEB_CONTAINER \ --resource-group $WEB_CONTAINER_RG \ - --image $ACR_REGISTRY_URL/$REPOSITORY_NAME:$LATEST_TAG + --image $ACR_REGISTRY_URL/$REPOSITORY_NAME:$VERSION sentry-release-web: name: Sentry Web @@ -341,6 +347,15 @@ jobs: - name: Install Sentry CLI run: npm i -g @sentry/cli + - name: Install PNPM + run: npm i -g pnpm + + - name: Install + run: pnpm i + + - name: Build + run: pnpm build:api + - name: Sentry Release env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -352,7 +367,7 @@ jobs: sentry-cli releases new $VERSION sentry-cli releases set-commits --auto $VERSION sentry-cli releases deploys $VERSION new -e $ENVIRONMENT - sentry-cli releases files $VERSION upload-sourcemaps apps/web/.next/static \ + sentry-cli releases files $VERSION upload-sourcemaps apps/web/.next/static/chunks \ --url-prefix '~/_next' \ --validate \ --rewrite @@ -426,13 +441,25 @@ jobs: uses: actions/checkout@v4 with: persist-credentials: false + - name: node uses: actions/setup-node@v4 with: node-version: 20.x registry-url: https://registry.npmjs.org + + - name: Install PNPM + run: npm i -g pnpm + + - name: Install + run: pnpm i + + - name: Build + run: pnpm build:api + - name: Install sentry-cli run: npm i -g @sentry/cli + - name: Make release env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -446,6 +473,6 @@ jobs: sentry-cli releases new -p $SENTRY_PROJECT $VERSION sentry-cli releases set-commits --auto $VERSION sentry-cli releases deploys $VERSION new -e $ENVIRONMENT - sentry-cli releases files $VERSION upload-sourcemaps apps/cli/.dist \ + sentry-cli releases files $VERSION upload-sourcemaps apps/cli/dist \ --validate \ --rewrite diff --git a/CHANGELOG.md b/CHANGELOG.md index 3651bc32a..744d3a9cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,81 @@ +## [2.10.0-stage.8](https://github.com/keyshade-xyz/keyshade/compare/v2.10.0-stage.7...v2.10.0-stage.8) (2025-01-28) + +### 🚀 Features + +* **platform:** Operate on environments ([#670](https://github.com/keyshade-xyz/keyshade/issues/670)) ([f45c5fa](https://github.com/keyshade-xyz/keyshade/commit/f45c5fa81728832f6ff74c5c26d52e00e32fb546)) + +## [2.10.0-stage.7](https://github.com/keyshade-xyz/keyshade/compare/v2.10.0-stage.6...v2.10.0-stage.7) (2025-01-28) + +### 🚀 Features + +* **platform:** Implement delete project ([#671](https://github.com/keyshade-xyz/keyshade/issues/671)) ([d243c89](https://github.com/keyshade-xyz/keyshade/commit/d243c89be2358f104ad893b5c955d814835aa8e8)) + +## [2.10.0-stage.6](https://github.com/keyshade-xyz/keyshade/compare/v2.10.0-stage.5...v2.10.0-stage.6) (2025-01-26) + +### 🐛 Bug Fixes + +* **Platfrom:** Replace manual date calculation with dayjs to improve better calculation ([#668](https://github.com/keyshade-xyz/keyshade/issues/668)) ([990eb86](https://github.com/keyshade-xyz/keyshade/commit/990eb86aa5bdeb911c3907df6ea7b52c510e64ad)) + +### 🔧 Miscellaneous Chores + +* **ci:** Update API sentry dist folder ([2bc9afb](https://github.com/keyshade-xyz/keyshade/commit/2bc9afb3e790035e7e3ceec6a568eeab6165be9a)) +* **CI:** Update pipeline ([fd63b70](https://github.com/keyshade-xyz/keyshade/commit/fd63b7085f2802b440a9c9628cfe8c0fd609bba9)) + +## [2.10.0-stage.5](https://github.com/keyshade-xyz/keyshade/compare/v2.10.0-stage.4...v2.10.0-stage.5) (2025-01-25) + +### 🐛 Bug Fixes + +* **platform:** Refactor layout structure to improve Navbar positioning & child component ([#661](https://github.com/keyshade-xyz/keyshade/issues/661)) ([31067f3](https://github.com/keyshade-xyz/keyshade/commit/31067f309914fc8cd4ac2c1b854e4d8039af5494)) + +## [2.10.0-stage.4](https://github.com/keyshade-xyz/keyshade/compare/v2.10.0-stage.3...v2.10.0-stage.4) (2025-01-25) + +### 🐛 Bug Fixes + +* **cli:** Workspace membership API client payload fixed ([#614](https://github.com/keyshade-xyz/keyshade/issues/614)) ([#648](https://github.com/keyshade-xyz/keyshade/issues/648)) ([e23057b](https://github.com/keyshade-xyz/keyshade/commit/e23057b382f0f0d407a9d457876868b9cc39dda1)) + +## [2.10.0-stage.3](https://github.com/keyshade-xyz/keyshade/compare/v2.10.0-stage.2...v2.10.0-stage.3) (2025-01-25) + +### 🚀 Features + +* **platform:** Improved UI of [secure] listing ([#655](https://github.com/keyshade-xyz/keyshade/issues/655)) ([b19de47](https://github.com/keyshade-xyz/keyshade/commit/b19de47bdbe5c098fd2c256d4d9b65989498786c)) + +### 🔧 Miscellaneous Chores + +* **ci:** Fixed misplaced sentry sourcemaps commands ([fbd6f3b](https://github.com/keyshade-xyz/keyshade/commit/fbd6f3be5924c32c0df49177131acd39a3249076)) + +### 🔨 Code Refactoring + +* **api:** Update getSelf function ([fe752ce](https://github.com/keyshade-xyz/keyshade/commit/fe752ce1f19582da2c5be9c48b0d7ef1a6332284)) + +## [2.10.0-stage.2](https://github.com/keyshade-xyz/keyshade/compare/v2.10.0-stage.1...v2.10.0-stage.2) (2025-01-24) + +### 🐛 Bug Fixes + +* **api:** Only user's default workspace returns isDefault: true ([#647](https://github.com/keyshade-xyz/keyshade/issues/647)) ([870b4dc](https://github.com/keyshade-xyz/keyshade/commit/870b4dc7b68f57fbd09a51099f701a57fb0c998d)) + +### 🔧 Miscellaneous Chores + +* Fix prerelease branch config ([7e84021](https://github.com/keyshade-xyz/keyshade/commit/7e8402140e4cefe021fd9269829d38643aa94404)) + +## [2.10.0-stage.1](https://github.com/keyshade-xyz/keyshade/compare/v2.9.2-stage.1...v2.10.0-stage.1) (2025-01-24) + +### 🚀 Features + +* **api:** Secret rotation ([#652](https://github.com/keyshade-xyz/keyshade/issues/652)) ([ad9a808](https://github.com/keyshade-xyz/keyshade/commit/ad9a808ac6819c34b67ee77a1c86cb87b962d411)) + +## [2.9.2-stage.1](https://github.com/keyshade-xyz/keyshade/compare/v2.9.1...v2.9.2-stage.1) (2025-01-24) + +### 🐛 Bug Fixes + +* Added lockfile ([856eb3c](https://github.com/keyshade-xyz/keyshade/commit/856eb3c1446752b3312854e3b63b60cff223140c)) + +### 🔧 Miscellaneous Chores + +* **ci:** Add missing LATEST_TAG variable ([a2ea2ed](https://github.com/keyshade-xyz/keyshade/commit/a2ea2edc08583ff6a89fed5bb935d3bf12f91c3c)) +* **ci:** Fixed scripts ([374f7ed](https://github.com/keyshade-xyz/keyshade/commit/374f7ed31d85d3481af9ccc2ccdb920eee1d463b)) +* **ci:** Update sourcemap upload commands ([c7e8e45](https://github.com/keyshade-xyz/keyshade/commit/c7e8e45fdcc1b9bad1785618ff43bf3bf5785783)) +* **cli:** Bumped CLI version to 2.5.0 ([7b772f8](https://github.com/keyshade-xyz/keyshade/commit/7b772f83cda360eeff9b69f5f2a76458e5bcd406)) + ## [2.9.1](https://github.com/keyshade-xyz/keyshade/compare/v2.9.0...v2.9.1) (2025-01-23) ### 🐛 Bug Fixes diff --git a/apps/api/package.json b/apps/api/package.json index f8ad00aa5..ba5187b54 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -33,10 +33,11 @@ "@nestjs/swagger": "^7.3.0", "@nestjs/throttler": "^6.2.1", "@nestjs/websockets": "^10.3.7", - "@slack/bolt": "^3.22.0", "@react-email/components": "^0.0.25", "@react-email/preview": "0.0.11", "@react-email/render": "^1.0.1", + "@sentry/cli": "^2.28.6", + "@slack/bolt": "^3.22.0", "@socket.io/redis-adapter": "^8.3.0", "@supabase/supabase-js": "^2.39.6", "class-transformer": "^0.5.1", diff --git a/apps/api/src/common/secret.spec.ts b/apps/api/src/common/secret.spec.ts new file mode 100644 index 000000000..595d925bb --- /dev/null +++ b/apps/api/src/common/secret.spec.ts @@ -0,0 +1,39 @@ +import { generateSecretValue } from './secret' + +describe('generateSecretValue', () => { + test('should generate a string of exactly 20 characters', () => { + const secret = generateSecretValue() + expect(secret).toHaveLength(20) + }) + + test('should include at least one digit', () => { + const secret = generateSecretValue() + expect(secret).toMatch(/\d/) + }) + + test('should include at least one lowercase letter', () => { + const secret = generateSecretValue() + expect(secret).toMatch(/[a-z]/) + }) + + test('should include at least one uppercase letter', () => { + const secret = generateSecretValue() + expect(secret).toMatch(/[A-Z]/) + }) + + test('should include at least one special character', () => { + const secret = generateSecretValue() + expect(secret).toMatch(/[!@#$%^&*]/) + }) + + test('should only include allowed characters', () => { + const secret = generateSecretValue() + expect(secret).toMatch(/^[0-9a-zA-Z!@#$%^&*]{20}$/) + }) + + test('should generate different values for consecutive calls', () => { + const secret1 = generateSecretValue() + const secret2 = generateSecretValue() + expect(secret1).not.toBe(secret2) + }) +}) diff --git a/apps/api/src/common/secret.ts b/apps/api/src/common/secret.ts index 5797828a7..7d0ef03b8 100644 --- a/apps/api/src/common/secret.ts +++ b/apps/api/src/common/secret.ts @@ -26,3 +26,39 @@ export function getSecretWithValues( values } } + +export function generateSecretValue(): string { + const length = 20 + const digits = '0123456789' + const lowercaseChars = 'abcdefghijklmnopqrstuvwxyz' + const uppercaseChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + const specialChars = '!@#$%^&*' + const allChars = digits + lowercaseChars + uppercaseChars + specialChars + + const getRandomIndex = (max: number): number => { + const randomValues = new Uint8Array(1) + crypto.getRandomValues(randomValues) + return randomValues[0] % max + } + + // Ensure at least one character from each required set is included + const result = [ + lowercaseChars[getRandomIndex(lowercaseChars.length)], + uppercaseChars[getRandomIndex(uppercaseChars.length)], + digits[getRandomIndex(digits.length)], + specialChars[getRandomIndex(specialChars.length)] + ] + + // Fill the rest of the string to meet the minimum length + while (result.length < length) { + result.push(allChars[getRandomIndex(allChars.length)]) + } + + // Shuffle the result to randomize the order + for (let i = result.length - 1; i > 0; i--) { + const j = getRandomIndex(i + 1) + ;[result[i], result[j]] = [result[j], result[i]] + } + + return result.join('') +} diff --git a/apps/api/src/common/util.ts b/apps/api/src/common/util.ts index 6ec09439c..d3a72e541 100644 --- a/apps/api/src/common/util.ts +++ b/apps/api/src/common/util.ts @@ -97,7 +97,7 @@ export const excludeFields = ( * @param hours The number of hours to add to the current date * @returns The new date with the given number of hours added, or undefined if the hours is 'never' */ -export const addHoursToDate = (hours?: string): Date | undefined => { +export const addHoursToDate = (hours?: string | number): Date | undefined => { if (!hours || hours === 'never') return undefined const date = new Date() diff --git a/apps/api/src/environment/environment.e2e.spec.ts b/apps/api/src/environment/environment.e2e.spec.ts index 607705ef8..a47587fde 100644 --- a/apps/api/src/environment/environment.e2e.spec.ts +++ b/apps/api/src/environment/environment.e2e.spec.ts @@ -29,6 +29,10 @@ 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' +import { SecretService } from '@/secret/service/secret.service' +import { VariableService } from '@/variable/service/variable.service' +import { SecretModule } from '@/secret/secret.module' +import { VariableModule } from '@/variable/variable.module' describe('Environment Controller Tests', () => { let app: NestFastifyApplication @@ -37,6 +41,8 @@ describe('Environment Controller Tests', () => { let environmentService: EnvironmentService let userService: UserService let eventService: EventService + let secretService: SecretService + let variableService: VariableService let user1: User, user2: User let workspace1: Workspace @@ -50,7 +56,9 @@ describe('Environment Controller Tests', () => { EventModule, ProjectModule, EnvironmentModule, - UserModule + UserModule, + SecretModule, + VariableModule ] }) .overrideProvider(MAIL_SERVICE) @@ -65,6 +73,8 @@ describe('Environment Controller Tests', () => { eventService = moduleRef.get(EventService) environmentService = moduleRef.get(EnvironmentService) userService = moduleRef.get(UserService) + secretService = moduleRef.get(SecretService) + variableService = moduleRef.get(VariableService) app.useGlobalPipes(new ValidationPipe(), new QueryTransformPipe()) @@ -465,6 +475,100 @@ describe('Environment Controller Tests', () => { ) }) + it('should be able to get the count of secrets and variables in an environment', async () => { + // Add secrets to the environment + const secret1 = await secretService.createSecret( + user1, + { + name: 'Secret 1', + entries: [ + { + value: 'Secret 1 value', + environmentSlug: environment1.slug + } + ] + }, + project1.slug + ) + await secretService.createSecret( + user1, + { + name: 'Secret 2', + entries: [ + { + value: 'Secret 2 value', + environmentSlug: environment1.slug + } + ] + }, + project1.slug + ) + + // Add variables to the environment + const variable1 = await variableService.createVariable( + user1, + { + name: 'Variable 1', + entries: [ + { + value: 'Variable 1 value', + environmentSlug: environment1.slug + } + ] + }, + project1.slug + ) + await variableService.createVariable( + user1, + { + name: 'Variable 2', + entries: [ + { + value: 'Variable 2 value', + environmentSlug: environment1.slug + } + ] + }, + project1.slug + ) + + // Update the value of a secret to add a SecretVersion + await secretService.updateSecret(user1, secret1.secret.slug, { + entries: [ + { + value: 'Updated Secret 1 value', + environmentSlug: environment1.slug + } + ] + }) + + // Update the value of a variable to add a VariableVersion + await variableService.updateVariable(user1, variable1.variable.slug, { + entries: [ + { + value: 'Updated Variable 1 value', + environmentSlug: environment1.slug + } + ] + }) + + const response = await app.inject({ + method: 'GET', + url: `/environment/all/${project1.slug}?page=0&limit=10`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + const devEnvironment = response + .json() + .items.find((env: Environment) => env.slug === environment1.slug) + + expect(devEnvironment.secrets).toBe(2) + expect(devEnvironment.variables).toBe(2) + }) + it('should not be able to fetch all environments of a project that does not exist', async () => { const response = await app.inject({ method: 'GET', diff --git a/apps/api/src/environment/service/environment.service.ts b/apps/api/src/environment/service/environment.service.ts index 57a0be15a..1d708cc40 100644 --- a/apps/api/src/environment/service/environment.service.ts +++ b/apps/api/src/environment/service/environment.service.ts @@ -93,6 +93,16 @@ export class EnvironmentService { id: user.id } } + }, + include: { + lastUpdatedBy: { + select: { + id: true, + name: true, + profilePictureUrl: true, + email: true + } + } } }) @@ -312,6 +322,17 @@ export class EnvironmentService { [sort]: order } }) + + // Parse the secret and variable counts for each environment + for (const environment of items) { + const [secrets, variables] = await Promise.all([ + this.getSecretCount(environment.id), + this.getVariableCount(environment.id) + ]) + environment['secrets'] = secrets + environment['variables'] = variables + } + // Calculate metadata for pagination const totalCount = await this.prisma.environment.count({ where: { @@ -422,4 +443,42 @@ export class EnvironmentService { ) } } + + /** + * Counts the number of unique secrets in an environment. + * @param environmentId The ID of the environment to count secrets for. + * @returns The number of unique secrets in the environment. + * @private + */ + private async getSecretCount( + environmentId: Environment['id'] + ): Promise { + const secrets = await this.prisma.secretVersion.findMany({ + distinct: ['secretId'], + where: { + environmentId + } + }) + + return secrets.length + } + + /** + * Counts the number of unique variables in an environment. + * @param environmentId The ID of the environment to count variables for. + * @returns The number of unique variables in the environment. + * @private + */ + private async getVariableCount( + environmentId: Environment['id'] + ): Promise { + const variables = await this.prisma.variableVersion.findMany({ + distinct: ['variableId'], + where: { + environmentId + } + }) + + return variables.length + } } diff --git a/apps/api/src/prisma/migrations/20250123150220_add_rotate_after_in_secret/migration.sql b/apps/api/src/prisma/migrations/20250123150220_add_rotate_after_in_secret/migration.sql new file mode 100644 index 000000000..c65fc2be1 --- /dev/null +++ b/apps/api/src/prisma/migrations/20250123150220_add_rotate_after_in_secret/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Secret" ADD COLUMN "rotateAfter" INTEGER; diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 146d2cdae..425ce4919 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -371,14 +371,15 @@ model SecretVersion { } model Secret { - id String @id @default(cuid()) - name String - slug String @unique - versions SecretVersion[] // Stores the versions of the secret - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - rotateAt DateTime? - note String? + id String @id @default(cuid()) + name String + slug String @unique + versions SecretVersion[] // Stores the versions of the secret + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + rotateAt DateTime? + rotateAfter Int? + note String? lastUpdatedBy User? @relation(fields: [lastUpdatedById], references: [id], onUpdate: Cascade, onDelete: SetNull) lastUpdatedById String? diff --git a/apps/api/src/secret/secret.e2e.spec.ts b/apps/api/src/secret/secret.e2e.spec.ts index 621901de8..c0f17cab7 100644 --- a/apps/api/src/secret/secret.e2e.spec.ts +++ b/apps/api/src/secret/secret.e2e.spec.ts @@ -636,6 +636,7 @@ describe('Secret Controller Tests', () => { }, createdAt: secret1.createdAt.toISOString(), updatedAt: secret1.updatedAt.toISOString(), + rotateAfter: secret1.rotateAfter, rotateAt: secret1.rotateAt.toISOString() }) expect(values.length).toBe(1) @@ -686,6 +687,7 @@ describe('Secret Controller Tests', () => { expect(response.json().items.length).toBe(1) const { secret, values } = response.json().items[0] + expect(secret).toStrictEqual({ id: secret1.id, name: secret1.name, @@ -699,6 +701,7 @@ describe('Secret Controller Tests', () => { }, createdAt: secret1.createdAt.toISOString(), updatedAt: expect.any(String), + rotateAfter: secret1.rotateAfter, rotateAt: secret1.rotateAt.toISOString() }) expect(values.length).toBe(1) @@ -752,6 +755,7 @@ describe('Secret Controller Tests', () => { }, createdAt: secret1.createdAt.toISOString(), updatedAt: secret1.updatedAt.toISOString(), + rotateAfter: secret1.rotateAfter, rotateAt: secret1.rotateAt.toISOString() }) expect(values.length).toBe(1) @@ -1114,4 +1118,110 @@ describe('Secret Controller Tests', () => { expect(response.statusCode).toBe(401) }) }) + + describe('Rotate Secrets Tests', () => { + it('should have not created a new secret version when there is no rotation defined', async () => { + const secretWithoutRotation = ( + await secretService.createSecret( + user1, + { + name: 'Secret', + note: 'Secret note', + rotateAfter: 'never', + entries: [ + { + environmentSlug: environment1.slug, + value: 'Secret value' + } + ] + }, + project1.slug + ) + ).secret as Secret + + await secretService.rotateSecrets() + + const secretVersion = await prisma.secretVersion.findFirst({ + where: { + secretId: secretWithoutRotation.id, + environmentId: environment1.id + }, + orderBy: { + version: 'desc' + }, + take: 1 + }) + + expect(secretVersion).toBeDefined() + expect(secretVersion.version).toBe(1) + expect(secretVersion.environmentId).toBe(environment1.id) + }) + + it('should have not created a new secret version when rotation is not due', async () => { + await secretService.rotateSecrets() + + const secretVersion = await prisma.secretVersion.findFirst({ + where: { + secretId: secret1.id, + environmentId: environment1.id + }, + orderBy: { + version: 'desc' + }, + take: 1 + }) + + expect(secretVersion).toBeDefined() + expect(secretVersion.version).toBe(1) + expect(secretVersion.environmentId).toBe(environment1.id) + }) + + it('should have created a new secret version when rotation is due', async () => { + const currentTime = new Date() + + currentTime.setHours(currentTime.getHours() + secret1.rotateAfter) + + await secretService.rotateSecrets(currentTime) + + const secretVersion = await prisma.secretVersion.findFirst({ + where: { + secretId: secret1.id, + environmentId: environment1.id + }, + orderBy: { + version: 'desc' + }, + take: 1 + }) + + expect(secretVersion).toBeDefined() + expect(secretVersion.version).toBe(2) + expect(secretVersion.environmentId).toBe(environment1.id) + }) + + it('should have created a SECRET_UPDATED event when rotation is due', async () => { + const currentTime = new Date() + + currentTime.setHours(currentTime.getHours() + secret1.rotateAfter) + + await secretService.rotateSecrets(currentTime) + + const events = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.SECRET + ) + + const event = events.items[0] + + expect(event.source).toBe(EventSource.SECRET) + expect(event.triggerer).toBe(EventTriggerer.SYSTEM) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.SECRET_UPDATED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBe(secret1.id) + expect(event.title).toBe('Secret rotated') + }) + }) }) diff --git a/apps/api/src/secret/service/secret.service.ts b/apps/api/src/secret/service/secret.service.ts index 0a46ad579..103e99859 100644 --- a/apps/api/src/secret/service/secret.service.ts +++ b/apps/api/src/secret/service/secret.service.ts @@ -10,6 +10,7 @@ import { Authority, Environment, EventSource, + EventTriggerer, EventType, Project, Secret, @@ -33,7 +34,13 @@ import generateEntitySlug from '@/common/slug-generator' import { decrypt, encrypt } from '@/common/cryptography' import { createEvent } from '@/common/event' import { getEnvironmentIdToSlugMap } from '@/common/environment' -import { getSecretWithValues, SecretWithValues } from '@/common/secret' +import { + getSecretWithValues, + generateSecretValue, + SecretWithValues +} from '@/common/secret' +import { Cron, CronExpression } from '@nestjs/schedule' +import { SecretWithProject } from '../secret.types' @Injectable() export class SecretService { @@ -96,6 +103,7 @@ export class SecretService { slug: await generateEntitySlug(dto.name, 'SECRET', this.prisma), note: dto.note, rotateAt: addHoursToDate(dto.rotateAfter), + rotateAfter: +dto.rotateAfter, versions: shouldCreateRevisions && { createMany: { data: await Promise.all( @@ -218,9 +226,12 @@ export class SecretService { ? await generateEntitySlug(dto.name, 'SECRET', this.prisma) : undefined, note: dto.note, - rotateAt: dto.rotateAfter - ? addHoursToDate(dto.rotateAfter) - : undefined, + ...(dto.rotateAfter + ? { + rotateAt: addHoursToDate(dto.rotateAfter), + rotateAfter: +dto.rotateAfter + } + : {}), lastUpdatedById: user.id }, select: { @@ -789,6 +800,131 @@ export class SecretService { return { items, metadata } } + /** + * Rotate values of secrets that have reached their rotation time + * @param currentTime the current time + */ + @Cron(CronExpression.EVERY_HOUR) + async rotateSecrets(currentTime?: Date): Promise { + // Fetch all secrets that have reached their rotation time + currentTime = currentTime ?? new Date() + + const secrets = await this.prisma.secret.findMany({ + where: { + rotateAt: { + lt: currentTime + } + }, + include: { + project: true + } + }) + + // Rotate secrets + await Promise.all(secrets.map((secret) => this.rotateSecret(secret))) + + this.logger.log('Secrets rotated') + } + + private async rotateSecret(secret: SecretWithProject): Promise { + const op = [] + + // Update the secret + op.push( + this.prisma.secret.update({ + where: { + id: secret.id + }, + data: { + rotateAt: addHoursToDate(secret.rotateAfter) + }, + select: { + name: true + } + }) + ) + + // Fetch the latest version of the secret for all environments + const latestEnvironmentVersions = await this.prisma.secretVersion.groupBy({ + where: { + secretId: secret.id + }, + by: ['environmentId'], + _max: { + version: true + } + }) + + // Create new versions for all environments + for (const latestEnvironmentVersion of latestEnvironmentVersions) { + // Create the new version + op.push( + this.prisma.secretVersion.create({ + data: { + value: await encrypt( + secret.project.publicKey, + generateSecretValue() + ), + version: latestEnvironmentVersion._max.version + 1, + environmentId: latestEnvironmentVersion.environmentId, + secretId: secret.id + }, + select: { + environment: { + select: { + id: true + } + }, + value: true + } + }) + ) + } + + // Make the transaction + const tx = await this.prisma.$transaction(op) + + const updatedSecret = tx[0] + const updatedVersions = tx.slice(1) + + // Notify the new secret version through Redis + for (const updatedVersion of updatedVersions) { + try { + await this.redis.publish( + CHANGE_NOTIFIER_RSC, + JSON.stringify({ + environmentId: updatedVersion.environment.id, + name: updatedSecret.name, + value: updatedVersion.value, + isPlaintext: true + } as ChangeNotificationEvent) + ) + } catch (error) { + this.logger.error(`Error publishing secret update to Redis: ${error}`) + } + } + + await createEvent( + { + triggerer: EventTriggerer.SYSTEM, + entity: secret, + type: EventType.SECRET_UPDATED, + source: EventSource.SECRET, + title: `Secret rotated`, + metadata: { + secretId: secret.id, + name: secret.name, + projectId: secret.projectId, + projectName: secret.project.name + }, + workspaceId: secret.project.workspaceId + }, + this.prisma + ) + + this.logger.log(`Secret ${secret.id} rotated`) + } + /** * Checks if a secret with a given name already exists in the project * @throws {ConflictException} if the secret already exists diff --git a/apps/api/src/user/service/user.service.ts b/apps/api/src/user/service/user.service.ts index 8a45be109..74425baae 100644 --- a/apps/api/src/user/service/user.service.ts +++ b/apps/api/src/user/service/user.service.ts @@ -6,7 +6,7 @@ import { UnauthorizedException } from '@nestjs/common' import { UpdateUserDto } from '../dto/update.user/update.user' -import { AuthProvider, User, Workspace } from '@prisma/client' +import { AuthProvider, User } from '@prisma/client' import { PrismaService } from '@/prisma/prisma.service' import { CreateUserDto } from '../dto/create.user/create.user' import { IMailService, MAIL_SERVICE } from '@/mail/services/interface.service' @@ -33,15 +33,7 @@ export class UserService { } async getSelf(user: UserWithWorkspace) { - const defaultWorkspace: Workspace | null = - await this.prisma.workspace.findFirst({ - where: { - ownerId: user.id, - isDefault: true - } - }) - - return { ...user, defaultWorkspace } + return user } async updateSelf(user: UserWithWorkspace, dto: UpdateUserDto) { diff --git a/apps/api/src/workspace/service/workspace.service.ts b/apps/api/src/workspace/service/workspace.service.ts index b965a7d3d..7e6f22428 100644 --- a/apps/api/src/workspace/service/workspace.service.ts +++ b/apps/api/src/workspace/service/workspace.service.ts @@ -171,12 +171,18 @@ export class WorkspaceService { user: User, workspaceSlug: Workspace['slug'] ): Promise { - return await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { slug: workspaceSlug }, - authorities: [Authority.READ_USERS], - prisma: this.prisma - }) + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_USERS], + prisma: this.prisma + }) + + return { + ...workspace, + isDefault: workspace.isDefault && workspace.ownerId === user.id + } } /** @@ -197,7 +203,7 @@ export class WorkspaceService { order: string, search: string ) { - //get all workspaces of user for page with limit + // Get all workspaces of user for page with limit const items = await this.prisma.workspace.findMany({ skip: page * limit, take: Number(limit), @@ -217,6 +223,13 @@ export class WorkspaceService { } }) + for (const workspace of items) { + workspace['projects'] = await this.getProjectsOfWorkspace( + workspace.id, + user.id + ) + } + // get total count of workspaces of the user const totalCount = await this.prisma.workspace.count({ where: { @@ -241,7 +254,13 @@ export class WorkspaceService { search }) - return { items, metadata } + return { + items: items.map((item) => ({ + ...item, + isDefault: item.isDefault && item.ownerId === user.id + })), + metadata + } } /** @@ -647,4 +666,41 @@ export class WorkspaceService { })) > 0 ) } + + /** + * Retrieves the count of projects within a workspace that a user has permission to access. + * + * @param workspaceId The ID of the workspace to retrieve projects from. + * @param userId The ID of the user whose access permissions are being checked. + * @returns The number of projects the user has authority to access within the specified workspace. + * @private + */ + + private async getProjectsOfWorkspace( + workspaceId: Workspace['id'], + userId: User['id'] + ) { + const projects = await this.prisma.project.findMany({ + where: { + workspaceId + } + }) + + let accessibleProjectCount = 0 + + for (const project of projects) { + const hasAuthority = + await this.authorityCheckerService.checkAuthorityOverProject({ + userId, + entity: { slug: project.slug }, + authorities: [Authority.READ_PROJECT], + prisma: this.prisma + }) + + if (hasAuthority) { + accessibleProjectCount++ + } + } + return accessibleProjectCount + } } diff --git a/apps/api/src/workspace/workspace.e2e.spec.ts b/apps/api/src/workspace/workspace.e2e.spec.ts index 0f0cd1f2c..71c5178c1 100644 --- a/apps/api/src/workspace/workspace.e2e.spec.ts +++ b/apps/api/src/workspace/workspace.e2e.spec.ts @@ -456,6 +456,30 @@ describe('Workspace Controller Tests', () => { ) }) + it('should be able to fetch the number of projects accessible by the user', async () => { + // Create a project accessible to the user + await projectService.createProject(user1, workspace1.slug, { + name: 'Project 1', + description: 'Description 1', + accessLevel: ProjectAccessLevel.GLOBAL + }) + + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user1.email + }, + url: '/workspace' + }) + + expect(response.statusCode).toBe(200) + const workspaceOfProject = response + .json() + .items.find((workspace: any) => workspace.slug === workspace1.slug) + + expect(workspaceOfProject.projects).toBe(1) + }) + it('should be able to fetch the 2nd page of the workspaces the user is a member of', async () => { await createMembership(memberRole.id, user2.id, workspace1.id, prisma) const response = await app.inject({ diff --git a/apps/cli/package.json b/apps/cli/package.json index f14ea92f9..40dfe3d60 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -17,8 +17,7 @@ "build": "node esbuild.config.js", "start": "node dist/index.cjs", "dev": "pnpm build && node dist/index.cjs", - "lint": "eslint \"src/**/*.ts\" --fix", - "publish": "pnpm build && pnpm publish --access public --provenance" + "lint": "eslint \"src/**/*.ts\" --fix" }, "keywords": [ "keyshade", diff --git a/apps/platform/next.config.mjs b/apps/platform/next.config.mjs index 7dd7d9842..7baa72b43 100644 --- a/apps/platform/next.config.mjs +++ b/apps/platform/next.config.mjs @@ -24,6 +24,9 @@ const nextConfig = { transpilePackages: ['geist'], eslint: { ignoreDuringBuilds: true + }, + typescript: { + ignoreBuildErrors: true } } diff --git a/apps/platform/public/svg/dashboard/config.svg b/apps/platform/public/svg/dashboard/config.svg deleted file mode 100644 index 5c2ce5aa6..000000000 --- a/apps/platform/public/svg/dashboard/config.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/apps/platform/public/svg/dashboard/environment.svg b/apps/platform/public/svg/dashboard/environment.svg index 239bd48ae..74f09fe49 100644 --- a/apps/platform/public/svg/dashboard/environment.svg +++ b/apps/platform/public/svg/dashboard/environment.svg @@ -1,4 +1,4 @@ - + diff --git a/apps/platform/public/svg/dashboard/index.ts b/apps/platform/public/svg/dashboard/index.ts index 9354166aa..2e741eb80 100644 --- a/apps/platform/public/svg/dashboard/index.ts +++ b/apps/platform/public/svg/dashboard/index.ts @@ -1,6 +1,6 @@ import EnvironmentSVG from './environment.svg' -import ConfigSVG from './config.svg' import SecretSVG from './secret.svg' import FolderSVG from './folder.svg' +import VariableSVG from './variable.svg' -export { EnvironmentSVG, ConfigSVG, SecretSVG, FolderSVG } +export { EnvironmentSVG, SecretSVG, FolderSVG, VariableSVG } diff --git a/apps/platform/public/svg/dashboard/secret.svg b/apps/platform/public/svg/dashboard/secret.svg index 83fb879f9..d244d691d 100644 --- a/apps/platform/public/svg/dashboard/secret.svg +++ b/apps/platform/public/svg/dashboard/secret.svg @@ -1,4 +1,4 @@ - + + + + + + + diff --git a/apps/platform/src/app/(main)/layout.tsx b/apps/platform/src/app/(main)/layout.tsx index 03971be40..b755db1ef 100644 --- a/apps/platform/src/app/(main)/layout.tsx +++ b/apps/platform/src/app/(main)/layout.tsx @@ -1,6 +1,6 @@ import React from 'react' -import Navbar from '@/components/shared/navbar' import Sidebar from '@/components/shared/sidebar' +import Navbar from '@/components/shared/navbar' export default function AppLayout({ children @@ -11,8 +11,10 @@ export default function AppLayout({
- -
{children}
+
+ +
{children}
+
) diff --git a/apps/platform/src/app/(main)/page.tsx b/apps/platform/src/app/(main)/page.tsx index d25007624..307aff6d5 100644 --- a/apps/platform/src/app/(main)/page.tsx +++ b/apps/platform/src/app/(main)/page.tsx @@ -1,5 +1,5 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import type { GetAllProjectsResponse } from '@keyshade/schema' import { FolderSVG } from '@public/svg/dashboard' import { toast } from 'sonner' @@ -11,56 +11,59 @@ import CreateProjectDialogue from '@/components/dashboard/project/createProjectD import { createProjectOpenAtom, selectedWorkspaceAtom, - projectsOfWorkspaceAtom + projectsOfWorkspaceAtom, + deleteProjectOpenAtom, + selectedProjectAtom } from '@/store' import EditProjectSheet from '@/components/dashboard/project/editProjectSheet' import { Button } from '@/components/ui/button' +import ConfirmDeleteProject from '@/components/dashboard/project/confirmDeleteProject' export default function Index(): JSX.Element { const [loading, setLoading] = useState(false) const setIsCreateProjectDialogOpen = useSetAtom(createProjectOpenAtom) const selectedWorkspace = useAtomValue(selectedWorkspaceAtom) + const isDeleteProjectOpen = useAtomValue(deleteProjectOpenAtom) + const selectedProject = useAtomValue(selectedProjectAtom) // Projects to be displayed in the dashboard const [projects, setProjects] = useAtom(projectsOfWorkspaceAtom) const isProjectsEmpty = useMemo(() => projects.length === 0, [projects]) - // If a workspace is selected, we want to fetch all the projects - // under that workspace and display it in the dashboard. - useEffect(() => { - async function getAllProjects() { - setLoading(true) + const getAllProjects = useCallback(async () => { + setLoading(true) - if (selectedWorkspace) { - const { success, error, data } = - await ControllerInstance.getInstance().projectController.getAllProjects( - { workspaceSlug: selectedWorkspace.slug }, - {} - ) + if (selectedWorkspace) { + const { success, error, data } = + await ControllerInstance.getInstance().projectController.getAllProjects( + { workspaceSlug: selectedWorkspace.slug }, + {} + ) - if (success && data) { - setProjects(data.items) - } else { - toast.error('Something went wrong!', { - description: ( -

- Something went wrong while fetching projects. Check console for - more info. -

- ) - }) - // eslint-disable-next-line no-console -- we need to log the error - console.error(error) - } + if (success && data) { + setProjects(data.items) + } else { + toast.error('Something went wrong!', { + description: ( +

+ Something went wrong while fetching projects. Check console for + more info. +

+ ) + }) + // eslint-disable-next-line no-console -- we need to log the error + console.error(error) } - - setLoading(false) } - getAllProjects() + setLoading(false) }, [selectedWorkspace, setProjects]) + useEffect(() => { + getAllProjects() + }, [getAllProjects, selectedWorkspace, setProjects]) + return (
@@ -94,6 +97,10 @@ export default function Index(): JSX.Element {
)} + {isDeleteProjectOpen && selectedProject ? ( + + ) : null} +
) diff --git a/apps/platform/src/app/(main)/project/[project]/@environment/page.tsx b/apps/platform/src/app/(main)/project/[project]/@environment/page.tsx new file mode 100644 index 000000000..9c092049f --- /dev/null +++ b/apps/platform/src/app/(main)/project/[project]/@environment/page.tsx @@ -0,0 +1,124 @@ +'use client' + +import { useEffect } from 'react' +import type { + ClientResponse, + GetAllEnvironmentsOfProjectResponse +} from '@keyshade/schema' +import { EnvironmentSVG } from '@public/svg/dashboard' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { toast } from 'sonner' +import { + createEnvironmentOpenAtom, + selectedProjectAtom, + deleteEnvironmentOpenAtom, + editEnvironmentOpenAtom, + environmentsOfProjectAtom, + selectedEnvironmentAtom +} from '@/store' +import EnvironmentCard from '@/components/dashboard/environment/environmentCard' +import ConfirmDeleteEnvironment from '@/components/dashboard/environment/confirmDeleteEnvironment' +import EditEnvironmentDialogue from '@/components/dashboard/environment/editEnvironmentSheet' +import ControllerInstance from '@/lib/controller-instance' +import { Button } from '@/components/ui/button' + +function EnvironmentPage(): React.JSX.Element { + const setIsCreateEnvironmentOpen = useSetAtom(createEnvironmentOpenAtom) + const isDeleteEnvironmentOpen = useAtomValue(deleteEnvironmentOpenAtom) + const isEditEnvironmentOpen = useAtomValue(editEnvironmentOpenAtom) + const [environments, setEnvironments] = useAtom(environmentsOfProjectAtom) + const selectedProject = useAtomValue(selectedProjectAtom) + const selectedEnvironment = useAtomValue(selectedEnvironmentAtom) + + useEffect(() => { + const getAllEnvironments = async () => { + if (!selectedProject) { + toast.error('No project selected', { + description: ( +

+ No project selected. Please select a project. +

+ ) + }) + return + } + + const { + success, + error, + data + }: ClientResponse = + await ControllerInstance.getInstance().environmentController.getAllEnvironmentsOfProject( + { projectSlug: selectedProject.slug }, + {} + ) + + if (success && data) { + setEnvironments(data.items) + } else { + toast.error('Something went wrong!', { + description: ( +

+ Something went wrong while fetching environments. Check console + for more info. +

+ ) + }) + // eslint-disable-next-line no-console -- we need to log the error + console.error(error) + } + } + + getAllEnvironments() + }, [selectedProject, setEnvironments]) + + return ( +
+ {/* Showing this when there are no environments present */} + {environments.length === 0 ? ( +
+ + +
+

+ Declare your first environment +

+

+ Declare and store a environment against different environments +

+
+ + +
+ ) : ( + // Showing this when environments are present +
+ {environments.map((environment) => ( + + ))} + + {/* Delete environment alert dialog */} + {isDeleteEnvironmentOpen && selectedEnvironment ? ( + + ) : null} + + {/* Edit environment dialog */} + {isEditEnvironmentOpen && selectedEnvironment ? ( + + ) : null} +
+ )} +
+ ) +} + +export default EnvironmentPage diff --git a/apps/platform/src/app/(main)/project/[project]/@secret/page.tsx b/apps/platform/src/app/(main)/project/[project]/@secret/page.tsx index 3910a7fb9..5082c8628 100644 --- a/apps/platform/src/app/(main)/project/[project]/@secret/page.tsx +++ b/apps/platform/src/app/(main)/project/[project]/@secret/page.tsx @@ -1,10 +1,10 @@ 'use client' -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { extend } from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' -import { SecretLogoSVG } from '@public/svg/secret' import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { toast } from 'sonner' +import { SecretSVG } from '@public/svg/dashboard' import { Accordion } from '@/components/ui/accordion' import { ScrollArea } from '@/components/ui/scroll-area' import ControllerInstance from '@/lib/controller-instance' @@ -24,6 +24,10 @@ function SecretPage(): React.JSX.Element { const [secrets, setSecrets] = useAtom(secretsOfProjectAtom) const [isLoading, setIsLoading] = useState(true) const selectedProject = useAtomValue(selectedProjectAtom) + const isDecrypted = useMemo( + () => selectedProject?.storePrivateKey === true || false, + [selectedProject] + ) useEffect(() => { setIsLoading(true) @@ -42,13 +46,21 @@ function SecretPage(): React.JSX.Element { const { success, error, data } = await ControllerInstance.getInstance().secretController.getAllSecretsOfProject( - { projectSlug: selectedProject.slug }, + { projectSlug: selectedProject.slug, decryptValue: isDecrypted }, {} ) if (success && data) { setSecrets(data.items) } else { + toast.error('Something went wrong!', { + description: ( +

+ Something went wrong while fetching secrets. Check console for + more info. +

+ ) + }) // eslint-disable-next-line no-console -- we need to log the error console.error(error) } @@ -57,7 +69,7 @@ function SecretPage(): React.JSX.Element { getAllSecretsByProjectSlug() setIsLoading(false) - }, [selectedProject, setSecrets]) + }, [isDecrypted, selectedProject, setSecrets]) if (isLoading) { return ( @@ -70,10 +82,10 @@ function SecretPage(): React.JSX.Element { } return ( -
+
{secrets.length === 0 ? ( -
- +
+

@@ -85,21 +97,25 @@ function SecretPage(): React.JSX.Element {

) : ( - + - {secrets.map(({ secret, values }) => ( - + {secrets.map((secret) => ( + ))} diff --git a/apps/platform/src/app/(main)/project/[project]/@variable/page.tsx b/apps/platform/src/app/(main)/project/[project]/@variable/page.tsx index e9bbe3623..47e2b9ce0 100644 --- a/apps/platform/src/app/(main)/project/[project]/@variable/page.tsx +++ b/apps/platform/src/app/(main)/project/[project]/@variable/page.tsx @@ -5,7 +5,7 @@ import type { ClientResponse, GetAllVariablesOfProjectResponse } from '@keyshade/schema' -import { FolderSVG } from '@public/svg/dashboard' +import { VariableSVG } from '@public/svg/dashboard' import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { toast } from 'sonner' import { @@ -17,7 +17,7 @@ import { variablesOfProjectAtom } from '@/store' import VariableCard from '@/components/dashboard/variable/variableCard' -import ConfirmDelete from '@/components/dashboard/variable/confirmDeleteVariable' +import ConfirmDeleteVariable from '@/components/dashboard/variable/confirmDeleteVariable' import EditVariableDialog from '@/components/dashboard/variable/editVariableDialogue' import ControllerInstance from '@/lib/controller-instance' import { Button } from '@/components/ui/button' @@ -56,6 +56,14 @@ function VariablePage(): React.JSX.Element { if (success && data) { setVariables(data.items) } else { + toast.error('Something went wrong!', { + description: ( +

+ Something went wrong while fetching variables. Check console for + more info. +

+ ) + }) // eslint-disable-next-line no-console -- we need to log the error console.error(error) } @@ -66,12 +74,12 @@ function VariablePage(): React.JSX.Element { return (
{/* Showing this when there are no variables present */} {variables.length === 0 ? ( -
- +
+

@@ -83,7 +91,7 @@ function VariablePage(): React.JSX.Element {

+ + + + + Add a new environment + + + Add a new environment to the project. You can later on add more + environments and variables to this environment. + + + +
+
+
+ + + setNewEnvironmentData({ + ...newEnvironmentData, + environmentName: e.target.value + }) + } + placeholder="Enter the key of the environment" + value={newEnvironmentData.environmentName} + /> +
+ +
+ + + setNewEnvironmentData({ + ...newEnvironmentData, + environmentDescription: e.target.value + }) + } + placeholder="Enter an optional description of the environment" + value={newEnvironmentData.environmentDescription} + /> +
+ +
+ +
+
+
+
+ + ) +} diff --git a/apps/platform/src/components/dashboard/environment/confirmDeleteEnvironment/index.tsx b/apps/platform/src/components/dashboard/environment/confirmDeleteEnvironment/index.tsx new file mode 100644 index 000000000..07a539c2d --- /dev/null +++ b/apps/platform/src/components/dashboard/environment/confirmDeleteEnvironment/index.tsx @@ -0,0 +1,136 @@ +'use client' + +import React, { useCallback, useEffect } from 'react' +import { TrashSVG } from '@public/svg/shared' +import { toast } from 'sonner' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog' +import ControllerInstance from '@/lib/controller-instance' +import { + deleteEnvironmentOpenAtom, + selectedEnvironmentAtom, + environmentsOfProjectAtom +} from '@/store' + +export default function ConfirmDeleteEnvironment(): React.JSX.Element { + const selectedEnvironment = useAtomValue(selectedEnvironmentAtom) + const [isDeleteEnvironmentOpen, setIsDeleteEnvironmentOpen] = useAtom( + deleteEnvironmentOpenAtom + ) + const setEnvironments = useSetAtom(environmentsOfProjectAtom) + + const handleClose = useCallback(() => { + setIsDeleteEnvironmentOpen(false) + }, [setIsDeleteEnvironmentOpen]) + + const deleteEnvironment = useCallback(async () => { + if (selectedEnvironment === null) { + toast.error('No environment selected', { + description: ( +

+ No environment selected. Please select a environment. +

+ ) + }) + return + } + + const environmentSlug = selectedEnvironment.slug + + const { success, error } = + await ControllerInstance.getInstance().environmentController.deleteEnvironment( + { slug: environmentSlug }, + {} + ) + + if (success) { + toast.success('Environment deleted successfully', { + description: ( +

+ The environment has been deleted. +

+ ) + }) + + // Remove the environment from the store + setEnvironments((prevEnvironments) => + prevEnvironments.filter( + (environment) => environment.slug !== environmentSlug + ) + ) + } + if (error) { + toast.error('Something went wrong!', { + description: ( +

+ Something went wrong while deleting the environment. Check console + for more info. +

+ ) + }) + // eslint-disable-next-line no-console -- we need to log the error + console.error(error) + } + + handleClose() + }, [setEnvironments, selectedEnvironment, handleClose]) + + //Cleaning the pointer events for the context menu after closing the alert dialog + const cleanup = useCallback(() => { + document.body.style.pointerEvents = '' + document.documentElement.style.pointerEvents = '' + }, []) + + useEffect(() => { + if (!isDeleteEnvironmentOpen) { + cleanup() + } + return () => cleanup() + }, [isDeleteEnvironmentOpen, cleanup]) + + return ( + + + +
+ + + Do you really want to delete this environment? + +
+ + This action cannot be undone. This will permanently delete your + environment and remove your environment data from our servers. + +
+ + + Cancel + + + Yes, delete the environment + + +
+
+ ) +} diff --git a/apps/platform/src/components/dashboard/environment/editEnvironmentSheet/index.tsx b/apps/platform/src/components/dashboard/environment/editEnvironmentSheet/index.tsx new file mode 100644 index 000000000..fd5ff44c8 --- /dev/null +++ b/apps/platform/src/components/dashboard/environment/editEnvironmentSheet/index.tsx @@ -0,0 +1,185 @@ +'use client' + +import { useCallback, useState } from 'react' +import { toast } from 'sonner' +import type { UpdateEnvironmentRequest } from '@keyshade/schema' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle +} from '@/components/ui/sheet' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Label } from '@/components/ui/label' +import ControllerInstance from '@/lib/controller-instance' +import { + editEnvironmentOpenAtom, + selectedEnvironmentAtom, + environmentsOfProjectAtom +} from '@/store' + +export default function EditEnvironmentDialogue(): React.JSX.Element { + const [isEditEnvironmentOpen, setIsEditEnvironmentOpen] = useAtom( + editEnvironmentOpenAtom + ) + const selectedEnvironment = useAtomValue(selectedEnvironmentAtom) + const setEnvironments = useSetAtom(environmentsOfProjectAtom) + + const [requestData, setRequestData] = useState<{ + name: string | undefined + description: string | undefined + }>({ + name: selectedEnvironment?.name, + description: selectedEnvironment?.description || '' + }) + + const updateRequestData = useCallback( + (name: string, value: string) => { + setRequestData((prev) => ({ + ...prev, + [name]: value + })) + }, + [setRequestData] + ) + + const updateEnvironment = useCallback(async () => { + if (!selectedEnvironment) { + toast.error('No environment selected', { + description: ( +

+ No environment selected. Please select an environment. +

+ ) + }) + return + } + + const request: UpdateEnvironmentRequest = { + slug: selectedEnvironment.slug, + description: + requestData.description === '' ? undefined : requestData.description, + name: + requestData.name === selectedEnvironment.name || requestData.name === '' + ? undefined + : requestData.name + } + + const { success, error, data } = + await ControllerInstance.getInstance().environmentController.updateEnvironment( + request, + {} + ) + + if (success && data) { + toast.success('Environment updated successfully', { + description: ( +

+ You successfully edited the environment +

+ ) + }) + + // Update the environment in the store + setEnvironments((prev) => { + return prev.map((environment) => { + if (environment.slug === selectedEnvironment.slug) { + return { + ...environment, + slug: data.slug, + name: data.name, + description: data.description + } + } + return environment + }) + }) + + // Close the sheet + setIsEditEnvironmentOpen(false) + } + + if (error) { + toast.error('Something went wrong!', { + description: ( +

+ Something went wrong while updating the environment. Check console + for more info. +

+ ) + }) + + // eslint-disable-next-line no-console -- we need to log the error + console.log(error) + } + }, [ + selectedEnvironment, + requestData.description, + requestData.name, + setEnvironments, + setIsEditEnvironmentOpen + ]) + + return ( + { + setIsEditEnvironmentOpen(open) + }} + open={isEditEnvironmentOpen} + > + + + + Edit this environment + + + Edit the environment name or the description + + +
+
+ + updateRequestData(e.target.name, e.target.value)} + value={requestData.name} + /> +
+
+ +