mirror of
https://github.com/Infisical/infisical.git
synced 2025-08-05 07:30:33 +00:00
Compare commits
48 Commits
snyk-upgra
...
sem-ver-gh
Author | SHA1 | Date | |
---|---|---|---|
|
84616d063d | ||
|
344dc93d3e | ||
|
560f3b384a | ||
|
d497c2a219 | ||
|
dea064e7fa | ||
|
1a103715f6 | ||
|
8d03869ee4 | ||
|
d8ff0bef0d | ||
|
29b96246b9 | ||
|
8503c9355b | ||
|
ddf0a272f6 | ||
|
e3980f8666 | ||
|
d52534b185 | ||
|
db07a033e1 | ||
|
476d0be101 | ||
|
2eff7b6128 | ||
|
d8a781af1f | ||
|
8b42f4f998 | ||
|
da127a3c0a | ||
|
d4aa75a182 | ||
|
d097003e9b | ||
|
b615a5084e | ||
|
379f086828 | ||
|
f11a7d0f87 | ||
|
f5aeb85c62 | ||
|
2966aa6eda | ||
|
b1f2515731 | ||
|
c5094ec37d | ||
|
6c745f617d | ||
|
82995fbd02 | ||
|
38f578c4ae | ||
|
65b12eee5e | ||
|
9043db4727 | ||
|
0eceeb6aa9 | ||
|
2d2bbbd0ad | ||
|
c9b4e11539 | ||
|
fd4ea97e18 | ||
|
49d2ecc460 | ||
|
ca31a70032 | ||
|
3334338eaa | ||
|
6d5e281811 | ||
|
87d36ac47a | ||
|
b72e1198df | ||
|
837ea2ef40 | ||
|
b462ca3e89 | ||
|
f639f682c9 | ||
|
01d9695153 | ||
|
21eb1815c4 |
11
.env.example
11
.env.example
@@ -1,5 +1,6 @@
|
||||
# Keys
|
||||
# Required key for platform encryption/decryption ops
|
||||
# THIS IS A SAMPLE ENCRYPTION KEY AND SHOULD NOT BE USED FOR PRODUCTION
|
||||
ENCRYPTION_KEY=6c1fe4e407b8911c104518103505b218
|
||||
|
||||
# JWT
|
||||
@@ -30,11 +31,11 @@ MONGO_PASSWORD=example
|
||||
# Required
|
||||
SITE_URL=http://localhost:8080
|
||||
|
||||
# Mail/SMTP
|
||||
SMTP_HOST='smtp-server'
|
||||
SMTP_PORT='1025'
|
||||
SMTP_NAME='local'
|
||||
SMTP_USERNAME='team@infisical.com'
|
||||
# Mail/SMTP
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_NAME=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
|
||||
# Integration
|
||||
|
21
.github/workflows/docker-image.yml
vendored
21
.github/workflows/docker-image.yml
vendored
@@ -1,12 +1,17 @@
|
||||
name: Build, Publish and Deploy to Gamma
|
||||
on: [workflow_dispatch]
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "infisical/v*.*.*"
|
||||
|
||||
jobs:
|
||||
backend-image:
|
||||
name: Build backend image
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Extract version from tag
|
||||
id: extract_version
|
||||
run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical/}"
|
||||
- name: ☁️ Checkout source
|
||||
uses: actions/checkout@v3
|
||||
- name: 📦 Install dependencies to test all dependencies
|
||||
@@ -51,15 +56,19 @@ jobs:
|
||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
push: true
|
||||
context: backend
|
||||
tags: infisical/backend:${{ steps.commit.outputs.short }},
|
||||
tags: |
|
||||
infisical/backend:${{ steps.commit.outputs.short }}
|
||||
infisical/backend:latest
|
||||
infisical/backend:${{ steps.extract_version.outputs.version }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
frontend-image:
|
||||
name: Build frontend image
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Extract version from tag
|
||||
id: extract_version
|
||||
run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical/}"
|
||||
- name: ☁️ Checkout source
|
||||
uses: actions/checkout@v3
|
||||
- name: Save commit hashes for tag
|
||||
@@ -100,8 +109,10 @@ jobs:
|
||||
push: true
|
||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
context: frontend
|
||||
tags: infisical/frontend:${{ steps.commit.outputs.short }},
|
||||
tags: |
|
||||
infisical/frontend:${{ steps.commit.outputs.short }}
|
||||
infisical/frontend:latest
|
||||
infisical/frontend:${{ steps.extract_version.outputs.version }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }}
|
||||
|
69
.github/workflows/release-standalone-docker-img.yml
vendored
Normal file
69
.github/workflows/release-standalone-docker-img.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
name: Release standalone docker image
|
||||
on: [workflow_dispatch]
|
||||
|
||||
jobs:
|
||||
infisical-standalone:
|
||||
name: Build infisical standalone image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ☁️ Checkout source
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# - name: 📦 Install dependencies to test all dependencies
|
||||
# run: npm ci --only-production
|
||||
# working-directory: backend
|
||||
- uses: paulhatch/semantic-version@v5.0.2
|
||||
id: version
|
||||
with:
|
||||
# The prefix to use to identify tags
|
||||
tag_prefix: "infisical-standalone/v"
|
||||
# A string which, if present in a git commit, indicates that a change represents a
|
||||
# major (breaking) change, supports regular expressions wrapped with '/'
|
||||
major_pattern: "(MAJOR)"
|
||||
# Same as above except indicating a minor change, supports regular expressions wrapped with '/'
|
||||
minor_pattern: "(MINOR)"
|
||||
# A string to determine the format of the version output
|
||||
version_format: "${major}.${minor}.${patch}-${increment}"
|
||||
# Optional path to check for changes. If any changes are detected in the path the
|
||||
# 'changed' output will true. Enter multiple paths separated by spaces.
|
||||
change_path: "backend/ frontend/"
|
||||
# Prevents pre-v1.0.0 version from automatically incrementing the major version.
|
||||
# If enabled, when the major version is 0, major releases will be treated as minor and minor as patch. Note that the version_type output is unchanged.
|
||||
enable_prerelease_mode: true
|
||||
# - name: 🧪 Run tests
|
||||
# run: npm run test:ci
|
||||
# working-directory: backend
|
||||
- name: version output
|
||||
run: |
|
||||
echo "Output Value: ${{ steps.version.outputs.major }}"
|
||||
echo "Output Value: ${{ steps.version.outputs.minor }}"
|
||||
echo "Output Value: ${{ steps.version.outputs.patch }}"
|
||||
echo "Output Value: ${{ steps.version.outputs.version }}"
|
||||
echo "Output Value: ${{ steps.version.outputs.version_type }}"
|
||||
echo "Output Value: ${{ steps.version.outputs.increment }}"
|
||||
echo "Output Value: ${{ steps.version.outputs.changed }}"
|
||||
# - name: Save commit hashes for tag
|
||||
# id: commit
|
||||
# uses: pr-mpt/actions-commit-hash@v2
|
||||
# - name: 🔧 Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@v2
|
||||
# - name: 🐋 Login to Docker Hub
|
||||
# uses: docker/login-action@v2
|
||||
# with:
|
||||
# username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
# password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
# - name: Set up Depot CLI
|
||||
# uses: depot/setup-action@v1
|
||||
# - name: 📦 Build backend and export to Docker
|
||||
# uses: depot/build-push-action@v1
|
||||
# with:
|
||||
# project: 64mmf0n610
|
||||
# token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
# push: true
|
||||
# context: .
|
||||
# tags: |
|
||||
# infisical/infisical:latest
|
||||
# infisical/infisical:${{ steps.commit.outputs.short }}
|
||||
# platforms: linux/amd64,linux/arm64
|
||||
# file: Dockerfile.standalone-infisical
|
@@ -65,10 +65,10 @@ archives:
|
||||
- goos: windows
|
||||
format: zip
|
||||
files:
|
||||
- README*
|
||||
- LICENSE*
|
||||
- manpages/*
|
||||
- completions/*
|
||||
- ../README*
|
||||
- ../LICENSE*
|
||||
- ../manpages/*
|
||||
- ../completions/*
|
||||
|
||||
release:
|
||||
replace_existing_draft: true
|
||||
@@ -98,12 +98,12 @@ brews:
|
||||
folder: Formula
|
||||
homepage: "https://infisical.com"
|
||||
description: "The official Infisical CLI"
|
||||
# install: |-
|
||||
# bin.install "infisical"
|
||||
# bash_completion.install "completions/infisical.bash" => "infisical"
|
||||
# zsh_completion.install "completions/infisical.zsh" => "_infisical"
|
||||
# fish_completion.install "completions/infisical.fish"
|
||||
# man1.install "manpages/infisical.1.gz"
|
||||
install: |-
|
||||
bin.install "infisical"
|
||||
bash_completion.install "completions/infisical.bash" => "infisical"
|
||||
zsh_completion.install "completions/infisical.zsh" => "_infisical"
|
||||
fish_completion.install "completions/infisical.fish"
|
||||
man1.install "manpages/infisical.1.gz"
|
||||
|
||||
nfpms:
|
||||
- id: infisical
|
||||
@@ -121,15 +121,15 @@ nfpms:
|
||||
- apk
|
||||
- archlinux
|
||||
bindir: /usr/bin
|
||||
# contents:
|
||||
# - src: ./completions/infisical.bash
|
||||
# dst: /etc/bash_completion.d/infisical
|
||||
# - src: ./completions/infisical.fish
|
||||
# dst: /usr/share/fish/vendor_completions.d/infisical.fish
|
||||
# - src: ./completions/infisical.zsh
|
||||
# dst: /usr/share/zsh/site-functions/_infisical
|
||||
# - src: ./manpages/infisical.1.gz
|
||||
# dst: /usr/share/man/man1/infisical.1.gz
|
||||
contents:
|
||||
- src: ./completions/infisical.bash
|
||||
dst: /etc/bash_completion.d/infisical
|
||||
- src: ./completions/infisical.fish
|
||||
dst: /usr/share/fish/vendor_completions.d/infisical.fish
|
||||
- src: ./completions/infisical.zsh
|
||||
dst: /usr/share/zsh/site-functions/_infisical
|
||||
- src: ./manpages/infisical.1.gz
|
||||
dst: /usr/share/man/man1/infisical.1.gz
|
||||
|
||||
scoop:
|
||||
bucket:
|
||||
@@ -156,6 +156,15 @@ aurs:
|
||||
install -Dm755 "./infisical" "${pkgdir}/usr/bin/infisical"
|
||||
# license
|
||||
install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/infisical/LICENSE"
|
||||
# completions
|
||||
mkdir -p "${pkgdir}/usr/share/bash-completion/completions/"
|
||||
mkdir -p "${pkgdir}/usr/share/zsh/site-functions/"
|
||||
mkdir -p "${pkgdir}/usr/share/fish/vendor_completions.d/"
|
||||
install -Dm644 "./completions/infisical.bash" "${pkgdir}/usr/share/bash-completion/completions/infisical"
|
||||
install -Dm644 "./completions/infisical.zsh" "${pkgdir}/usr/share/zsh/site-functions/infisical"
|
||||
install -Dm644 "./completions/infisical.fish" "${pkgdir}/usr/share/fish/vendor_completions.d/infisical.fish"
|
||||
# man pages
|
||||
install -Dm644 "./manpages/infisical.1.gz" "${pkgdir}/usr/share/man/man1/infisical.1.gz"
|
||||
|
||||
# dockers:
|
||||
# - dockerfile: cli/docker/Dockerfile
|
||||
|
102
Dockerfile.standalone-infisical
Normal file
102
Dockerfile.standalone-infisical
Normal file
@@ -0,0 +1,102 @@
|
||||
ARG POSTHOG_HOST=https://app.posthog.com
|
||||
ARG POSTHOG_API_KEY=posthog-api-key
|
||||
|
||||
FROM node:16-alpine AS frontend-dependencies
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json frontend/next.config.js ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only-production --ignore-scripts
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM node:16-alpine AS frontend-builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependencies
|
||||
COPY --from=frontend-dependencies /app/node_modules ./node_modules
|
||||
# Copy all files
|
||||
COPY /frontend .
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_PUBLIC_ENV production
|
||||
ARG POSTHOG_HOST
|
||||
ENV NEXT_PUBLIC_POSTHOG_HOST $POSTHOG_HOST
|
||||
ARG POSTHOG_API_KEY
|
||||
ENV NEXT_PUBLIC_POSTHOG_API_KEY $POSTHOG_API_KEY
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
# Production image
|
||||
FROM node:16-alpine AS frontend-runner
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
RUN mkdir -p /app/.next/cache/images && chown nextjs:nodejs /app/.next/cache/images
|
||||
VOLUME /app/.next/cache/images
|
||||
|
||||
ARG POSTHOG_API_KEY
|
||||
ENV NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY \
|
||||
BAKED_NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY
|
||||
|
||||
COPY --chown=nextjs:nodejs --chmod=555 frontend/scripts ./scripts
|
||||
COPY --from=frontend-builder /app/public ./public
|
||||
RUN chown nextjs:nodejs ./public/data
|
||||
COPY --from=frontend-builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=frontend-builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
##
|
||||
## BACKEND
|
||||
##
|
||||
FROM node:16-alpine AS backend-build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/package*.json ./
|
||||
RUN npm ci --only-production
|
||||
|
||||
COPY /backend .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:16-alpine AS backend-runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/package*.json ./
|
||||
RUN npm ci --only-production
|
||||
|
||||
COPY --from=backend-build /app .
|
||||
|
||||
# Production stage
|
||||
FROM node:14-alpine AS production
|
||||
|
||||
WORKDIR /
|
||||
|
||||
# Install PM2
|
||||
RUN npm install -g pm2
|
||||
# Copy ecosystem.config.js
|
||||
COPY ecosystem.config.js .
|
||||
|
||||
RUN apk add --no-cache nginx
|
||||
|
||||
COPY nginx/default-stand-alone-docker.conf /etc/nginx/nginx.conf
|
||||
|
||||
COPY --from=backend-runner /app /backend
|
||||
|
||||
COPY --from=frontend-runner /app/ /app/
|
||||
|
||||
EXPOSE 80
|
||||
ENV HTTPS_ENABLED false
|
||||
|
||||
CMD ["pm2-runtime", "start", "ecosystem.config.js"]
|
||||
|
||||
|
@@ -78,16 +78,16 @@ To set up and run Infisical locally, make sure you have Git and Docker installed
|
||||
Linux/macOS:
|
||||
|
||||
```console
|
||||
git clone https://github.com/Infisical/infisical && cd "$(basename $_ .git)" && cp .env.example .env && docker-compose -f docker-compose.dev.yml up --build
|
||||
git clone https://github.com/Infisical/infisical && cd "$(basename $_ .git)" && cp .env.example .env && docker-compose -f docker-compose.yml up
|
||||
```
|
||||
|
||||
Windows Command Prompt:
|
||||
|
||||
```console
|
||||
git clone https://github.com/Infisical/infisical && cd infisical && copy .env.example .env && docker-compose -f docker-compose.dev.yml up --build
|
||||
git clone https://github.com/Infisical/infisical && cd infisical && copy .env.example .env && docker-compose -f docker-compose.yml up
|
||||
```
|
||||
|
||||
Login to the web app at `http://localhost:8080` by entering the test user email `test@localhost.local` and password `testInfisical1`.
|
||||
Create an account at `http://localhost:80`
|
||||
|
||||
## Open-source vs. paid
|
||||
|
||||
|
30
backend/package-lock.json
generated
30
backend/package-lock.json
generated
@@ -22,7 +22,7 @@
|
||||
"axios": "^1.3.5",
|
||||
"axios-retry": "^3.4.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"bigint-conversion": "^2.2.2",
|
||||
"bigint-conversion": "^2.4.0",
|
||||
"builder-pattern": "^2.2.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
@@ -3171,9 +3171,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@juanelas/base64": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@juanelas/base64/-/base64-1.0.5.tgz",
|
||||
"integrity": "sha512-gTIElNo4ohMcYUZzol/Hb6DYJzphxl0b1B4egJJ+JiqxqcOcWx4XLMAB+lhWuMsMX3uR1oc5hwPusU3lgc1FkQ=="
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@juanelas/base64/-/base64-1.1.2.tgz",
|
||||
"integrity": "sha512-mr2pfRQpWap0Uq4tlrCgp3W+Yjx1/Bpq4QJsYeAQUh1mExgyQvXz7xUhmYT2HcLLspuAL5dpnos8P2QhaCSXsQ=="
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp": {
|
||||
"version": "1.0.10",
|
||||
@@ -4942,11 +4942,11 @@
|
||||
"integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="
|
||||
},
|
||||
"node_modules/bigint-conversion": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/bigint-conversion/-/bigint-conversion-2.3.0.tgz",
|
||||
"integrity": "sha512-U4Yzg8ygZ3m78n9weKP/6NMxN/98Pdsw6YdnDYgQO8fffL7ila31fCZIy5lCMswr4kEcyl8bGHltcHT0QdG/MQ==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/bigint-conversion/-/bigint-conversion-2.4.0.tgz",
|
||||
"integrity": "sha512-PApDhrpW5qjdK8ecsBgcJ08pNMt8nNsnPxlw9a9PVPeF01tzBaeV66ogu3L9i/tEz5WhD8IvSf9ipQILujThzA==",
|
||||
"dependencies": {
|
||||
"@juanelas/base64": "^1.0.1"
|
||||
"@juanelas/base64": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
@@ -15685,9 +15685,9 @@
|
||||
}
|
||||
},
|
||||
"@juanelas/base64": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@juanelas/base64/-/base64-1.0.5.tgz",
|
||||
"integrity": "sha512-gTIElNo4ohMcYUZzol/Hb6DYJzphxl0b1B4egJJ+JiqxqcOcWx4XLMAB+lhWuMsMX3uR1oc5hwPusU3lgc1FkQ=="
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@juanelas/base64/-/base64-1.1.2.tgz",
|
||||
"integrity": "sha512-mr2pfRQpWap0Uq4tlrCgp3W+Yjx1/Bpq4QJsYeAQUh1mExgyQvXz7xUhmYT2HcLLspuAL5dpnos8P2QhaCSXsQ=="
|
||||
},
|
||||
"@mapbox/node-pre-gyp": {
|
||||
"version": "1.0.10",
|
||||
@@ -16992,11 +16992,11 @@
|
||||
"integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="
|
||||
},
|
||||
"bigint-conversion": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/bigint-conversion/-/bigint-conversion-2.3.0.tgz",
|
||||
"integrity": "sha512-U4Yzg8ygZ3m78n9weKP/6NMxN/98Pdsw6YdnDYgQO8fffL7ila31fCZIy5lCMswr4kEcyl8bGHltcHT0QdG/MQ==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/bigint-conversion/-/bigint-conversion-2.4.0.tgz",
|
||||
"integrity": "sha512-PApDhrpW5qjdK8ecsBgcJ08pNMt8nNsnPxlw9a9PVPeF01tzBaeV66ogu3L9i/tEz5WhD8IvSf9ipQILujThzA==",
|
||||
"requires": {
|
||||
"@juanelas/base64": "^1.0.1"
|
||||
"@juanelas/base64": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"binary-extensions": {
|
||||
|
@@ -13,7 +13,7 @@
|
||||
"axios": "^1.3.5",
|
||||
"axios-retry": "^3.4.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"bigint-conversion": "^2.2.2",
|
||||
"bigint-conversion": "^2.4.0",
|
||||
"builder-pattern": "^2.2.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
|
@@ -188,7 +188,7 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
if (!(await getSmtpConfigured())) {
|
||||
completeInviteLink = `${siteUrl + '/signupinvite'}?token=${token}&to=${inviteeEmail}`
|
||||
completeInviteLink = `${siteUrl + '/signupinvite'}?token=${token}&to=${inviteeEmail}&organization_id=${organization._id}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,10 +217,10 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => {
|
||||
export const verifyUserToOrganization = async (req: Request, res: Response) => {
|
||||
let user, token;
|
||||
try {
|
||||
const {
|
||||
email,
|
||||
const {
|
||||
email,
|
||||
organizationId,
|
||||
code
|
||||
code
|
||||
} = req.body;
|
||||
|
||||
user = await User.findOne({ email }).select('+publicKey');
|
||||
|
@@ -28,6 +28,9 @@ import {
|
||||
import { getFolderPath, getFoldersInDirectory, normalizePath } from '../../utils/folder';
|
||||
import { ROOT_FOLDER_PATH } from '../../utils/folder';
|
||||
|
||||
// test commm
|
||||
// hshs
|
||||
|
||||
/**
|
||||
* Peform a batch of any specified CUD secret operations
|
||||
* (used by dashboard)
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Types } from 'mongoose';
|
||||
import { Action } from '../models';
|
||||
import {
|
||||
@@ -36,33 +35,25 @@ const createActionUpdateSecret = async ({
|
||||
workspaceId: Types.ObjectId;
|
||||
secretIds: Types.ObjectId[];
|
||||
}) => {
|
||||
let action;
|
||||
try {
|
||||
const latestSecretVersions = (await getLatestNSecretSecretVersionIds({
|
||||
secretIds,
|
||||
n: 2
|
||||
}))
|
||||
.map((s) => ({
|
||||
oldSecretVersion: s.versions[0]._id,
|
||||
newSecretVersion: s.versions[1]._id
|
||||
}));
|
||||
|
||||
action = await new Action({
|
||||
name,
|
||||
user: userId,
|
||||
serviceAccount: serviceAccountId,
|
||||
serviceTokenData: serviceTokenDataId,
|
||||
workspace: workspaceId,
|
||||
payload: {
|
||||
secretVersions: latestSecretVersions
|
||||
}
|
||||
}).save();
|
||||
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to create update secret action');
|
||||
}
|
||||
const latestSecretVersions = (await getLatestNSecretSecretVersionIds({
|
||||
secretIds,
|
||||
n: 2
|
||||
}))
|
||||
.map((s) => ({
|
||||
oldSecretVersion: s.versions[0]._id,
|
||||
newSecretVersion: s.versions[1]._id
|
||||
}));
|
||||
|
||||
const action = await new Action({
|
||||
name,
|
||||
user: userId,
|
||||
serviceAccount: serviceAccountId,
|
||||
serviceTokenData: serviceTokenDataId,
|
||||
workspace: workspaceId,
|
||||
payload: {
|
||||
secretVersions: latestSecretVersions
|
||||
}
|
||||
}).save();
|
||||
|
||||
return action;
|
||||
}
|
||||
@@ -90,33 +81,25 @@ const createActionSecret = async ({
|
||||
workspaceId: Types.ObjectId;
|
||||
secretIds: Types.ObjectId[];
|
||||
}) => {
|
||||
let action;
|
||||
try {
|
||||
// case: action is adding, deleting, or reading secrets
|
||||
// -> add new secret versions
|
||||
const latestSecretVersions = (await getLatestSecretVersionIds({
|
||||
secretIds
|
||||
}))
|
||||
.map((s) => ({
|
||||
newSecretVersion: s.versionId
|
||||
}));
|
||||
|
||||
action = await new Action({
|
||||
name,
|
||||
user: userId,
|
||||
serviceAccount: serviceAccountId,
|
||||
serviceTokenData: serviceTokenDataId,
|
||||
workspace: workspaceId,
|
||||
payload: {
|
||||
secretVersions: latestSecretVersions
|
||||
}
|
||||
}).save();
|
||||
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to create action create/read/delete secret action');
|
||||
}
|
||||
// case: action is adding, deleting, or reading secrets
|
||||
// -> add new secret versions
|
||||
const latestSecretVersions = (await getLatestSecretVersionIds({
|
||||
secretIds
|
||||
}))
|
||||
.map((s) => ({
|
||||
newSecretVersion: s.versionId
|
||||
}));
|
||||
|
||||
const action = await new Action({
|
||||
name,
|
||||
user: userId,
|
||||
serviceAccount: serviceAccountId,
|
||||
serviceTokenData: serviceTokenDataId,
|
||||
workspace: workspaceId,
|
||||
payload: {
|
||||
secretVersions: latestSecretVersions
|
||||
}
|
||||
}).save();
|
||||
|
||||
return action;
|
||||
}
|
||||
@@ -140,19 +123,12 @@ const createActionClient = ({
|
||||
serviceAccountId?: Types.ObjectId;
|
||||
serviceTokenDataId?: Types.ObjectId;
|
||||
}) => {
|
||||
let action;
|
||||
try {
|
||||
action = new Action({
|
||||
name,
|
||||
user: userId,
|
||||
serviceAccount: serviceAccountId,
|
||||
serviceTokenData: serviceTokenDataId
|
||||
}).save();
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to create client action');
|
||||
}
|
||||
const action = new Action({
|
||||
name,
|
||||
user: userId,
|
||||
serviceAccount: serviceAccountId,
|
||||
serviceTokenData: serviceTokenDataId
|
||||
}).save();
|
||||
|
||||
return action;
|
||||
}
|
||||
@@ -181,40 +157,34 @@ const createActionHelper = async ({
|
||||
secretIds?: Types.ObjectId[];
|
||||
}) => {
|
||||
let action;
|
||||
try {
|
||||
switch (name) {
|
||||
case ACTION_LOGIN:
|
||||
case ACTION_LOGOUT:
|
||||
action = await createActionClient({
|
||||
name,
|
||||
userId
|
||||
});
|
||||
break;
|
||||
case ACTION_ADD_SECRETS:
|
||||
case ACTION_READ_SECRETS:
|
||||
case ACTION_DELETE_SECRETS:
|
||||
if (!workspaceId || !secretIds) throw new Error('Missing required params workspace id or secret ids to create action secret');
|
||||
action = await createActionSecret({
|
||||
name,
|
||||
userId,
|
||||
workspaceId,
|
||||
secretIds
|
||||
});
|
||||
break;
|
||||
case ACTION_UPDATE_SECRETS:
|
||||
if (!workspaceId || !secretIds) throw new Error('Missing required params workspace id or secret ids to create action secret');
|
||||
action = await createActionUpdateSecret({
|
||||
name,
|
||||
userId,
|
||||
workspaceId,
|
||||
secretIds
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to create action');
|
||||
switch (name) {
|
||||
case ACTION_LOGIN:
|
||||
case ACTION_LOGOUT:
|
||||
action = await createActionClient({
|
||||
name,
|
||||
userId
|
||||
});
|
||||
break;
|
||||
case ACTION_ADD_SECRETS:
|
||||
case ACTION_READ_SECRETS:
|
||||
case ACTION_DELETE_SECRETS:
|
||||
if (!workspaceId || !secretIds) throw new Error('Missing required params workspace id or secret ids to create action secret');
|
||||
action = await createActionSecret({
|
||||
name,
|
||||
userId,
|
||||
workspaceId,
|
||||
secretIds
|
||||
});
|
||||
break;
|
||||
case ACTION_UPDATE_SECRETS:
|
||||
if (!workspaceId || !secretIds) throw new Error('Missing required params workspace id or secret ids to create action secret');
|
||||
action = await createActionUpdateSecret({
|
||||
name,
|
||||
userId,
|
||||
workspaceId,
|
||||
secretIds
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return action;
|
||||
@@ -222,4 +192,4 @@ const createActionHelper = async ({
|
||||
|
||||
export {
|
||||
createActionHelper
|
||||
};
|
||||
};
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Types } from 'mongoose';
|
||||
import {
|
||||
Log,
|
||||
@@ -32,27 +31,20 @@ const createLogHelper = async ({
|
||||
channel: string;
|
||||
ipAddress: string;
|
||||
}) => {
|
||||
let log;
|
||||
try {
|
||||
log = await new Log({
|
||||
user: userId,
|
||||
serviceAccount: serviceAccountId,
|
||||
serviceTokenData: serviceTokenDataId,
|
||||
workspace: workspaceId ?? undefined,
|
||||
actionNames: actions.map((a) => a.name),
|
||||
actions,
|
||||
channel,
|
||||
ipAddress
|
||||
}).save();
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to create log');
|
||||
}
|
||||
const log = await new Log({
|
||||
user: userId,
|
||||
serviceAccount: serviceAccountId,
|
||||
serviceTokenData: serviceTokenDataId,
|
||||
workspace: workspaceId ?? undefined,
|
||||
actionNames: actions.map((a) => a.name),
|
||||
actions,
|
||||
channel,
|
||||
ipAddress
|
||||
}).save();
|
||||
|
||||
return log;
|
||||
}
|
||||
|
||||
export {
|
||||
createLogHelper
|
||||
}
|
||||
}
|
||||
|
@@ -1,14 +1,6 @@
|
||||
import { Types } from 'mongoose';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import {
|
||||
Secret,
|
||||
ISecret,
|
||||
} from '../../models';
|
||||
import {
|
||||
SecretSnapshot,
|
||||
SecretVersion,
|
||||
ISecretVersion
|
||||
} from '../models';
|
||||
import { Types } from "mongoose";
|
||||
import { Secret, ISecret } from "../../models";
|
||||
import { SecretSnapshot, SecretVersion, ISecretVersion } from "../models";
|
||||
|
||||
/**
|
||||
* Save a secret snapshot that is a copy of the current state of secrets in workspace with id
|
||||
@@ -19,56 +11,53 @@ import {
|
||||
* @returns {SecretSnapshot} secretSnapshot - new secret snapshot
|
||||
*/
|
||||
const takeSecretSnapshotHelper = async ({
|
||||
workspaceId
|
||||
workspaceId,
|
||||
}: {
|
||||
workspaceId: Types.ObjectId;
|
||||
workspaceId: Types.ObjectId;
|
||||
}) => {
|
||||
const secretIds = (
|
||||
await Secret.find(
|
||||
{
|
||||
workspace: workspaceId,
|
||||
},
|
||||
"_id"
|
||||
)
|
||||
).map((s) => s._id);
|
||||
|
||||
let secretSnapshot;
|
||||
try {
|
||||
const secretIds = (await Secret.find({
|
||||
workspace: workspaceId
|
||||
}, '_id')).map((s) => s._id);
|
||||
const latestSecretVersions = (
|
||||
await SecretVersion.aggregate([
|
||||
{
|
||||
$match: {
|
||||
secret: {
|
||||
$in: secretIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$secret",
|
||||
version: { $max: "$version" },
|
||||
versionId: { $max: "$_id" }, // secret version id
|
||||
},
|
||||
},
|
||||
{
|
||||
$sort: { version: -1 },
|
||||
},
|
||||
]).exec()
|
||||
).map((s) => s.versionId);
|
||||
|
||||
const latestSecretVersions = (await SecretVersion.aggregate([
|
||||
{
|
||||
$match: {
|
||||
secret: {
|
||||
$in: secretIds
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$secret',
|
||||
version: { $max: '$version' },
|
||||
versionId: { $max: '$_id' } // secret version id
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: { version: -1 }
|
||||
}
|
||||
])
|
||||
.exec())
|
||||
.map((s) => s.versionId);
|
||||
const latestSecretSnapshot = await SecretSnapshot.findOne({
|
||||
workspace: workspaceId,
|
||||
}).sort({ version: -1 });
|
||||
|
||||
const latestSecretSnapshot = await SecretSnapshot.findOne({
|
||||
workspace: workspaceId
|
||||
}).sort({ version: -1 });
|
||||
const secretSnapshot = await new SecretSnapshot({
|
||||
workspace: workspaceId,
|
||||
version: latestSecretSnapshot ? latestSecretSnapshot.version + 1 : 1,
|
||||
secretVersions: latestSecretVersions,
|
||||
}).save();
|
||||
|
||||
secretSnapshot = await new SecretSnapshot({
|
||||
workspace: workspaceId,
|
||||
version: latestSecretSnapshot ? latestSecretSnapshot.version + 1 : 1,
|
||||
secretVersions: latestSecretVersions
|
||||
}).save();
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to take a secret snapshot');
|
||||
}
|
||||
|
||||
return secretSnapshot;
|
||||
}
|
||||
return secretSnapshot;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add secret versions [secretVersions] to the SecretVersion collection.
|
||||
@@ -77,93 +66,79 @@ const takeSecretSnapshotHelper = async ({
|
||||
* @returns {SecretVersion[]} newSecretVersions - new secret versions
|
||||
*/
|
||||
const addSecretVersionsHelper = async ({
|
||||
secretVersions
|
||||
secretVersions,
|
||||
}: {
|
||||
secretVersions: ISecretVersion[]
|
||||
secretVersions: ISecretVersion[];
|
||||
}) => {
|
||||
let newSecretVersions;
|
||||
try {
|
||||
newSecretVersions = await SecretVersion.insertMany(secretVersions);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error(`Failed to add secret versions [err=${err}]`);
|
||||
}
|
||||
const newSecretVersions = await SecretVersion.insertMany(secretVersions);
|
||||
|
||||
return newSecretVersions;
|
||||
}
|
||||
return newSecretVersions;
|
||||
};
|
||||
|
||||
const markDeletedSecretVersionsHelper = async ({
|
||||
secretIds
|
||||
secretIds,
|
||||
}: {
|
||||
secretIds: Types.ObjectId[];
|
||||
secretIds: Types.ObjectId[];
|
||||
}) => {
|
||||
try {
|
||||
await SecretVersion.updateMany({
|
||||
secret: { $in: secretIds }
|
||||
}, {
|
||||
isDeleted: true
|
||||
}, {
|
||||
new: true
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to mark secret versions as deleted');
|
||||
}
|
||||
}
|
||||
await SecretVersion.updateMany(
|
||||
{
|
||||
secret: { $in: secretIds },
|
||||
},
|
||||
{
|
||||
isDeleted: true,
|
||||
},
|
||||
{
|
||||
new: true,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize secret versioning by setting previously unversioned
|
||||
* secrets to version 1 and begin populating secret versions.
|
||||
*/
|
||||
const initSecretVersioningHelper = async () => {
|
||||
try {
|
||||
await Secret.updateMany(
|
||||
{ version: { $exists: false } },
|
||||
{ $set: { version: 1 } }
|
||||
);
|
||||
|
||||
await Secret.updateMany(
|
||||
{ version: { $exists: false } },
|
||||
{ $set: { version: 1 } }
|
||||
);
|
||||
const unversionedSecrets: ISecret[] = await Secret.aggregate([
|
||||
{
|
||||
$lookup: {
|
||||
from: "secretversions",
|
||||
localField: "_id",
|
||||
foreignField: "secret",
|
||||
as: "versions",
|
||||
},
|
||||
},
|
||||
{
|
||||
$match: {
|
||||
versions: { $size: 0 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const unversionedSecrets: ISecret[] = await Secret.aggregate([
|
||||
{
|
||||
$lookup: {
|
||||
from: 'secretversions',
|
||||
localField: '_id',
|
||||
foreignField: 'secret',
|
||||
as: 'versions',
|
||||
},
|
||||
},
|
||||
{
|
||||
$match: {
|
||||
versions: { $size: 0 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
if (unversionedSecrets.length > 0) {
|
||||
await addSecretVersionsHelper({
|
||||
secretVersions: unversionedSecrets.map((s, idx) => new SecretVersion({
|
||||
...s,
|
||||
secret: s._id,
|
||||
version: s.version ? s.version : 1,
|
||||
isDeleted: false,
|
||||
workspace: s.workspace,
|
||||
environment: s.environment
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to ensure that secrets are versioned');
|
||||
}
|
||||
}
|
||||
if (unversionedSecrets.length > 0) {
|
||||
await addSecretVersionsHelper({
|
||||
secretVersions: unversionedSecrets.map(
|
||||
(s, idx) =>
|
||||
new SecretVersion({
|
||||
...s,
|
||||
secret: s._id,
|
||||
version: s.version ? s.version : 1,
|
||||
isDeleted: false,
|
||||
workspace: s.workspace,
|
||||
environment: s.environment,
|
||||
})
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
takeSecretSnapshotHelper,
|
||||
addSecretVersionsHelper,
|
||||
markDeletedSecretVersionsHelper,
|
||||
initSecretVersioningHelper
|
||||
}
|
||||
takeSecretSnapshotHelper,
|
||||
addSecretVersionsHelper,
|
||||
markDeletedSecretVersionsHelper,
|
||||
initSecretVersioningHelper,
|
||||
};
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Types } from 'mongoose';
|
||||
import { SecretVersion } from '../models';
|
||||
|
||||
@@ -13,41 +12,32 @@ const getLatestSecretVersionIds = async ({
|
||||
}: {
|
||||
secretIds: Types.ObjectId[];
|
||||
}) => {
|
||||
|
||||
interface LatestSecretVersionId {
|
||||
_id: Types.ObjectId;
|
||||
version: number;
|
||||
versionId: Types.ObjectId;
|
||||
}
|
||||
|
||||
let latestSecretVersionIds: LatestSecretVersionId[];
|
||||
try {
|
||||
latestSecretVersionIds = (await SecretVersion.aggregate([
|
||||
{
|
||||
$match: {
|
||||
secret: {
|
||||
$in: secretIds
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$secret',
|
||||
version: { $max: '$version' },
|
||||
versionId: { $max: '$_id' } // id of latest secret version
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: { version: -1 }
|
||||
const latestSecretVersionIds = (await SecretVersion.aggregate([
|
||||
{
|
||||
$match: {
|
||||
secret: {
|
||||
$in: secretIds
|
||||
}
|
||||
}
|
||||
])
|
||||
.exec());
|
||||
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get latest secret versions');
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$secret',
|
||||
version: { $max: '$version' },
|
||||
versionId: { $max: '$_id' } // id of latest secret version
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: { version: -1 }
|
||||
}
|
||||
])
|
||||
.exec());
|
||||
|
||||
return latestSecretVersionIds;
|
||||
}
|
||||
@@ -66,40 +56,32 @@ const getLatestNSecretSecretVersionIds = async ({
|
||||
secretIds: Types.ObjectId[];
|
||||
n: number;
|
||||
}) => {
|
||||
|
||||
// TODO: optimize query
|
||||
let latestNSecretVersions;
|
||||
try {
|
||||
latestNSecretVersions = (await SecretVersion.aggregate([
|
||||
{
|
||||
$match: {
|
||||
secret: {
|
||||
$in: secretIds,
|
||||
},
|
||||
},
|
||||
const latestNSecretVersions = (await SecretVersion.aggregate([
|
||||
{
|
||||
$match: {
|
||||
secret: {
|
||||
$in: secretIds,
|
||||
},
|
||||
{
|
||||
$sort: { version: -1 },
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$secret",
|
||||
versions: { $push: "$$ROOT" },
|
||||
},
|
||||
},
|
||||
{
|
||||
$sort: { version: -1 },
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$secret",
|
||||
versions: { $push: "$$ROOT" },
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
secret: "$_id",
|
||||
versions: { $slice: ["$versions", n] },
|
||||
},
|
||||
}
|
||||
]));
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get latest n secret versions');
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
secret: "$_id",
|
||||
versions: { $slice: ["$versions", n] },
|
||||
},
|
||||
}
|
||||
]));
|
||||
|
||||
return latestNSecretVersions;
|
||||
}
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Types } from 'mongoose';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
@@ -1,41 +1,34 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Types } from 'mongoose';
|
||||
import { Types } from "mongoose";
|
||||
import {
|
||||
Bot,
|
||||
BotKey,
|
||||
Secret,
|
||||
ISecret,
|
||||
IUser,
|
||||
User,
|
||||
IServiceAccount,
|
||||
ServiceAccount,
|
||||
IServiceTokenData,
|
||||
ServiceTokenData
|
||||
} from '../models';
|
||||
import {
|
||||
generateKeyPair,
|
||||
encryptSymmetric,
|
||||
decryptSymmetric,
|
||||
decryptAsymmetric
|
||||
} from '../utils/crypto';
|
||||
Bot,
|
||||
BotKey,
|
||||
Secret,
|
||||
ISecret,
|
||||
IUser,
|
||||
User,
|
||||
IServiceAccount,
|
||||
ServiceAccount,
|
||||
IServiceTokenData,
|
||||
ServiceTokenData,
|
||||
} from "../models";
|
||||
import {
|
||||
SECRET_SHARED,
|
||||
AUTH_MODE_JWT,
|
||||
AUTH_MODE_SERVICE_ACCOUNT,
|
||||
AUTH_MODE_SERVICE_TOKEN,
|
||||
AUTH_MODE_API_KEY
|
||||
} from '../variables';
|
||||
import { getEncryptionKey } from '../config';
|
||||
import { BotNotFoundError, UnauthorizedRequestError } from '../utils/errors';
|
||||
generateKeyPair,
|
||||
encryptSymmetric,
|
||||
decryptSymmetric,
|
||||
decryptAsymmetric,
|
||||
} from "../utils/crypto";
|
||||
import {
|
||||
validateMembership
|
||||
} from '../helpers/membership';
|
||||
import {
|
||||
validateUserClientForWorkspace
|
||||
} from '../helpers/user';
|
||||
import {
|
||||
validateServiceAccountClientForWorkspace
|
||||
} from '../helpers/serviceAccount';
|
||||
SECRET_SHARED,
|
||||
AUTH_MODE_JWT,
|
||||
AUTH_MODE_SERVICE_ACCOUNT,
|
||||
AUTH_MODE_SERVICE_TOKEN,
|
||||
AUTH_MODE_API_KEY,
|
||||
} from "../variables";
|
||||
import { getEncryptionKey } from "../config";
|
||||
import { BotNotFoundError, UnauthorizedRequestError } from "../utils/errors";
|
||||
import { validateMembership } from "../helpers/membership";
|
||||
import { validateUserClientForWorkspace } from "../helpers/user";
|
||||
import { validateServiceAccountClientForWorkspace } from "../helpers/serviceAccount";
|
||||
|
||||
/**
|
||||
* Validate authenticated clients for bot with id [botId] based
|
||||
@@ -46,99 +39,104 @@ import {
|
||||
* @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles
|
||||
*/
|
||||
const validateClientForBot = async ({
|
||||
authData,
|
||||
botId,
|
||||
acceptedRoles
|
||||
authData,
|
||||
botId,
|
||||
acceptedRoles,
|
||||
}: {
|
||||
authData: {
|
||||
authMode: string;
|
||||
authPayload: IUser | IServiceAccount | IServiceTokenData;
|
||||
};
|
||||
botId: Types.ObjectId;
|
||||
acceptedRoles: Array<'admin' | 'member'>;
|
||||
authData: {
|
||||
authMode: string;
|
||||
authPayload: IUser | IServiceAccount | IServiceTokenData;
|
||||
};
|
||||
botId: Types.ObjectId;
|
||||
acceptedRoles: Array<"admin" | "member">;
|
||||
}) => {
|
||||
const bot = await Bot.findById(botId);
|
||||
|
||||
if (!bot) throw BotNotFoundError();
|
||||
|
||||
if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) {
|
||||
await validateUserClientForWorkspace({
|
||||
user: authData.authPayload,
|
||||
workspaceId: bot.workspace,
|
||||
acceptedRoles
|
||||
});
|
||||
|
||||
return bot;
|
||||
}
|
||||
const bot = await Bot.findById(botId);
|
||||
|
||||
if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) {
|
||||
await validateServiceAccountClientForWorkspace({
|
||||
serviceAccount: authData.authPayload,
|
||||
workspaceId: bot.workspace
|
||||
});
|
||||
if (!bot) throw BotNotFoundError();
|
||||
|
||||
return bot;
|
||||
}
|
||||
|
||||
if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) {
|
||||
throw UnauthorizedRequestError({
|
||||
message: 'Failed service token authorization for bot'
|
||||
});
|
||||
}
|
||||
|
||||
if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) {
|
||||
await validateUserClientForWorkspace({
|
||||
user: authData.authPayload,
|
||||
workspaceId: bot.workspace,
|
||||
acceptedRoles
|
||||
});
|
||||
|
||||
return bot;
|
||||
}
|
||||
|
||||
throw BotNotFoundError({
|
||||
message: 'Failed client authorization for bot'
|
||||
if (
|
||||
authData.authMode === AUTH_MODE_JWT &&
|
||||
authData.authPayload instanceof User
|
||||
) {
|
||||
await validateUserClientForWorkspace({
|
||||
user: authData.authPayload,
|
||||
workspaceId: bot.workspace,
|
||||
acceptedRoles,
|
||||
});
|
||||
}
|
||||
|
||||
return bot;
|
||||
}
|
||||
|
||||
if (
|
||||
authData.authMode === AUTH_MODE_SERVICE_ACCOUNT &&
|
||||
authData.authPayload instanceof ServiceAccount
|
||||
) {
|
||||
await validateServiceAccountClientForWorkspace({
|
||||
serviceAccount: authData.authPayload,
|
||||
workspaceId: bot.workspace,
|
||||
});
|
||||
|
||||
return bot;
|
||||
}
|
||||
|
||||
if (
|
||||
authData.authMode === AUTH_MODE_SERVICE_TOKEN &&
|
||||
authData.authPayload instanceof ServiceTokenData
|
||||
) {
|
||||
throw UnauthorizedRequestError({
|
||||
message: "Failed service token authorization for bot",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
authData.authMode === AUTH_MODE_API_KEY &&
|
||||
authData.authPayload instanceof User
|
||||
) {
|
||||
await validateUserClientForWorkspace({
|
||||
user: authData.authPayload,
|
||||
workspaceId: bot.workspace,
|
||||
acceptedRoles,
|
||||
});
|
||||
|
||||
return bot;
|
||||
}
|
||||
|
||||
throw BotNotFoundError({
|
||||
message: "Failed client authorization for bot",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an inactive bot with name [name] for workspace with id [workspaceId]
|
||||
* @param {Object} obj
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.name - name of bot
|
||||
* @param {String} obj.workspaceId - id of workspace that bot belongs to
|
||||
*/
|
||||
const createBot = async ({
|
||||
name,
|
||||
workspaceId,
|
||||
name,
|
||||
workspaceId,
|
||||
}: {
|
||||
name: string;
|
||||
workspaceId: Types.ObjectId;
|
||||
name: string;
|
||||
workspaceId: Types.ObjectId;
|
||||
}) => {
|
||||
let bot;
|
||||
try {
|
||||
const { publicKey, privateKey } = generateKeyPair();
|
||||
const { ciphertext, iv, tag } = encryptSymmetric({
|
||||
plaintext: privateKey,
|
||||
key: await getEncryptionKey()
|
||||
});
|
||||
const { publicKey, privateKey } = generateKeyPair();
|
||||
const { ciphertext, iv, tag } = encryptSymmetric({
|
||||
plaintext: privateKey,
|
||||
key: await getEncryptionKey(),
|
||||
});
|
||||
|
||||
bot = await new Bot({
|
||||
name,
|
||||
workspace: workspaceId,
|
||||
isActive: false,
|
||||
publicKey,
|
||||
encryptedPrivateKey: ciphertext,
|
||||
iv,
|
||||
tag
|
||||
}).save();
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to create bot');
|
||||
}
|
||||
|
||||
return bot;
|
||||
}
|
||||
const bot = await new Bot({
|
||||
name,
|
||||
workspace: workspaceId,
|
||||
isActive: false,
|
||||
publicKey,
|
||||
encryptedPrivateKey: ciphertext,
|
||||
iv,
|
||||
tag,
|
||||
}).save();
|
||||
|
||||
return bot;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return decrypted secrets for workspace with id [workspaceId]
|
||||
@@ -148,125 +146,105 @@ const createBot = async ({
|
||||
* @param {String} obj.environment - environment
|
||||
*/
|
||||
const getSecretsHelper = async ({
|
||||
workspaceId,
|
||||
environment
|
||||
workspaceId,
|
||||
environment,
|
||||
}: {
|
||||
workspaceId: Types.ObjectId;
|
||||
environment: string;
|
||||
workspaceId: Types.ObjectId;
|
||||
environment: string;
|
||||
}) => {
|
||||
const content = {} as any;
|
||||
try {
|
||||
const key = await getKey({ workspaceId });
|
||||
const secrets = await Secret.find({
|
||||
workspace: workspaceId,
|
||||
environment,
|
||||
type: SECRET_SHARED
|
||||
});
|
||||
|
||||
secrets.forEach((secret: ISecret) => {
|
||||
const secretKey = decryptSymmetric({
|
||||
ciphertext: secret.secretKeyCiphertext,
|
||||
iv: secret.secretKeyIV,
|
||||
tag: secret.secretKeyTag,
|
||||
key
|
||||
});
|
||||
const content = {} as any;
|
||||
const key = await getKey({ workspaceId: workspaceId.toString() });
|
||||
const secrets = await Secret.find({
|
||||
workspace: workspaceId,
|
||||
environment,
|
||||
type: SECRET_SHARED,
|
||||
});
|
||||
|
||||
const secretValue = decryptSymmetric({
|
||||
ciphertext: secret.secretValueCiphertext,
|
||||
iv: secret.secretValueIV,
|
||||
tag: secret.secretValueTag,
|
||||
key
|
||||
});
|
||||
secrets.forEach((secret: ISecret) => {
|
||||
const secretKey = decryptSymmetric({
|
||||
ciphertext: secret.secretKeyCiphertext,
|
||||
iv: secret.secretKeyIV,
|
||||
tag: secret.secretKeyTag,
|
||||
key,
|
||||
});
|
||||
|
||||
content[secretKey] = secretValue;
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get secrets');
|
||||
}
|
||||
const secretValue = decryptSymmetric({
|
||||
ciphertext: secret.secretValueCiphertext,
|
||||
iv: secret.secretValueIV,
|
||||
tag: secret.secretValueTag,
|
||||
key,
|
||||
});
|
||||
|
||||
return content;
|
||||
}
|
||||
content[secretKey] = secretValue;
|
||||
});
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return bot's copy of the workspace key for workspace
|
||||
* Return bot's copy of the workspace key for workspace
|
||||
* with id [workspaceId]
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.workspaceId - id of workspace
|
||||
* @returns {String} key - decrypted workspace key
|
||||
*/
|
||||
const getKey = async ({ workspaceId }: { workspaceId: Types.ObjectId }) => {
|
||||
let key;
|
||||
try {
|
||||
const botKey = await BotKey.findOne({
|
||||
workspace: workspaceId
|
||||
}).populate<{ sender: IUser }>('sender', 'publicKey');
|
||||
|
||||
if (!botKey) throw new Error('Failed to find bot key');
|
||||
|
||||
const bot = await Bot.findOne({
|
||||
workspace: workspaceId
|
||||
}).select('+encryptedPrivateKey +iv +tag');
|
||||
|
||||
if (!bot) throw new Error('Failed to find bot');
|
||||
if (!bot.isActive) throw new Error('Bot is not active');
|
||||
|
||||
const privateKeyBot = decryptSymmetric({
|
||||
ciphertext: bot.encryptedPrivateKey,
|
||||
iv: bot.iv,
|
||||
tag: bot.tag,
|
||||
key: await getEncryptionKey()
|
||||
});
|
||||
|
||||
key = decryptAsymmetric({
|
||||
ciphertext: botKey.encryptedKey,
|
||||
nonce: botKey.nonce,
|
||||
publicKey: botKey.sender.publicKey as string,
|
||||
privateKey: privateKeyBot
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get workspace key');
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
const getKey = async ({ workspaceId }: { workspaceId: string }) => {
|
||||
const botKey = await BotKey.findOne({
|
||||
workspace: workspaceId,
|
||||
}).populate<{ sender: IUser }>("sender", "publicKey");
|
||||
|
||||
if (!botKey) throw new Error("Failed to find bot key");
|
||||
|
||||
const bot = await Bot.findOne({
|
||||
workspace: workspaceId,
|
||||
}).select("+encryptedPrivateKey +iv +tag");
|
||||
|
||||
if (!bot) throw new Error("Failed to find bot");
|
||||
if (!bot.isActive) throw new Error("Bot is not active");
|
||||
|
||||
const privateKeyBot = decryptSymmetric({
|
||||
ciphertext: bot.encryptedPrivateKey,
|
||||
iv: bot.iv,
|
||||
tag: bot.tag,
|
||||
key: await getEncryptionKey(),
|
||||
});
|
||||
|
||||
const key = decryptAsymmetric({
|
||||
ciphertext: botKey.encryptedKey,
|
||||
nonce: botKey.nonce,
|
||||
publicKey: botKey.sender.publicKey as string,
|
||||
privateKey: privateKeyBot,
|
||||
});
|
||||
|
||||
return key;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return symmetrically encrypted [plaintext] using the
|
||||
* key for workspace with id [workspaceId]
|
||||
* key for workspace with id [workspaceId]
|
||||
* @param {Object} obj1
|
||||
* @param {String} obj1.workspaceId - id of workspace
|
||||
* @param {String} obj1.plaintext - plaintext to encrypt
|
||||
*/
|
||||
const encryptSymmetricHelper = async ({
|
||||
workspaceId,
|
||||
plaintext
|
||||
workspaceId,
|
||||
plaintext,
|
||||
}: {
|
||||
workspaceId: Types.ObjectId;
|
||||
plaintext: string;
|
||||
workspaceId: Types.ObjectId;
|
||||
plaintext: string;
|
||||
}) => {
|
||||
|
||||
try {
|
||||
const key = await getKey({ workspaceId });
|
||||
const { ciphertext, iv, tag } = encryptSymmetric({
|
||||
plaintext,
|
||||
key
|
||||
});
|
||||
|
||||
return ({
|
||||
ciphertext,
|
||||
iv,
|
||||
tag
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to perform symmetric encryption with bot');
|
||||
}
|
||||
}
|
||||
const key = await getKey({ workspaceId: workspaceId.toString() });
|
||||
const { ciphertext, iv, tag } = encryptSymmetric({
|
||||
plaintext,
|
||||
key,
|
||||
});
|
||||
|
||||
return {
|
||||
ciphertext,
|
||||
iv,
|
||||
tag,
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Return symmetrically decrypted [ciphertext] using the
|
||||
* key for workspace with id [workspaceId]
|
||||
@@ -277,40 +255,31 @@ const encryptSymmetricHelper = async ({
|
||||
* @param {String} obj.tag - tag
|
||||
*/
|
||||
const decryptSymmetricHelper = async ({
|
||||
workspaceId,
|
||||
workspaceId,
|
||||
ciphertext,
|
||||
iv,
|
||||
tag,
|
||||
}: {
|
||||
workspaceId: Types.ObjectId;
|
||||
ciphertext: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
}) => {
|
||||
const key = await getKey({ workspaceId: workspaceId.toString() });
|
||||
const plaintext = decryptSymmetric({
|
||||
ciphertext,
|
||||
iv,
|
||||
tag
|
||||
}: {
|
||||
workspaceId: Types.ObjectId;
|
||||
ciphertext: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
}) => {
|
||||
let plaintext;
|
||||
try {
|
||||
const key = await getKey({ workspaceId });
|
||||
const plaintext = decryptSymmetric({
|
||||
ciphertext,
|
||||
iv,
|
||||
tag,
|
||||
key
|
||||
});
|
||||
|
||||
return plaintext;
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to perform symmetric decryption with bot');
|
||||
}
|
||||
|
||||
return plaintext;
|
||||
}
|
||||
tag,
|
||||
key,
|
||||
});
|
||||
|
||||
return plaintext;
|
||||
};
|
||||
|
||||
export {
|
||||
validateClientForBot,
|
||||
createBot,
|
||||
getSecretsHelper,
|
||||
encryptSymmetricHelper,
|
||||
decryptSymmetricHelper
|
||||
}
|
||||
validateClientForBot,
|
||||
createBot,
|
||||
getSecretsHelper,
|
||||
encryptSymmetricHelper,
|
||||
decryptSymmetricHelper,
|
||||
};
|
||||
|
@@ -1,14 +1,13 @@
|
||||
import { Types } from 'mongoose';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Bot, IBot } from '../models';
|
||||
import { EVENT_PUSH_SECRETS } from '../variables';
|
||||
import { IntegrationService } from '../services';
|
||||
import { Types } from "mongoose";
|
||||
import { Bot, IBot } from "../models";
|
||||
import { EVENT_PUSH_SECRETS } from "../variables";
|
||||
import { IntegrationService } from "../services";
|
||||
|
||||
interface Event {
|
||||
name: string;
|
||||
workspaceId: Types.ObjectId;
|
||||
environment?: string;
|
||||
payload: any;
|
||||
name: string;
|
||||
workspaceId: Types.ObjectId;
|
||||
environment?: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,39 +18,25 @@ interface Event {
|
||||
* @param {String} obj.event.workspaceId - id of workspace that event is part of
|
||||
* @param {Object} obj.event.payload - payload of event (depends on event)
|
||||
*/
|
||||
const handleEventHelper = async ({
|
||||
event
|
||||
}: {
|
||||
event: Event;
|
||||
}) => {
|
||||
const {
|
||||
workspaceId,
|
||||
environment
|
||||
} = event;
|
||||
|
||||
// TODO: moduralize bot check into separate function
|
||||
const bot = await Bot.findOne({
|
||||
workspace: workspaceId,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!bot) return;
|
||||
|
||||
try {
|
||||
switch (event.name) {
|
||||
case EVENT_PUSH_SECRETS:
|
||||
IntegrationService.syncIntegrations({
|
||||
workspaceId,
|
||||
environment
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
}
|
||||
}
|
||||
const handleEventHelper = async ({ event }: { event: Event }) => {
|
||||
const { workspaceId, environment } = event;
|
||||
|
||||
export {
|
||||
handleEventHelper
|
||||
}
|
||||
// TODO: moduralize bot check into separate function
|
||||
const bot = await Bot.findOne({
|
||||
workspace: workspaceId,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
if (!bot) return;
|
||||
|
||||
switch (event.name) {
|
||||
case EVENT_PUSH_SECRETS:
|
||||
IntegrationService.syncIntegrations({
|
||||
workspaceId,
|
||||
environment,
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export { handleEventHelper };
|
||||
|
@@ -256,7 +256,7 @@ const syncIntegrationsHelper = async ({
|
||||
integration,
|
||||
integrationAuth,
|
||||
secrets,
|
||||
accessId: access.accessId,
|
||||
accessId: access.accessId === undefined ? null : access.accessId,
|
||||
accessToken: access.accessToken
|
||||
});
|
||||
}
|
||||
@@ -482,4 +482,4 @@ export {
|
||||
getIntegrationAuthAccessHelper,
|
||||
setIntegrationAuthRefreshHelper,
|
||||
setIntegrationAuthAccessHelper
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Key, IKey } from '../models';
|
||||
|
||||
interface Key {
|
||||
@@ -27,36 +26,30 @@ const pushKeys = async ({
|
||||
workspaceId: string;
|
||||
keys: Key[];
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
// filter out already-inserted keys
|
||||
const keysSet = new Set(
|
||||
(
|
||||
await Key.find(
|
||||
{
|
||||
workspace: workspaceId
|
||||
},
|
||||
'receiver'
|
||||
)
|
||||
).map((k: IKey) => k.receiver.toString())
|
||||
);
|
||||
// filter out already-inserted keys
|
||||
const keysSet = new Set(
|
||||
(
|
||||
await Key.find(
|
||||
{
|
||||
workspace: workspaceId
|
||||
},
|
||||
'receiver'
|
||||
)
|
||||
).map((k: IKey) => k.receiver.toString())
|
||||
);
|
||||
|
||||
keys = keys.filter((key) => !keysSet.has(key.userId));
|
||||
keys = keys.filter((key) => !keysSet.has(key.userId));
|
||||
|
||||
// add new shared keys only
|
||||
await Key.insertMany(
|
||||
keys.map((k) => ({
|
||||
encryptedKey: k.encryptedKey,
|
||||
nonce: k.nonce,
|
||||
sender: userId,
|
||||
receiver: k.userId,
|
||||
workspace: workspaceId
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to push access keys');
|
||||
}
|
||||
// add new shared keys only
|
||||
await Key.insertMany(
|
||||
keys.map((k) => ({
|
||||
encryptedKey: k.encryptedKey,
|
||||
nonce: k.nonce,
|
||||
sender: userId,
|
||||
receiver: k.userId,
|
||||
workspace: workspaceId
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
export { pushKeys };
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Types } from 'mongoose';
|
||||
import {
|
||||
MembershipOrg,
|
||||
@@ -144,15 +143,7 @@ const validateMembershipOrg = async ({
|
||||
* @return {Object} membershipOrg - membership
|
||||
*/
|
||||
const findMembershipOrg = (queryObj: any) => {
|
||||
let membershipOrg;
|
||||
try {
|
||||
membershipOrg = MembershipOrg.findOne(queryObj);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to find organization membership');
|
||||
}
|
||||
|
||||
const membershipOrg = MembershipOrg.findOne(queryObj);
|
||||
return membershipOrg;
|
||||
};
|
||||
|
||||
@@ -175,33 +166,27 @@ const addMembershipsOrg = async ({
|
||||
roles: string[];
|
||||
statuses: string[];
|
||||
}) => {
|
||||
try {
|
||||
const operations = userIds.map((userId, idx) => {
|
||||
return {
|
||||
updateOne: {
|
||||
filter: {
|
||||
user: userId,
|
||||
organization: organizationId,
|
||||
role: roles[idx],
|
||||
status: statuses[idx]
|
||||
},
|
||||
update: {
|
||||
user: userId,
|
||||
organization: organizationId,
|
||||
role: roles[idx],
|
||||
status: statuses[idx]
|
||||
},
|
||||
upsert: true
|
||||
}
|
||||
};
|
||||
});
|
||||
const operations = userIds.map((userId, idx) => {
|
||||
return {
|
||||
updateOne: {
|
||||
filter: {
|
||||
user: userId,
|
||||
organization: organizationId,
|
||||
role: roles[idx],
|
||||
status: statuses[idx]
|
||||
},
|
||||
update: {
|
||||
user: userId,
|
||||
organization: organizationId,
|
||||
role: roles[idx],
|
||||
status: statuses[idx]
|
||||
},
|
||||
upsert: true
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
await MembershipOrg.bulkWrite(operations as any);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to add users to organization');
|
||||
}
|
||||
await MembershipOrg.bulkWrite(operations as any);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -214,43 +199,36 @@ const deleteMembershipOrg = async ({
|
||||
}: {
|
||||
membershipOrgId: string;
|
||||
}) => {
|
||||
let deletedMembershipOrg;
|
||||
try {
|
||||
deletedMembershipOrg = await MembershipOrg.findOneAndDelete({
|
||||
_id: membershipOrgId
|
||||
});
|
||||
const deletedMembershipOrg = await MembershipOrg.findOneAndDelete({
|
||||
_id: membershipOrgId
|
||||
});
|
||||
|
||||
if (!deletedMembershipOrg) throw new Error('Failed to delete organization membership');
|
||||
if (!deletedMembershipOrg) throw new Error('Failed to delete organization membership');
|
||||
|
||||
// delete keys associated with organization membership
|
||||
if (deletedMembershipOrg?.user) {
|
||||
// case: organization membership had a registered user
|
||||
// delete keys associated with organization membership
|
||||
if (deletedMembershipOrg?.user) {
|
||||
// case: organization membership had a registered user
|
||||
|
||||
const workspaces = (
|
||||
await Workspace.find({
|
||||
organization: deletedMembershipOrg.organization
|
||||
})
|
||||
).map((w) => w._id.toString());
|
||||
const workspaces = (
|
||||
await Workspace.find({
|
||||
organization: deletedMembershipOrg.organization
|
||||
})
|
||||
).map((w) => w._id.toString());
|
||||
|
||||
await Membership.deleteMany({
|
||||
user: deletedMembershipOrg.user,
|
||||
workspace: {
|
||||
$in: workspaces
|
||||
}
|
||||
});
|
||||
await Membership.deleteMany({
|
||||
user: deletedMembershipOrg.user,
|
||||
workspace: {
|
||||
$in: workspaces
|
||||
}
|
||||
});
|
||||
|
||||
await Key.deleteMany({
|
||||
receiver: deletedMembershipOrg.user,
|
||||
workspace: {
|
||||
$in: workspaces
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to delete organization membership');
|
||||
}
|
||||
await Key.deleteMany({
|
||||
receiver: deletedMembershipOrg.user,
|
||||
workspace: {
|
||||
$in: workspaces
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return deletedMembershipOrg;
|
||||
};
|
||||
|
@@ -1,39 +1,34 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import Stripe from 'stripe';
|
||||
import { Types } from 'mongoose';
|
||||
import Stripe from "stripe";
|
||||
import { Types } from "mongoose";
|
||||
import {
|
||||
IUser,
|
||||
User,
|
||||
IServiceAccount,
|
||||
ServiceAccount,
|
||||
IServiceTokenData,
|
||||
ServiceTokenData
|
||||
} from '../models';
|
||||
import { Organization, MembershipOrg } from '../models';
|
||||
import {
|
||||
ACCEPTED,
|
||||
AUTH_MODE_JWT,
|
||||
AUTH_MODE_SERVICE_ACCOUNT,
|
||||
AUTH_MODE_SERVICE_TOKEN,
|
||||
AUTH_MODE_API_KEY,
|
||||
OWNER
|
||||
} from '../variables';
|
||||
import {
|
||||
getStripeSecretKey,
|
||||
getStripeProductPro,
|
||||
getStripeProductTeam,
|
||||
getStripeProductStarter
|
||||
} from '../config';
|
||||
IUser,
|
||||
User,
|
||||
IServiceAccount,
|
||||
ServiceAccount,
|
||||
IServiceTokenData,
|
||||
ServiceTokenData,
|
||||
} from "../models";
|
||||
import { Organization, MembershipOrg } from "../models";
|
||||
import {
|
||||
UnauthorizedRequestError,
|
||||
OrganizationNotFoundError
|
||||
} from '../utils/errors';
|
||||
ACCEPTED,
|
||||
AUTH_MODE_JWT,
|
||||
AUTH_MODE_SERVICE_ACCOUNT,
|
||||
AUTH_MODE_SERVICE_TOKEN,
|
||||
AUTH_MODE_API_KEY,
|
||||
OWNER,
|
||||
} from "../variables";
|
||||
import {
|
||||
validateUserClientForOrganization
|
||||
} from '../helpers/user';
|
||||
getStripeSecretKey,
|
||||
getStripeProductPro,
|
||||
getStripeProductTeam,
|
||||
getStripeProductStarter,
|
||||
} from "../config";
|
||||
import {
|
||||
validateServiceAccountClientForOrganization
|
||||
} from '../helpers/serviceAccount';
|
||||
UnauthorizedRequestError,
|
||||
OrganizationNotFoundError,
|
||||
} from "../utils/errors";
|
||||
import { validateUserClientForOrganization } from "../helpers/user";
|
||||
import { validateServiceAccountClientForOrganization } from "../helpers/serviceAccount";
|
||||
|
||||
/**
|
||||
* Validate accepted clients for organization with id [organizationId]
|
||||
@@ -42,69 +37,80 @@ import {
|
||||
* @param {Types.ObjectId} obj.organizationId - id of organization to validate against
|
||||
*/
|
||||
const validateClientForOrganization = async ({
|
||||
authData,
|
||||
organizationId,
|
||||
acceptedRoles,
|
||||
acceptedStatuses
|
||||
authData,
|
||||
organizationId,
|
||||
acceptedRoles,
|
||||
acceptedStatuses,
|
||||
}: {
|
||||
authData: {
|
||||
authMode: string;
|
||||
authPayload: IUser | IServiceAccount | IServiceTokenData;
|
||||
},
|
||||
organizationId: Types.ObjectId;
|
||||
acceptedRoles: Array<'owner' | 'admin' | 'member'>;
|
||||
acceptedStatuses: Array<'invited' | 'accepted'>;
|
||||
authData: {
|
||||
authMode: string;
|
||||
authPayload: IUser | IServiceAccount | IServiceTokenData;
|
||||
};
|
||||
organizationId: Types.ObjectId;
|
||||
acceptedRoles: Array<"owner" | "admin" | "member">;
|
||||
acceptedStatuses: Array<"invited" | "accepted">;
|
||||
}) => {
|
||||
|
||||
const organization = await Organization.findById(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw OrganizationNotFoundError({
|
||||
message: 'Failed to find organization'
|
||||
});
|
||||
}
|
||||
|
||||
if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) {
|
||||
const membershipOrg = await validateUserClientForOrganization({
|
||||
user: authData.authPayload,
|
||||
organization,
|
||||
acceptedRoles,
|
||||
acceptedStatuses
|
||||
});
|
||||
|
||||
return ({ organization, membershipOrg });
|
||||
}
|
||||
const organization = await Organization.findById(organizationId);
|
||||
|
||||
if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) {
|
||||
await validateServiceAccountClientForOrganization({
|
||||
serviceAccount: authData.authPayload,
|
||||
organization
|
||||
});
|
||||
|
||||
return ({ organization });
|
||||
}
|
||||
if (!organization) {
|
||||
throw OrganizationNotFoundError({
|
||||
message: "Failed to find organization",
|
||||
});
|
||||
}
|
||||
|
||||
if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) {
|
||||
throw UnauthorizedRequestError({
|
||||
message: 'Failed service token authorization for organization'
|
||||
});
|
||||
}
|
||||
if (
|
||||
authData.authMode === AUTH_MODE_JWT &&
|
||||
authData.authPayload instanceof User
|
||||
) {
|
||||
const membershipOrg = await validateUserClientForOrganization({
|
||||
user: authData.authPayload,
|
||||
organization,
|
||||
acceptedRoles,
|
||||
acceptedStatuses,
|
||||
});
|
||||
|
||||
if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) {
|
||||
const membershipOrg = await validateUserClientForOrganization({
|
||||
user: authData.authPayload,
|
||||
organization,
|
||||
acceptedRoles,
|
||||
acceptedStatuses
|
||||
});
|
||||
|
||||
return ({ organization, membershipOrg });
|
||||
}
|
||||
|
||||
throw UnauthorizedRequestError({
|
||||
message: 'Failed client authorization for organization'
|
||||
});
|
||||
}
|
||||
return { organization, membershipOrg };
|
||||
}
|
||||
|
||||
if (
|
||||
authData.authMode === AUTH_MODE_SERVICE_ACCOUNT &&
|
||||
authData.authPayload instanceof ServiceAccount
|
||||
) {
|
||||
await validateServiceAccountClientForOrganization({
|
||||
serviceAccount: authData.authPayload,
|
||||
organization,
|
||||
});
|
||||
|
||||
return { organization };
|
||||
}
|
||||
|
||||
if (
|
||||
authData.authMode === AUTH_MODE_SERVICE_TOKEN &&
|
||||
authData.authPayload instanceof ServiceTokenData
|
||||
) {
|
||||
throw UnauthorizedRequestError({
|
||||
message: "Failed service token authorization for organization",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
authData.authMode === AUTH_MODE_API_KEY &&
|
||||
authData.authPayload instanceof User
|
||||
) {
|
||||
const membershipOrg = await validateUserClientForOrganization({
|
||||
user: authData.authPayload,
|
||||
organization,
|
||||
acceptedRoles,
|
||||
acceptedStatuses,
|
||||
});
|
||||
|
||||
return { organization, membershipOrg };
|
||||
}
|
||||
|
||||
throw UnauthorizedRequestError({
|
||||
message: "Failed client authorization for organization",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an organization with name [name]
|
||||
@@ -114,43 +120,37 @@ const validateClientForOrganization = async ({
|
||||
* @param {Object} organization - new organization
|
||||
*/
|
||||
const createOrganization = async ({
|
||||
name,
|
||||
email
|
||||
name,
|
||||
email,
|
||||
}: {
|
||||
name: string;
|
||||
email: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}) => {
|
||||
let organization;
|
||||
try {
|
||||
// register stripe account
|
||||
const stripe = new Stripe(await getStripeSecretKey(), {
|
||||
apiVersion: '2022-08-01'
|
||||
});
|
||||
let organization;
|
||||
// register stripe account
|
||||
const stripe = new Stripe(await getStripeSecretKey(), {
|
||||
apiVersion: "2022-08-01",
|
||||
});
|
||||
|
||||
if (await getStripeSecretKey()) {
|
||||
const customer = await stripe.customers.create({
|
||||
email,
|
||||
description: name
|
||||
});
|
||||
if (await getStripeSecretKey()) {
|
||||
const customer = await stripe.customers.create({
|
||||
email,
|
||||
description: name,
|
||||
});
|
||||
|
||||
organization = await new Organization({
|
||||
name,
|
||||
customerId: customer.id
|
||||
}).save();
|
||||
} else {
|
||||
organization = await new Organization({
|
||||
name
|
||||
}).save();
|
||||
}
|
||||
organization = await new Organization({
|
||||
name,
|
||||
customerId: customer.id,
|
||||
}).save();
|
||||
} else {
|
||||
organization = await new Organization({
|
||||
name,
|
||||
}).save();
|
||||
}
|
||||
|
||||
await initSubscriptionOrg({ organizationId: organization._id });
|
||||
} catch (err) {
|
||||
Sentry.setUser({ email });
|
||||
Sentry.captureException(err);
|
||||
throw new Error(`Failed to create organization [err=${err}]`);
|
||||
}
|
||||
await initSubscriptionOrg({ organizationId: organization._id });
|
||||
|
||||
return organization;
|
||||
return organization;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -162,57 +162,52 @@ const createOrganization = async ({
|
||||
* @return {Subscription} obj.subscription - new subscription
|
||||
*/
|
||||
const initSubscriptionOrg = async ({
|
||||
organizationId
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId: Types.ObjectId;
|
||||
organizationId: Types.ObjectId;
|
||||
}) => {
|
||||
let stripeSubscription;
|
||||
let subscription;
|
||||
try {
|
||||
// find organization
|
||||
const organization = await Organization.findOne({
|
||||
_id: organizationId
|
||||
});
|
||||
let stripeSubscription;
|
||||
let subscription;
|
||||
|
||||
if (organization) {
|
||||
if (organization.customerId) {
|
||||
// initialize starter subscription with quantity of 0
|
||||
const stripe = new Stripe(await getStripeSecretKey(), {
|
||||
apiVersion: '2022-08-01'
|
||||
});
|
||||
// find organization
|
||||
const organization = await Organization.findOne({
|
||||
_id: organizationId,
|
||||
});
|
||||
|
||||
const productToPriceMap = {
|
||||
starter: await getStripeProductStarter(),
|
||||
team: await getStripeProductTeam(),
|
||||
pro: await getStripeProductPro()
|
||||
};
|
||||
if (organization) {
|
||||
if (organization.customerId) {
|
||||
// initialize starter subscription with quantity of 0
|
||||
const stripe = new Stripe(await getStripeSecretKey(), {
|
||||
apiVersion: "2022-08-01",
|
||||
});
|
||||
|
||||
stripeSubscription = await stripe.subscriptions.create({
|
||||
customer: organization.customerId,
|
||||
items: [
|
||||
{
|
||||
price: productToPriceMap['starter'],
|
||||
quantity: 1
|
||||
}
|
||||
],
|
||||
payment_behavior: 'default_incomplete',
|
||||
proration_behavior: 'none',
|
||||
expand: ['latest_invoice.payment_intent']
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to initialize free organization subscription');
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to initialize free organization subscription');
|
||||
}
|
||||
const productToPriceMap = {
|
||||
starter: await getStripeProductStarter(),
|
||||
team: await getStripeProductTeam(),
|
||||
pro: await getStripeProductPro(),
|
||||
};
|
||||
|
||||
return {
|
||||
stripeSubscription,
|
||||
subscription
|
||||
};
|
||||
stripeSubscription = await stripe.subscriptions.create({
|
||||
customer: organization.customerId,
|
||||
items: [
|
||||
{
|
||||
price: productToPriceMap["starter"],
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
payment_behavior: "default_incomplete",
|
||||
proration_behavior: "none",
|
||||
expand: ["latest_invoice.payment_intent"],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new Error("Failed to initialize free organization subscription");
|
||||
}
|
||||
|
||||
return {
|
||||
stripeSubscription,
|
||||
subscription,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -222,54 +217,49 @@ const initSubscriptionOrg = async ({
|
||||
* @param {Number} obj.organizationId - id of subscription's organization
|
||||
*/
|
||||
const updateSubscriptionOrgQuantity = async ({
|
||||
organizationId
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
organizationId: string;
|
||||
}) => {
|
||||
let stripeSubscription;
|
||||
try {
|
||||
// find organization
|
||||
const organization = await Organization.findOne({
|
||||
_id: organizationId
|
||||
});
|
||||
let stripeSubscription;
|
||||
// find organization
|
||||
const organization = await Organization.findOne({
|
||||
_id: organizationId,
|
||||
});
|
||||
|
||||
if (organization && organization.customerId) {
|
||||
const quantity = await MembershipOrg.countDocuments({
|
||||
organization: organizationId,
|
||||
status: ACCEPTED
|
||||
});
|
||||
if (organization && organization.customerId) {
|
||||
const quantity = await MembershipOrg.countDocuments({
|
||||
organization: organizationId,
|
||||
status: ACCEPTED,
|
||||
});
|
||||
|
||||
const stripe = new Stripe(await getStripeSecretKey(), {
|
||||
apiVersion: '2022-08-01'
|
||||
});
|
||||
const stripe = new Stripe(await getStripeSecretKey(), {
|
||||
apiVersion: "2022-08-01",
|
||||
});
|
||||
|
||||
const subscription = (
|
||||
await stripe.subscriptions.list({
|
||||
customer: organization.customerId
|
||||
})
|
||||
).data[0];
|
||||
const subscription = (
|
||||
await stripe.subscriptions.list({
|
||||
customer: organization.customerId,
|
||||
})
|
||||
).data[0];
|
||||
|
||||
stripeSubscription = await stripe.subscriptions.update(subscription.id, {
|
||||
items: [
|
||||
{
|
||||
id: subscription.items.data[0].id,
|
||||
price: subscription.items.data[0].price.id,
|
||||
quantity
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
}
|
||||
stripeSubscription = await stripe.subscriptions.update(subscription.id, {
|
||||
items: [
|
||||
{
|
||||
id: subscription.items.data[0].id,
|
||||
price: subscription.items.data[0].price.id,
|
||||
quantity,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return stripeSubscription;
|
||||
return stripeSubscription;
|
||||
};
|
||||
|
||||
export {
|
||||
validateClientForOrganization,
|
||||
createOrganization,
|
||||
initSubscriptionOrg,
|
||||
updateSubscriptionOrgQuantity
|
||||
};
|
||||
validateClientForOrganization,
|
||||
createOrganization,
|
||||
initSubscriptionOrg,
|
||||
updateSubscriptionOrgQuantity,
|
||||
};
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,15 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Types } from 'mongoose';
|
||||
import { TokenData } from '../models';
|
||||
import crypto from 'crypto';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { Types } from "mongoose";
|
||||
import { TokenData } from "../models";
|
||||
import crypto from "crypto";
|
||||
import bcrypt from "bcrypt";
|
||||
import {
|
||||
TOKEN_EMAIL_CONFIRMATION,
|
||||
TOKEN_EMAIL_MFA,
|
||||
TOKEN_EMAIL_ORG_INVITATION,
|
||||
TOKEN_EMAIL_PASSWORD_RESET
|
||||
} from '../variables';
|
||||
import { UnauthorizedRequestError } from '../utils/errors';
|
||||
import { getSaltRounds } from '../config';
|
||||
TOKEN_EMAIL_CONFIRMATION,
|
||||
TOKEN_EMAIL_MFA,
|
||||
TOKEN_EMAIL_ORG_INVITATION,
|
||||
TOKEN_EMAIL_PASSWORD_RESET,
|
||||
} from "../variables";
|
||||
import { UnauthorizedRequestError } from "../utils/errors";
|
||||
import { getSaltRounds } from "../config";
|
||||
|
||||
/**
|
||||
* Create and store a token in the database for purpose [type]
|
||||
@@ -22,194 +21,197 @@ import { getSaltRounds } from '../config';
|
||||
* @returns {String} token - the created token
|
||||
*/
|
||||
const createTokenHelper = async ({
|
||||
type,
|
||||
email,
|
||||
phoneNumber,
|
||||
organizationId
|
||||
type,
|
||||
email,
|
||||
phoneNumber,
|
||||
organizationId,
|
||||
}: {
|
||||
type: 'emailConfirmation' | 'emailMfa' | 'organizationInvitation' | 'passwordReset';
|
||||
type:
|
||||
| "emailConfirmation"
|
||||
| "emailMfa"
|
||||
| "organizationInvitation"
|
||||
| "passwordReset";
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
organizationId?: Types.ObjectId;
|
||||
}) => {
|
||||
let token, expiresAt, triesLeft;
|
||||
// generate random token based on specified token use-case
|
||||
// type [type]
|
||||
switch (type) {
|
||||
case TOKEN_EMAIL_CONFIRMATION:
|
||||
// generate random 6-digit code
|
||||
token = String(crypto.randomInt(Math.pow(10, 5), Math.pow(10, 6) - 1));
|
||||
expiresAt = new Date(new Date().getTime() + 86400000);
|
||||
break;
|
||||
case TOKEN_EMAIL_MFA:
|
||||
// generate random 6-digit code
|
||||
token = String(crypto.randomInt(Math.pow(10, 5), Math.pow(10, 6) - 1));
|
||||
triesLeft = 5;
|
||||
expiresAt = new Date(new Date().getTime() + 300000);
|
||||
break;
|
||||
case TOKEN_EMAIL_ORG_INVITATION:
|
||||
// generate random hex
|
||||
token = crypto.randomBytes(16).toString("hex");
|
||||
expiresAt = new Date(new Date().getTime() + 259200000);
|
||||
break;
|
||||
case TOKEN_EMAIL_PASSWORD_RESET:
|
||||
// generate random hex
|
||||
token = crypto.randomBytes(16).toString("hex");
|
||||
expiresAt = new Date(new Date().getTime() + 86400000);
|
||||
break;
|
||||
default:
|
||||
token = crypto.randomBytes(16).toString("hex");
|
||||
expiresAt = new Date();
|
||||
break;
|
||||
}
|
||||
|
||||
interface TokenDataQuery {
|
||||
type: string;
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
organizationId?: Types.ObjectId
|
||||
}) => {
|
||||
let token, expiresAt, triesLeft;
|
||||
try {
|
||||
// generate random token based on specified token use-case
|
||||
// type [type]
|
||||
switch (type) {
|
||||
case TOKEN_EMAIL_CONFIRMATION:
|
||||
// generate random 6-digit code
|
||||
token = String(crypto.randomInt(Math.pow(10, 5), Math.pow(10, 6) - 1));
|
||||
expiresAt = new Date((new Date()).getTime() + 86400000);
|
||||
break;
|
||||
case TOKEN_EMAIL_MFA:
|
||||
// generate random 6-digit code
|
||||
token = String(crypto.randomInt(Math.pow(10, 5), Math.pow(10, 6) - 1));
|
||||
triesLeft = 5;
|
||||
expiresAt = new Date((new Date()).getTime() + 300000);
|
||||
break;
|
||||
case TOKEN_EMAIL_ORG_INVITATION:
|
||||
// generate random hex
|
||||
token = crypto.randomBytes(16).toString('hex');
|
||||
expiresAt = new Date((new Date()).getTime() + 259200000);
|
||||
break;
|
||||
case TOKEN_EMAIL_PASSWORD_RESET:
|
||||
// generate random hex
|
||||
token = crypto.randomBytes(16).toString('hex');
|
||||
expiresAt = new Date((new Date()).getTime() + 86400000);
|
||||
break;
|
||||
default:
|
||||
token = crypto.randomBytes(16).toString('hex');
|
||||
expiresAt = new Date();
|
||||
break;
|
||||
}
|
||||
|
||||
interface TokenDataQuery {
|
||||
type: string;
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
organization?: Types.ObjectId;
|
||||
}
|
||||
|
||||
interface TokenDataUpdate {
|
||||
type: string;
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
organization?: Types.ObjectId;
|
||||
tokenHash: string;
|
||||
triesLeft?: number;
|
||||
expiresAt: Date;
|
||||
}
|
||||
organization?: Types.ObjectId;
|
||||
}
|
||||
|
||||
const query: TokenDataQuery = { type };
|
||||
const update: TokenDataUpdate = {
|
||||
type,
|
||||
tokenHash: await bcrypt.hash(token, await getSaltRounds()),
|
||||
expiresAt
|
||||
}
|
||||
interface TokenDataUpdate {
|
||||
type: string;
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
organization?: Types.ObjectId;
|
||||
tokenHash: string;
|
||||
triesLeft?: number;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
if (email) {
|
||||
query.email = email;
|
||||
update.email = email;
|
||||
}
|
||||
if (phoneNumber) {
|
||||
query.phoneNumber = phoneNumber;
|
||||
update.phoneNumber = phoneNumber;
|
||||
}
|
||||
if (organizationId) {
|
||||
query.organization = organizationId
|
||||
update.organization = organizationId
|
||||
}
|
||||
|
||||
if (triesLeft) {
|
||||
update.triesLeft = triesLeft;
|
||||
}
|
||||
|
||||
await TokenData.findOneAndUpdate(
|
||||
query,
|
||||
update,
|
||||
{
|
||||
new: true,
|
||||
upsert: true
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error(
|
||||
"Failed to create token"
|
||||
);
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
const query: TokenDataQuery = { type };
|
||||
const update: TokenDataUpdate = {
|
||||
type,
|
||||
tokenHash: await bcrypt.hash(token, await getSaltRounds()),
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
if (email) {
|
||||
query.email = email;
|
||||
update.email = email;
|
||||
}
|
||||
if (phoneNumber) {
|
||||
query.phoneNumber = phoneNumber;
|
||||
update.phoneNumber = phoneNumber;
|
||||
}
|
||||
if (organizationId) {
|
||||
query.organization = organizationId;
|
||||
update.organization = organizationId;
|
||||
}
|
||||
|
||||
if (triesLeft) {
|
||||
update.triesLeft = triesLeft;
|
||||
}
|
||||
|
||||
await TokenData.findOneAndUpdate(query, update, {
|
||||
new: true,
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.email - email associated with the token
|
||||
* @param {String} obj.token - value of the token
|
||||
*/
|
||||
const validateTokenHelper = async ({
|
||||
type,
|
||||
email,
|
||||
phoneNumber,
|
||||
organizationId,
|
||||
token
|
||||
type,
|
||||
email,
|
||||
phoneNumber,
|
||||
organizationId,
|
||||
token,
|
||||
}: {
|
||||
type: 'emailConfirmation' | 'emailMfa' | 'organizationInvitation' | 'passwordReset';
|
||||
type:
|
||||
| "emailConfirmation"
|
||||
| "emailMfa"
|
||||
| "organizationInvitation"
|
||||
| "passwordReset";
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
organizationId?: Types.ObjectId;
|
||||
token: string;
|
||||
}) => {
|
||||
interface Query {
|
||||
type: string;
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
organizationId?: Types.ObjectId;
|
||||
token: string;
|
||||
}) => {
|
||||
interface Query {
|
||||
type: string;
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
organization?: Types.ObjectId;
|
||||
}
|
||||
organization?: Types.ObjectId;
|
||||
}
|
||||
|
||||
const query: Query = { type };
|
||||
const query: Query = { type };
|
||||
|
||||
if (email) { query.email = email; }
|
||||
if (phoneNumber) { query.phoneNumber = phoneNumber; }
|
||||
if (organizationId) { query.organization = organizationId; }
|
||||
if (email) {
|
||||
query.email = email;
|
||||
}
|
||||
if (phoneNumber) {
|
||||
query.phoneNumber = phoneNumber;
|
||||
}
|
||||
if (organizationId) {
|
||||
query.organization = organizationId;
|
||||
}
|
||||
|
||||
const tokenData = await TokenData.findOne(query).select('+tokenHash');
|
||||
|
||||
if (!tokenData) throw new Error('Failed to find token to validate');
|
||||
|
||||
if (tokenData.expiresAt < new Date()) {
|
||||
// case: token expired
|
||||
await TokenData.findByIdAndDelete(tokenData._id);
|
||||
throw UnauthorizedRequestError({
|
||||
message: 'MFA session expired. Please log in again',
|
||||
context: {
|
||||
code: 'mfa_expired'
|
||||
}
|
||||
});
|
||||
}
|
||||
const tokenData = await TokenData.findOne(query).select("+tokenHash");
|
||||
|
||||
const isValid = await bcrypt.compare(token, tokenData.tokenHash);
|
||||
if (!isValid) {
|
||||
// case: token is not valid
|
||||
if (tokenData?.triesLeft !== undefined) {
|
||||
// case: token has a try-limit
|
||||
if (tokenData.triesLeft === 1) {
|
||||
// case: token is out of tries
|
||||
await TokenData.findByIdAndDelete(tokenData._id);
|
||||
} else {
|
||||
// case: token has more than 1 try left
|
||||
await TokenData.findByIdAndUpdate(tokenData._id, {
|
||||
triesLeft: tokenData.triesLeft - 1
|
||||
}, {
|
||||
new: true
|
||||
});
|
||||
}
|
||||
if (!tokenData) throw new Error("Failed to find token to validate");
|
||||
|
||||
throw UnauthorizedRequestError({
|
||||
message: 'MFA code is invalid',
|
||||
context: {
|
||||
code: 'mfa_invalid',
|
||||
triesLeft: tokenData.triesLeft - 1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
throw UnauthorizedRequestError({
|
||||
message: 'MFA code is invalid',
|
||||
context: {
|
||||
code: 'mfa_invalid'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// case: token is valid
|
||||
if (tokenData.expiresAt < new Date()) {
|
||||
// case: token expired
|
||||
await TokenData.findByIdAndDelete(tokenData._id);
|
||||
}
|
||||
throw UnauthorizedRequestError({
|
||||
message: "MFA session expired. Please log in again",
|
||||
context: {
|
||||
code: "mfa_expired",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
createTokenHelper,
|
||||
validateTokenHelper
|
||||
}
|
||||
const isValid = await bcrypt.compare(token, tokenData.tokenHash);
|
||||
if (!isValid) {
|
||||
// case: token is not valid
|
||||
if (tokenData?.triesLeft !== undefined) {
|
||||
// case: token has a try-limit
|
||||
if (tokenData.triesLeft === 1) {
|
||||
// case: token is out of tries
|
||||
await TokenData.findByIdAndDelete(tokenData._id);
|
||||
} else {
|
||||
// case: token has more than 1 try left
|
||||
await TokenData.findByIdAndUpdate(
|
||||
tokenData._id,
|
||||
{
|
||||
triesLeft: tokenData.triesLeft - 1,
|
||||
},
|
||||
{
|
||||
new: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
throw UnauthorizedRequestError({
|
||||
message: "MFA code is invalid",
|
||||
context: {
|
||||
code: "mfa_invalid",
|
||||
triesLeft: tokenData.triesLeft - 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw UnauthorizedRequestError({
|
||||
message: "MFA code is invalid",
|
||||
context: {
|
||||
code: "mfa_invalid",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// case: token is valid
|
||||
await TokenData.findByIdAndDelete(tokenData._id);
|
||||
};
|
||||
|
||||
export { createTokenHelper, validateTokenHelper };
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Types } from 'mongoose';
|
||||
import {
|
||||
IUser,
|
||||
@@ -28,16 +27,9 @@ import {
|
||||
* @returns {Object} user - the initialized user
|
||||
*/
|
||||
const setupAccount = async ({ email }: { email: string }) => {
|
||||
let user;
|
||||
try {
|
||||
user = await new User({
|
||||
email
|
||||
}).save();
|
||||
} catch (err) {
|
||||
Sentry.setUser({ email });
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to set up account');
|
||||
}
|
||||
const user = await new User({
|
||||
email
|
||||
}).save();
|
||||
|
||||
return user;
|
||||
};
|
||||
@@ -89,34 +81,27 @@ const completeAccount = async ({
|
||||
salt: string;
|
||||
verifier: string;
|
||||
}) => {
|
||||
let user;
|
||||
try {
|
||||
const options = {
|
||||
new: true
|
||||
};
|
||||
user = await User.findByIdAndUpdate(
|
||||
userId,
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
encryptionVersion,
|
||||
protectedKey,
|
||||
protectedKeyIV,
|
||||
protectedKeyTag,
|
||||
publicKey,
|
||||
encryptedPrivateKey,
|
||||
iv: encryptedPrivateKeyIV,
|
||||
tag: encryptedPrivateKeyTag,
|
||||
salt,
|
||||
verifier
|
||||
},
|
||||
options
|
||||
);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to complete account set up');
|
||||
}
|
||||
const options = {
|
||||
new: true
|
||||
};
|
||||
const user = await User.findByIdAndUpdate(
|
||||
userId,
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
encryptionVersion,
|
||||
protectedKey,
|
||||
protectedKeyIV,
|
||||
protectedKeyTag,
|
||||
publicKey,
|
||||
encryptedPrivateKey,
|
||||
iv: encryptedPrivateKeyIV,
|
||||
tag: encryptedPrivateKeyTag,
|
||||
salt,
|
||||
verifier
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
return user;
|
||||
};
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { IIntegrationAuth } from "../models";
|
||||
import request from '../config/request';
|
||||
import request from "../config/request";
|
||||
import {
|
||||
INTEGRATION_AZURE_KEY_VAULT,
|
||||
INTEGRATION_AWS_PARAMETER_STORE,
|
||||
@@ -26,7 +25,7 @@ import {
|
||||
INTEGRATION_FLYIO_API_URL,
|
||||
INTEGRATION_CIRCLECI_API_URL,
|
||||
INTEGRATION_TRAVISCI_API_URL,
|
||||
INTEGRATION_SUPABASE_API_URL
|
||||
INTEGRATION_SUPABASE_API_URL,
|
||||
} from "../variables";
|
||||
|
||||
interface App {
|
||||
@@ -47,87 +46,80 @@ interface App {
|
||||
const getApps = async ({
|
||||
integrationAuth,
|
||||
accessToken,
|
||||
teamId
|
||||
teamId,
|
||||
}: {
|
||||
integrationAuth: IIntegrationAuth;
|
||||
accessToken: string;
|
||||
teamId?: string;
|
||||
}) => {
|
||||
|
||||
let apps: App[] = [];
|
||||
try {
|
||||
switch (integrationAuth.integration) {
|
||||
case INTEGRATION_AZURE_KEY_VAULT:
|
||||
apps = [];
|
||||
break;
|
||||
case INTEGRATION_AWS_PARAMETER_STORE:
|
||||
apps = [];
|
||||
break;
|
||||
case INTEGRATION_AWS_SECRET_MANAGER:
|
||||
apps = [];
|
||||
break;
|
||||
case INTEGRATION_HEROKU:
|
||||
apps = await getAppsHeroku({
|
||||
accessToken,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_VERCEL:
|
||||
apps = await getAppsVercel({
|
||||
integrationAuth,
|
||||
accessToken,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_NETLIFY:
|
||||
apps = await getAppsNetlify({
|
||||
accessToken,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_GITHUB:
|
||||
apps = await getAppsGithub({
|
||||
accessToken,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_GITLAB:
|
||||
apps = await getAppsGitlab({
|
||||
accessToken,
|
||||
teamId
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_RENDER:
|
||||
apps = await getAppsRender({
|
||||
accessToken,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_RAILWAY:
|
||||
apps = await getAppsRailway({
|
||||
accessToken
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_FLYIO:
|
||||
apps = await getAppsFlyio({
|
||||
accessToken,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_CIRCLECI:
|
||||
apps = await getAppsCircleCI({
|
||||
accessToken,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_TRAVISCI:
|
||||
apps = await getAppsTravisCI({
|
||||
accessToken,
|
||||
})
|
||||
break;
|
||||
case INTEGRATION_SUPABASE:
|
||||
apps = await getAppsSupabase({
|
||||
accessToken
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error("Failed to get integration apps");
|
||||
switch (integrationAuth.integration) {
|
||||
case INTEGRATION_AZURE_KEY_VAULT:
|
||||
apps = [];
|
||||
break;
|
||||
case INTEGRATION_AWS_PARAMETER_STORE:
|
||||
apps = [];
|
||||
break;
|
||||
case INTEGRATION_AWS_SECRET_MANAGER:
|
||||
apps = [];
|
||||
break;
|
||||
case INTEGRATION_HEROKU:
|
||||
apps = await getAppsHeroku({
|
||||
accessToken,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_VERCEL:
|
||||
apps = await getAppsVercel({
|
||||
integrationAuth,
|
||||
accessToken,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_NETLIFY:
|
||||
apps = await getAppsNetlify({
|
||||
accessToken,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_GITHUB:
|
||||
apps = await getAppsGithub({
|
||||
accessToken,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_GITLAB:
|
||||
apps = await getAppsGitlab({
|
||||
accessToken,
|
||||
teamId,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_RENDER:
|
||||
apps = await getAppsRender({
|
||||
accessToken,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_RAILWAY:
|
||||
apps = await getAppsRailway({
|
||||
accessToken,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_FLYIO:
|
||||
apps = await getAppsFlyio({
|
||||
accessToken,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_CIRCLECI:
|
||||
apps = await getAppsCircleCI({
|
||||
accessToken,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_TRAVISCI:
|
||||
apps = await getAppsTravisCI({
|
||||
accessToken,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_SUPABASE:
|
||||
apps = await getAppsSupabase({
|
||||
accessToken,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return apps;
|
||||
@@ -141,25 +133,18 @@ const getApps = async ({
|
||||
* @returns {String} apps.name - name of Heroku app
|
||||
*/
|
||||
const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => {
|
||||
let apps;
|
||||
try {
|
||||
const res = (
|
||||
await request.get(`${INTEGRATION_HEROKU_API_URL}/apps`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.heroku+json; version=3",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
).data;
|
||||
const res = (
|
||||
await request.get(`${INTEGRATION_HEROKU_API_URL}/apps`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.heroku+json; version=3",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
).data;
|
||||
|
||||
apps = res.map((a: any) => ({
|
||||
name: a.name,
|
||||
}));
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error("Failed to get Heroku integration apps");
|
||||
}
|
||||
const apps = res.map((a: any) => ({
|
||||
name: a.name,
|
||||
}));
|
||||
|
||||
return apps;
|
||||
};
|
||||
@@ -178,33 +163,26 @@ const getAppsVercel = async ({
|
||||
integrationAuth: IIntegrationAuth;
|
||||
accessToken: string;
|
||||
}) => {
|
||||
let apps;
|
||||
try {
|
||||
const res = (
|
||||
await request.get(`${INTEGRATION_VERCEL_API_URL}/v9/projects`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Accept-Encoding': 'application/json'
|
||||
},
|
||||
...(integrationAuth?.teamId
|
||||
? {
|
||||
params: {
|
||||
teamId: integrationAuth.teamId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
).data;
|
||||
const res = (
|
||||
await request.get(`${INTEGRATION_VERCEL_API_URL}/v9/projects`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
...(integrationAuth?.teamId
|
||||
? {
|
||||
params: {
|
||||
teamId: integrationAuth.teamId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
).data;
|
||||
|
||||
apps = res.projects.map((a: any) => ({
|
||||
name: a.name,
|
||||
appId: a.id
|
||||
}));
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error("Failed to get Vercel integration apps");
|
||||
}
|
||||
const apps = res.projects.map((a: any) => ({
|
||||
name: a.name,
|
||||
appId: a.id,
|
||||
}));
|
||||
|
||||
return apps;
|
||||
};
|
||||
@@ -218,43 +196,41 @@ const getAppsVercel = async ({
|
||||
*/
|
||||
const getAppsNetlify = async ({ accessToken }: { accessToken: string }) => {
|
||||
const apps: any = [];
|
||||
try {
|
||||
let page = 1;
|
||||
const perPage = 10;
|
||||
let hasMorePages = true;
|
||||
|
||||
// paginate through all sites
|
||||
while (hasMorePages) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(perPage)
|
||||
});
|
||||
let page = 1;
|
||||
const perPage = 10;
|
||||
let hasMorePages = true;
|
||||
|
||||
const { data } = await request.get(`${INTEGRATION_NETLIFY_API_URL}/api/v1/sites`, {
|
||||
// paginate through all sites
|
||||
while (hasMorePages) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(perPage),
|
||||
filter: 'all'
|
||||
});
|
||||
|
||||
const { data } = await request.get(
|
||||
`${INTEGRATION_NETLIFY_API_URL}/api/v1/sites`,
|
||||
{
|
||||
params,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Accept-Encoding': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
data.map((a: any) => {
|
||||
apps.push({
|
||||
name: a.name,
|
||||
appId: a.site_id
|
||||
});
|
||||
});
|
||||
|
||||
if (data.length < perPage) {
|
||||
hasMorePages = false;
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
page++;
|
||||
data.map((a: any) => {
|
||||
apps.push({
|
||||
name: a.name,
|
||||
appId: a.site_id,
|
||||
});
|
||||
});
|
||||
|
||||
if (data.length < perPage) {
|
||||
hasMorePages = false;
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error("Failed to get Netlify integration apps");
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
return apps;
|
||||
@@ -268,67 +244,59 @@ const getAppsNetlify = async ({ accessToken }: { accessToken: string }) => {
|
||||
* @returns {String} apps.name - name of Github site
|
||||
*/
|
||||
const getAppsGithub = async ({ accessToken }: { accessToken: string }) => {
|
||||
let apps;
|
||||
try {
|
||||
interface GitHubApp {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: {
|
||||
admin: boolean;
|
||||
};
|
||||
owner: {
|
||||
login: string;
|
||||
interface GitHubApp {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: {
|
||||
admin: boolean;
|
||||
};
|
||||
owner: {
|
||||
login: string;
|
||||
};
|
||||
}
|
||||
|
||||
const octokit = new Octokit({
|
||||
auth: accessToken,
|
||||
});
|
||||
|
||||
const getAllRepos = async () => {
|
||||
let repos: GitHubApp[] = [];
|
||||
let page = 1;
|
||||
const per_page = 100;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const response = await octokit.request(
|
||||
"GET /user/repos{?visibility,affiliation,type,sort,direction,per_page,page,since,before}",
|
||||
{
|
||||
per_page,
|
||||
page,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.length > 0) {
|
||||
repos = repos.concat(response.data);
|
||||
page++;
|
||||
} else {
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
const octokit = new Octokit({
|
||||
auth: accessToken,
|
||||
return repos;
|
||||
};
|
||||
|
||||
const repos = await getAllRepos();
|
||||
|
||||
const apps = repos
|
||||
.filter((a: GitHubApp) => a.permissions.admin === true)
|
||||
.map((a: GitHubApp) => {
|
||||
return {
|
||||
appId: a.id,
|
||||
name: a.name,
|
||||
owner: a.owner.login,
|
||||
};
|
||||
});
|
||||
|
||||
const getAllRepos = async () => {
|
||||
let repos: GitHubApp[] = [];
|
||||
let page = 1;
|
||||
const per_page = 100;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const response = await octokit.request(
|
||||
"GET /user/repos{?visibility,affiliation,type,sort,direction,per_page,page,since,before}",
|
||||
{
|
||||
per_page,
|
||||
page,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.length > 0) {
|
||||
repos = repos.concat(response.data);
|
||||
page++;
|
||||
} else {
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
return repos;
|
||||
};
|
||||
|
||||
const repos = await getAllRepos();
|
||||
|
||||
apps = repos
|
||||
.filter((a: GitHubApp) => a.permissions.admin === true)
|
||||
.map((a: GitHubApp) => {
|
||||
return {
|
||||
appId: a.id,
|
||||
name: a.name,
|
||||
owner: a.owner.login,
|
||||
};
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error("Failed to get Github repos");
|
||||
}
|
||||
|
||||
return apps;
|
||||
};
|
||||
|
||||
@@ -341,29 +309,20 @@ const getAppsGithub = async ({ accessToken }: { accessToken: string }) => {
|
||||
* @returns {String} apps.appId - id of Render service
|
||||
*/
|
||||
const getAppsRender = async ({ accessToken }: { accessToken: string }) => {
|
||||
let apps: any;
|
||||
try {
|
||||
const res = (
|
||||
await request.get(`${INTEGRATION_RENDER_API_URL}/v1/services`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Accept-Encoding': 'application/json',
|
||||
},
|
||||
})
|
||||
).data;
|
||||
|
||||
apps = res
|
||||
.map((a: any) => ({
|
||||
name: a.service.name,
|
||||
appId: a.service.id
|
||||
}));
|
||||
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error("Failed to get Render services");
|
||||
}
|
||||
const res = (
|
||||
await request.get(`${INTEGRATION_RENDER_API_URL}/v1/services`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
})
|
||||
).data;
|
||||
|
||||
const apps = res.map((a: any) => ({
|
||||
name: a.service.name,
|
||||
appId: a.service.id,
|
||||
}));
|
||||
|
||||
return apps;
|
||||
};
|
||||
@@ -376,49 +335,51 @@ const getAppsRender = async ({ accessToken }: { accessToken: string }) => {
|
||||
* @returns {String} apps.name - name of Railway project
|
||||
* @returns {String} apps.appId - id of Railway project
|
||||
*
|
||||
*/
|
||||
*/
|
||||
const getAppsRailway = async ({ accessToken }: { accessToken: string }) => {
|
||||
let apps: any[] = [];
|
||||
try {
|
||||
const query = `
|
||||
query GetProjects($userId: String, $teamId: String) {
|
||||
projects(userId: $userId, teamId: $teamId) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
}
|
||||
const query = `
|
||||
query GetProjects($userId: String, $teamId: String) {
|
||||
projects(userId: $userId, teamId: $teamId) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
`;
|
||||
|
||||
const variables = {};
|
||||
const variables = {};
|
||||
|
||||
const { data: { data: { projects: { edges }}} } = await request.post(INTEGRATION_RAILWAY_API_URL, {
|
||||
const {
|
||||
data: {
|
||||
data: {
|
||||
projects: { edges },
|
||||
},
|
||||
},
|
||||
} = await request.post(
|
||||
INTEGRATION_RAILWAY_API_URL,
|
||||
{
|
||||
query,
|
||||
variables,
|
||||
}, {
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Encoding': 'application/json'
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
apps = edges.map((e: any) => ({
|
||||
name: e.node.name,
|
||||
appId: e.node.id
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
const apps = edges.map((e: any) => ({
|
||||
name: e.node.name,
|
||||
appId: e.node.id,
|
||||
}));
|
||||
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error("Failed to get Railway services");
|
||||
}
|
||||
|
||||
return apps;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Return list of apps for Fly.io integration
|
||||
@@ -428,41 +389,40 @@ const getAppsRailway = async ({ accessToken }: { accessToken: string }) => {
|
||||
* @returns {String} apps.name - name of Fly.io apps
|
||||
*/
|
||||
const getAppsFlyio = async ({ accessToken }: { accessToken: string }) => {
|
||||
let apps;
|
||||
try {
|
||||
const query = `
|
||||
query($role: String) {
|
||||
apps(type: "container", first: 400, role: $role) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
hostname
|
||||
}
|
||||
const query = `
|
||||
query($role: String) {
|
||||
apps(type: "container", first: 400, role: $role) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
hostname
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
`;
|
||||
|
||||
const res = (await request.post(INTEGRATION_FLYIO_API_URL, {
|
||||
query,
|
||||
variables: {
|
||||
role: null,
|
||||
const res = (
|
||||
await request.post(
|
||||
INTEGRATION_FLYIO_API_URL,
|
||||
{
|
||||
query,
|
||||
variables: {
|
||||
role: null,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: "Bearer " + accessToken,
|
||||
'Accept': 'application/json',
|
||||
'Accept-Encoding': 'application/json',
|
||||
},
|
||||
})).data.data.apps.nodes;
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer " + accessToken,
|
||||
Accept: "application/json",
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
).data.data.apps.nodes;
|
||||
|
||||
apps = res.map((a: any) => ({
|
||||
name: a.name,
|
||||
}));
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error("Failed to get Fly.io apps");
|
||||
}
|
||||
const apps = res.map((a: any) => ({
|
||||
name: a.name,
|
||||
}));
|
||||
|
||||
return apps;
|
||||
};
|
||||
@@ -475,63 +435,43 @@ const getAppsFlyio = async ({ accessToken }: { accessToken: string }) => {
|
||||
* @returns {String} apps.name - name of CircleCI apps
|
||||
*/
|
||||
const getAppsCircleCI = async ({ accessToken }: { accessToken: string }) => {
|
||||
let apps: any;
|
||||
try {
|
||||
const res = (
|
||||
await request.get(
|
||||
`${INTEGRATION_CIRCLECI_API_URL}/v1.1/projects`,
|
||||
{
|
||||
headers: {
|
||||
"Circle-Token": accessToken,
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
).data
|
||||
const res = (
|
||||
await request.get(`${INTEGRATION_CIRCLECI_API_URL}/v1.1/projects`, {
|
||||
headers: {
|
||||
"Circle-Token": accessToken,
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
})
|
||||
).data;
|
||||
|
||||
const apps = res?.map((a: any) => {
|
||||
return {
|
||||
name: a?.reponame,
|
||||
};
|
||||
});
|
||||
|
||||
apps = res?.map((a: any) => {
|
||||
return {
|
||||
name: a?.reponame
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error("Failed to get CircleCI projects");
|
||||
}
|
||||
|
||||
return apps;
|
||||
};
|
||||
|
||||
const getAppsTravisCI = async ({ accessToken }: { accessToken: string }) => {
|
||||
let apps: any;
|
||||
try {
|
||||
const res = (
|
||||
await request.get(
|
||||
`${INTEGRATION_TRAVISCI_API_URL}/repos`,
|
||||
{
|
||||
headers: {
|
||||
"Authorization": `token ${accessToken}`,
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
).data;
|
||||
const res = (
|
||||
await request.get(`${INTEGRATION_TRAVISCI_API_URL}/repos`, {
|
||||
headers: {
|
||||
Authorization: `token ${accessToken}`,
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
})
|
||||
).data;
|
||||
|
||||
const apps = res?.map((a: any) => {
|
||||
return {
|
||||
name: a?.slug?.split("/")[1],
|
||||
appId: a?.id,
|
||||
};
|
||||
});
|
||||
|
||||
apps = res?.map((a: any) => {
|
||||
return {
|
||||
name: a?.slug?.split("/")[1],
|
||||
appId: a?.id,
|
||||
}
|
||||
});
|
||||
}catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error("Failed to get TravisCI projects");
|
||||
}
|
||||
|
||||
return apps;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Return list of repositories for GitLab integration
|
||||
@@ -540,112 +480,98 @@ const getAppsTravisCI = async ({ accessToken }: { accessToken: string }) => {
|
||||
* @returns {Object[]} apps - names of GitLab sites
|
||||
* @returns {String} apps.name - name of GitLab site
|
||||
*/
|
||||
const getAppsGitlab = async ({
|
||||
const getAppsGitlab = async ({
|
||||
accessToken,
|
||||
teamId
|
||||
teamId,
|
||||
}: {
|
||||
accessToken: string;
|
||||
teamId?: string;
|
||||
}) => {
|
||||
const apps: App[] = [];
|
||||
|
||||
|
||||
let page = 1;
|
||||
const perPage = 10;
|
||||
let hasMorePages = true;
|
||||
try {
|
||||
|
||||
if (teamId) {
|
||||
// case: fetch projects for group with id [teamId] in GitLab
|
||||
|
||||
while (hasMorePages) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(perPage)
|
||||
});
|
||||
if (teamId) {
|
||||
// case: fetch projects for group with id [teamId] in GitLab
|
||||
|
||||
const { data } = (
|
||||
await request.get(
|
||||
`${INTEGRATION_GITLAB_API_URL}/v4/groups/${teamId}/projects`,
|
||||
{
|
||||
params,
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
while (hasMorePages) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(perPage),
|
||||
});
|
||||
|
||||
data.map((a: any) => {
|
||||
apps.push({
|
||||
name: a.name,
|
||||
appId: a.id
|
||||
});
|
||||
});
|
||||
|
||||
if (data.length < perPage) {
|
||||
hasMorePages = false;
|
||||
const { data } = await request.get(
|
||||
`${INTEGRATION_GITLAB_API_URL}/v4/groups/${teamId}/projects`,
|
||||
{
|
||||
params,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
} else {
|
||||
// case: fetch projects for individual in GitLab
|
||||
|
||||
const { id } = (
|
||||
await request.get(
|
||||
`${INTEGRATION_GITLAB_API_URL}/v4/user`,
|
||||
{
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
).data;
|
||||
|
||||
while (hasMorePages) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(perPage)
|
||||
);
|
||||
|
||||
data.map((a: any) => {
|
||||
apps.push({
|
||||
name: a.name,
|
||||
appId: a.id,
|
||||
});
|
||||
});
|
||||
|
||||
const { data } = (
|
||||
await request.get(
|
||||
`${INTEGRATION_GITLAB_API_URL}/v4/users/${id}/projects`,
|
||||
{
|
||||
params,
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
data.map((a: any) => {
|
||||
apps.push({
|
||||
name: a.name,
|
||||
appId: a.id
|
||||
});
|
||||
});
|
||||
|
||||
if (data.length < perPage) {
|
||||
hasMorePages = false;
|
||||
}
|
||||
|
||||
page++;
|
||||
if (data.length < perPage) {
|
||||
hasMorePages = false;
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error("Failed to get GitLab projects");
|
||||
}
|
||||
|
||||
return apps;
|
||||
}
|
||||
} else {
|
||||
// case: fetch projects for individual in GitLab
|
||||
|
||||
const { id } = (
|
||||
await request.get(`${INTEGRATION_GITLAB_API_URL}/v4/user`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
})
|
||||
).data;
|
||||
|
||||
while (hasMorePages) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(perPage),
|
||||
});
|
||||
|
||||
const { data } = await request.get(
|
||||
`${INTEGRATION_GITLAB_API_URL}/v4/users/${id}/projects`,
|
||||
{
|
||||
params,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
data.map((a: any) => {
|
||||
apps.push({
|
||||
name: a.name,
|
||||
appId: a.id,
|
||||
});
|
||||
});
|
||||
|
||||
if (data.length < perPage) {
|
||||
hasMorePages = false;
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
}
|
||||
|
||||
return apps;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return list of projects for Supabase integration
|
||||
@@ -655,30 +581,23 @@ const getAppsGitlab = async ({
|
||||
* @returns {String} apps.name - name of Supabase app
|
||||
*/
|
||||
const getAppsSupabase = async ({ accessToken }: { accessToken: string }) => {
|
||||
let apps: any;
|
||||
try {
|
||||
const { data } = await request.get(
|
||||
`${INTEGRATION_SUPABASE_API_URL}/v1/projects`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Accept-Encoding': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
const { data } = await request.get(
|
||||
`${INTEGRATION_SUPABASE_API_URL}/v1/projects`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const apps = data.map((a: any) => {
|
||||
return {
|
||||
name: a.name,
|
||||
appId: a.id,
|
||||
};
|
||||
});
|
||||
|
||||
apps = data.map((a: any) => {
|
||||
return {
|
||||
name: a.name,
|
||||
appId: a.id
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get Supabase projects');
|
||||
}
|
||||
|
||||
return apps;
|
||||
};
|
||||
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import request from '../config/request';
|
||||
import request from "../config/request";
|
||||
import {
|
||||
INTEGRATION_AZURE_KEY_VAULT,
|
||||
INTEGRATION_HEROKU,
|
||||
@@ -12,8 +11,8 @@ import {
|
||||
INTEGRATION_VERCEL_TOKEN_URL,
|
||||
INTEGRATION_NETLIFY_TOKEN_URL,
|
||||
INTEGRATION_GITHUB_TOKEN_URL,
|
||||
INTEGRATION_GITLAB_TOKEN_URL
|
||||
} from '../variables';
|
||||
INTEGRATION_GITLAB_TOKEN_URL,
|
||||
} from "../variables";
|
||||
import {
|
||||
getSiteURL,
|
||||
getClientIdAzure,
|
||||
@@ -26,8 +25,8 @@ import {
|
||||
getClientIdGitHub,
|
||||
getClientSecretGitHub,
|
||||
getClientIdGitLab,
|
||||
getClientSecretGitLab
|
||||
} from '../config';
|
||||
getClientSecretGitLab,
|
||||
} from "../config";
|
||||
|
||||
interface ExchangeCodeAzureResponse {
|
||||
token_type: string;
|
||||
@@ -93,49 +92,43 @@ interface ExchangeCodeGitlabResponse {
|
||||
*/
|
||||
const exchangeCode = async ({
|
||||
integration,
|
||||
code
|
||||
code,
|
||||
}: {
|
||||
integration: string;
|
||||
code: string;
|
||||
}) => {
|
||||
let obj = {} as any;
|
||||
|
||||
try {
|
||||
switch (integration) {
|
||||
case INTEGRATION_AZURE_KEY_VAULT:
|
||||
obj = await exchangeCodeAzure({
|
||||
code
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_HEROKU:
|
||||
obj = await exchangeCodeHeroku({
|
||||
code
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_VERCEL:
|
||||
obj = await exchangeCodeVercel({
|
||||
code
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_NETLIFY:
|
||||
obj = await exchangeCodeNetlify({
|
||||
code
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_GITHUB:
|
||||
obj = await exchangeCodeGithub({
|
||||
code
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_GITLAB:
|
||||
obj = await exchangeCodeGitlab({
|
||||
code
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed OAuth2 code-token exchange');
|
||||
switch (integration) {
|
||||
case INTEGRATION_AZURE_KEY_VAULT:
|
||||
obj = await exchangeCodeAzure({
|
||||
code,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_HEROKU:
|
||||
obj = await exchangeCodeHeroku({
|
||||
code,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_VERCEL:
|
||||
obj = await exchangeCodeVercel({
|
||||
code,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_NETLIFY:
|
||||
obj = await exchangeCodeNetlify({
|
||||
code,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_GITHUB:
|
||||
obj = await exchangeCodeGithub({
|
||||
code,
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_GITLAB:
|
||||
obj = await exchangeCodeGitlab({
|
||||
code,
|
||||
});
|
||||
}
|
||||
|
||||
return obj;
|
||||
@@ -143,43 +136,33 @@ const exchangeCode = async ({
|
||||
|
||||
/**
|
||||
* Return [accessToken] for Azure OAuth2 code-token exchange
|
||||
* @param param0
|
||||
* @param param0
|
||||
*/
|
||||
const exchangeCodeAzure = async ({
|
||||
code
|
||||
}: {
|
||||
code: string;
|
||||
}) => {
|
||||
const exchangeCodeAzure = async ({ code }: { code: string }) => {
|
||||
const accessExpiresAt = new Date();
|
||||
let res: ExchangeCodeAzureResponse;
|
||||
try {
|
||||
res = (await request.post(
|
||||
|
||||
const res: ExchangeCodeAzureResponse = (
|
||||
await request.post(
|
||||
INTEGRATION_AZURE_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
scope: 'https://vault.azure.net/.default openid offline_access',
|
||||
scope: "https://vault.azure.net/.default openid offline_access",
|
||||
client_id: await getClientIdAzure(),
|
||||
client_secret: await getClientSecretAzure(),
|
||||
redirect_uri: `${await getSiteURL()}/integrations/azure-key-vault/oauth2/callback`
|
||||
redirect_uri: `${await getSiteURL()}/integrations/azure-key-vault/oauth2/callback`,
|
||||
} as any)
|
||||
)).data;
|
||||
)
|
||||
).data;
|
||||
|
||||
accessExpiresAt.setSeconds(
|
||||
accessExpiresAt.getSeconds() + res.expires_in
|
||||
);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed OAuth2 code-token exchange with Azure');
|
||||
}
|
||||
accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + res.expires_in);
|
||||
|
||||
return ({
|
||||
return {
|
||||
accessToken: res.access_token,
|
||||
refreshToken: res.refresh_token,
|
||||
accessExpiresAt
|
||||
});
|
||||
}
|
||||
accessExpiresAt,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Return [accessToken], [accessExpiresAt], and [refreshToken] for Heroku
|
||||
@@ -191,38 +174,28 @@ const exchangeCodeAzure = async ({
|
||||
* @returns {String} obj2.refreshToken - refresh token for Heroku API
|
||||
* @returns {Date} obj2.accessExpiresAt - date of expiration for access token
|
||||
*/
|
||||
const exchangeCodeHeroku = async ({
|
||||
code
|
||||
}: {
|
||||
code: string;
|
||||
}) => {
|
||||
let res: ExchangeCodeHerokuResponse;
|
||||
const exchangeCodeHeroku = async ({ code }: { code: string }) => {
|
||||
const accessExpiresAt = new Date();
|
||||
try {
|
||||
res = (await request.post(
|
||||
|
||||
const res: ExchangeCodeHerokuResponse = (
|
||||
await request.post(
|
||||
INTEGRATION_HEROKU_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
client_secret: await getClientSecretHeroku()
|
||||
client_secret: await getClientSecretHeroku(),
|
||||
} as any)
|
||||
)).data;
|
||||
)
|
||||
).data;
|
||||
|
||||
accessExpiresAt.setSeconds(
|
||||
accessExpiresAt.getSeconds() + res.expires_in
|
||||
);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed OAuth2 code-token exchange with Heroku');
|
||||
}
|
||||
accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + res.expires_in);
|
||||
|
||||
return ({
|
||||
return {
|
||||
accessToken: res.access_token,
|
||||
refreshToken: res.refresh_token,
|
||||
accessExpiresAt
|
||||
});
|
||||
}
|
||||
accessExpiresAt,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Return [accessToken], [accessExpiresAt], and [refreshToken] for Vercel
|
||||
@@ -235,30 +208,23 @@ const exchangeCodeHeroku = async ({
|
||||
* @returns {Date} obj2.accessExpiresAt - date of expiration for access token
|
||||
*/
|
||||
const exchangeCodeVercel = async ({ code }: { code: string }) => {
|
||||
let res: ExchangeCodeVercelResponse;
|
||||
try {
|
||||
res = (
|
||||
await request.post(
|
||||
INTEGRATION_VERCEL_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
code: code,
|
||||
client_id: await getClientIdVercel(),
|
||||
client_secret: await getClientSecretVercel(),
|
||||
redirect_uri: `${await getSiteURL()}/integrations/vercel/oauth2/callback`
|
||||
} as any)
|
||||
)
|
||||
).data;
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error(`Failed OAuth2 code-token exchange with Vercel [err=${err}]`);
|
||||
}
|
||||
const res: ExchangeCodeVercelResponse = (
|
||||
await request.post(
|
||||
INTEGRATION_VERCEL_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
code: code,
|
||||
client_id: await getClientIdVercel(),
|
||||
client_secret: await getClientSecretVercel(),
|
||||
redirect_uri: `${await getSiteURL()}/integrations/vercel/oauth2/callback`,
|
||||
} as any)
|
||||
)
|
||||
).data;
|
||||
|
||||
return {
|
||||
accessToken: res.access_token,
|
||||
refreshToken: null,
|
||||
accessExpiresAt: null,
|
||||
teamId: res.team_id
|
||||
teamId: res.team_id,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -273,47 +239,39 @@ const exchangeCodeVercel = async ({ code }: { code: string }) => {
|
||||
* @returns {Date} obj2.accessExpiresAt - date of expiration for access token
|
||||
*/
|
||||
const exchangeCodeNetlify = async ({ code }: { code: string }) => {
|
||||
let res: ExchangeCodeNetlifyResponse;
|
||||
let accountId;
|
||||
try {
|
||||
res = (
|
||||
await request.post(
|
||||
INTEGRATION_NETLIFY_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
client_id: await getClientIdNetlify(),
|
||||
client_secret: await getClientSecretNetlify(),
|
||||
redirect_uri: `${await getSiteURL()}/integrations/netlify/oauth2/callback`
|
||||
} as any)
|
||||
)
|
||||
).data;
|
||||
const res: ExchangeCodeNetlifyResponse = (
|
||||
await request.post(
|
||||
INTEGRATION_NETLIFY_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
client_id: await getClientIdNetlify(),
|
||||
client_secret: await getClientSecretNetlify(),
|
||||
redirect_uri: `${await getSiteURL()}/integrations/netlify/oauth2/callback`,
|
||||
} as any)
|
||||
)
|
||||
).data;
|
||||
|
||||
const res2 = await request.get('https://api.netlify.com/api/v1/sites', {
|
||||
const res2 = await request.get("https://api.netlify.com/api/v1/sites", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${res.access_token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const res3 = (
|
||||
await request.get("https://api.netlify.com/api/v1/accounts", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${res.access_token}`
|
||||
}
|
||||
});
|
||||
Authorization: `Bearer ${res.access_token}`,
|
||||
},
|
||||
})
|
||||
).data;
|
||||
|
||||
const res3 = (
|
||||
await request.get('https://api.netlify.com/api/v1/accounts', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${res.access_token}`
|
||||
}
|
||||
})
|
||||
).data;
|
||||
|
||||
accountId = res3[0].id;
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed OAuth2 code-token exchange with Netlify');
|
||||
}
|
||||
const accountId = res3[0].id;
|
||||
|
||||
return {
|
||||
accessToken: res.access_token,
|
||||
refreshToken: res.refresh_token,
|
||||
accountId
|
||||
accountId,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -328,33 +286,25 @@ const exchangeCodeNetlify = async ({ code }: { code: string }) => {
|
||||
* @returns {Date} obj2.accessExpiresAt - date of expiration for access token
|
||||
*/
|
||||
const exchangeCodeGithub = async ({ code }: { code: string }) => {
|
||||
let res: ExchangeCodeGithubResponse;
|
||||
try {
|
||||
res = (
|
||||
await request.get(INTEGRATION_GITHUB_TOKEN_URL, {
|
||||
params: {
|
||||
client_id: await getClientIdGitHub(),
|
||||
client_secret: await getClientSecretGitHub(),
|
||||
code: code,
|
||||
redirect_uri: `${await getSiteURL()}/integrations/github/oauth2/callback`
|
||||
},
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Accept-Encoding': 'application/json'
|
||||
}
|
||||
})
|
||||
).data;
|
||||
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed OAuth2 code-token exchange with Github');
|
||||
}
|
||||
const res: ExchangeCodeGithubResponse = (
|
||||
await request.get(INTEGRATION_GITHUB_TOKEN_URL, {
|
||||
params: {
|
||||
client_id: await getClientIdGitHub(),
|
||||
client_secret: await getClientSecretGitHub(),
|
||||
code: code,
|
||||
redirect_uri: `${await getSiteURL()}/integrations/github/oauth2/callback`,
|
||||
},
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
})
|
||||
).data;
|
||||
|
||||
return {
|
||||
accessToken: res.access_token,
|
||||
refreshToken: null,
|
||||
accessExpiresAt: null
|
||||
accessExpiresAt: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -369,42 +319,32 @@ const exchangeCodeGithub = async ({ code }: { code: string }) => {
|
||||
* @returns {Date} obj2.accessExpiresAt - date of expiration for access token
|
||||
*/
|
||||
const exchangeCodeGitlab = async ({ code }: { code: string }) => {
|
||||
let res: ExchangeCodeGitlabResponse;
|
||||
const accessExpiresAt = new Date();
|
||||
|
||||
try {
|
||||
res = (
|
||||
await request.post(
|
||||
INTEGRATION_GITLAB_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
client_id: await getClientIdGitLab(),
|
||||
client_secret: await getClientSecretGitLab(),
|
||||
redirect_uri: `${await getSiteURL()}/integrations/gitlab/oauth2/callback`
|
||||
} as any),
|
||||
{
|
||||
headers: {
|
||||
"Accept-Encoding": "application/json",
|
||||
}
|
||||
}
|
||||
)
|
||||
).data;
|
||||
|
||||
accessExpiresAt.setSeconds(
|
||||
accessExpiresAt.getSeconds() + res.expires_in
|
||||
);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed OAuth2 code-token exchange with Gitlab');
|
||||
}
|
||||
const res: ExchangeCodeGitlabResponse = (
|
||||
await request.post(
|
||||
INTEGRATION_GITLAB_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
client_id: await getClientIdGitLab(),
|
||||
client_secret: await getClientSecretGitLab(),
|
||||
redirect_uri: `${await getSiteURL()}/integrations/gitlab/oauth2/callback`,
|
||||
} as any),
|
||||
{
|
||||
headers: {
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
).data;
|
||||
|
||||
accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + res.expires_in);
|
||||
|
||||
return {
|
||||
accessToken: res.access_token,
|
||||
refreshToken: res.refresh_token,
|
||||
accessExpiresAt
|
||||
accessExpiresAt,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export { exchangeCode };
|
||||
|
@@ -1,29 +1,24 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import request from '../config/request';
|
||||
import request from "../config/request";
|
||||
import { IIntegrationAuth } from "../models";
|
||||
import {
|
||||
IIntegrationAuth
|
||||
} from '../models';
|
||||
import {
|
||||
INTEGRATION_AZURE_KEY_VAULT,
|
||||
INTEGRATION_AZURE_KEY_VAULT,
|
||||
INTEGRATION_HEROKU,
|
||||
INTEGRATION_GITLAB,
|
||||
} from '../variables';
|
||||
} from "../variables";
|
||||
import {
|
||||
INTEGRATION_AZURE_TOKEN_URL,
|
||||
INTEGRATION_HEROKU_TOKEN_URL,
|
||||
INTEGRATION_GITLAB_TOKEN_URL
|
||||
} from '../variables';
|
||||
import {
|
||||
IntegrationService
|
||||
} from '../services';
|
||||
INTEGRATION_GITLAB_TOKEN_URL,
|
||||
} from "../variables";
|
||||
import { IntegrationService } from "../services";
|
||||
import {
|
||||
getSiteURL,
|
||||
getClientIdAzure,
|
||||
getClientSecretAzure,
|
||||
getClientSecretHeroku,
|
||||
getClientIdGitLab,
|
||||
getClientSecretGitLab
|
||||
} from '../config';
|
||||
getClientSecretGitLab,
|
||||
} from "../config";
|
||||
|
||||
interface RefreshTokenAzureResponse {
|
||||
token_type: string;
|
||||
@@ -60,60 +55,57 @@ interface RefreshTokenGitLabResponse {
|
||||
*/
|
||||
const exchangeRefresh = async ({
|
||||
integrationAuth,
|
||||
refreshToken
|
||||
refreshToken,
|
||||
}: {
|
||||
integrationAuth: IIntegrationAuth;
|
||||
refreshToken: string;
|
||||
}) => {
|
||||
|
||||
interface TokenDetails {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessExpiresAt: Date;
|
||||
}
|
||||
|
||||
|
||||
let tokenDetails: TokenDetails;
|
||||
try {
|
||||
switch (integrationAuth.integration) {
|
||||
case INTEGRATION_AZURE_KEY_VAULT:
|
||||
tokenDetails = await exchangeRefreshAzure({
|
||||
refreshToken
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_HEROKU:
|
||||
tokenDetails = await exchangeRefreshHeroku({
|
||||
refreshToken
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_GITLAB:
|
||||
tokenDetails = await exchangeRefreshGitLab({
|
||||
refreshToken
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error('Failed to exchange token for incompatible integration');
|
||||
}
|
||||
|
||||
if (tokenDetails?.accessToken && tokenDetails?.refreshToken && tokenDetails?.accessExpiresAt) {
|
||||
await IntegrationService.setIntegrationAuthAccess({
|
||||
integrationAuthId: integrationAuth._id.toString(),
|
||||
accessId: null,
|
||||
accessToken: tokenDetails.accessToken,
|
||||
accessExpiresAt: tokenDetails.accessExpiresAt
|
||||
switch (integrationAuth.integration) {
|
||||
case INTEGRATION_AZURE_KEY_VAULT:
|
||||
tokenDetails = await exchangeRefreshAzure({
|
||||
refreshToken,
|
||||
});
|
||||
|
||||
await IntegrationService.setIntegrationAuthRefresh({
|
||||
integrationAuthId: integrationAuth._id.toString(),
|
||||
refreshToken: tokenDetails.refreshToken
|
||||
break;
|
||||
case INTEGRATION_HEROKU:
|
||||
tokenDetails = await exchangeRefreshHeroku({
|
||||
refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
return tokenDetails.accessToken;
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get new OAuth2 access token');
|
||||
break;
|
||||
case INTEGRATION_GITLAB:
|
||||
tokenDetails = await exchangeRefreshGitLab({
|
||||
refreshToken,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error("Failed to exchange token for incompatible integration");
|
||||
}
|
||||
|
||||
if (
|
||||
tokenDetails?.accessToken &&
|
||||
tokenDetails?.refreshToken &&
|
||||
tokenDetails?.accessExpiresAt
|
||||
) {
|
||||
await IntegrationService.setIntegrationAuthAccess({
|
||||
integrationAuthId: integrationAuth._id.toString(),
|
||||
accessId: null,
|
||||
accessToken: tokenDetails.accessToken,
|
||||
accessExpiresAt: tokenDetails.accessExpiresAt,
|
||||
});
|
||||
|
||||
await IntegrationService.setIntegrationAuthRefresh({
|
||||
integrationAuthId: integrationAuth._id.toString(),
|
||||
refreshToken: tokenDetails.refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
return tokenDetails.accessToken;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -124,38 +116,30 @@ const exchangeRefresh = async ({
|
||||
* @returns
|
||||
*/
|
||||
const exchangeRefreshAzure = async ({
|
||||
refreshToken
|
||||
refreshToken,
|
||||
}: {
|
||||
refreshToken: string;
|
||||
}) => {
|
||||
try {
|
||||
const accessExpiresAt = new Date();
|
||||
const { data }: { data: RefreshTokenAzureResponse } = await request.post(
|
||||
INTEGRATION_AZURE_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
client_id: await getClientIdAzure(),
|
||||
scope: 'openid offline_access',
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
client_secret: await getClientSecretAzure()
|
||||
} as any)
|
||||
);
|
||||
|
||||
accessExpiresAt.setSeconds(
|
||||
accessExpiresAt.getSeconds() + data.expires_in
|
||||
);
|
||||
const accessExpiresAt = new Date();
|
||||
const { data }: { data: RefreshTokenAzureResponse } = await request.post(
|
||||
INTEGRATION_AZURE_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
client_id: await getClientIdAzure(),
|
||||
scope: "openid offline_access",
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
client_secret: await getClientSecretAzure(),
|
||||
} as any)
|
||||
);
|
||||
|
||||
return ({
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
accessExpiresAt
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get refresh OAuth2 access token for Azure');
|
||||
}
|
||||
}
|
||||
accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in);
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
accessExpiresAt,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Return new access token by exchanging refresh token [refreshToken] for the
|
||||
@@ -165,39 +149,31 @@ const exchangeRefreshAzure = async ({
|
||||
* @returns
|
||||
*/
|
||||
const exchangeRefreshHeroku = async ({
|
||||
refreshToken
|
||||
refreshToken,
|
||||
}: {
|
||||
refreshToken: string;
|
||||
}) => {
|
||||
try {
|
||||
const accessExpiresAt = new Date();
|
||||
const {
|
||||
data
|
||||
}: {
|
||||
data: RefreshTokenHerokuResponse
|
||||
} = await request.post(
|
||||
INTEGRATION_HEROKU_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_secret: await getClientSecretHeroku()
|
||||
} as any)
|
||||
);
|
||||
const accessExpiresAt = new Date();
|
||||
const {
|
||||
data,
|
||||
}: {
|
||||
data: RefreshTokenHerokuResponse;
|
||||
} = await request.post(
|
||||
INTEGRATION_HEROKU_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_secret: await getClientSecretHeroku(),
|
||||
} as any)
|
||||
);
|
||||
|
||||
accessExpiresAt.setSeconds(
|
||||
accessExpiresAt.getSeconds() + data.expires_in
|
||||
);
|
||||
accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in);
|
||||
|
||||
return ({
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
accessExpiresAt
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to refresh OAuth2 access token for Heroku');
|
||||
}
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
accessExpiresAt,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -208,45 +184,38 @@ const exchangeRefreshHeroku = async ({
|
||||
* @returns
|
||||
*/
|
||||
const exchangeRefreshGitLab = async ({
|
||||
refreshToken
|
||||
refreshToken,
|
||||
}: {
|
||||
refreshToken: string;
|
||||
}) => {
|
||||
try {
|
||||
const accessExpiresAt = new Date();
|
||||
const {
|
||||
data
|
||||
}: {
|
||||
data: RefreshTokenGitLabResponse
|
||||
} = await request.post(
|
||||
INTEGRATION_GITLAB_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: await getClientIdGitLab,
|
||||
client_secret: await getClientSecretGitLab(),
|
||||
redirect_uri: `${await getSiteURL()}/integrations/gitlab/oauth2/callback`
|
||||
} as any),
|
||||
{
|
||||
headers: {
|
||||
"Accept-Encoding": "application/json",
|
||||
}
|
||||
});
|
||||
const accessExpiresAt = new Date();
|
||||
const {
|
||||
data,
|
||||
}: {
|
||||
data: RefreshTokenGitLabResponse;
|
||||
} = await request.post(
|
||||
INTEGRATION_GITLAB_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: await getClientIdGitLab(),
|
||||
client_secret: await getClientSecretGitLab(),
|
||||
redirect_uri: `${await getSiteURL()}/integrations/gitlab/oauth2/callback`,
|
||||
} as any),
|
||||
{
|
||||
headers: {
|
||||
"Accept-Encoding": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
accessExpiresAt.setSeconds(
|
||||
accessExpiresAt.getSeconds() + data.expires_in
|
||||
);
|
||||
accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in);
|
||||
|
||||
return ({
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
accessExpiresAt
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to refresh OAuth2 access token for GitLab');
|
||||
}
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
accessExpiresAt,
|
||||
};
|
||||
};
|
||||
|
||||
export { exchangeRefresh };
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import {
|
||||
IIntegrationAuth,
|
||||
IntegrationAuth,
|
||||
@@ -22,34 +21,28 @@ const revokeAccess = async ({
|
||||
accessToken: string;
|
||||
}) => {
|
||||
let deletedIntegrationAuth;
|
||||
try {
|
||||
// add any integration-specific revocation logic
|
||||
switch (integrationAuth.integration) {
|
||||
case INTEGRATION_HEROKU:
|
||||
break;
|
||||
case INTEGRATION_VERCEL:
|
||||
break;
|
||||
case INTEGRATION_NETLIFY:
|
||||
break;
|
||||
case INTEGRATION_GITHUB:
|
||||
break;
|
||||
case INTEGRATION_GITLAB:
|
||||
break;
|
||||
}
|
||||
// add any integration-specific revocation logic
|
||||
switch (integrationAuth.integration) {
|
||||
case INTEGRATION_HEROKU:
|
||||
break;
|
||||
case INTEGRATION_VERCEL:
|
||||
break;
|
||||
case INTEGRATION_NETLIFY:
|
||||
break;
|
||||
case INTEGRATION_GITHUB:
|
||||
break;
|
||||
case INTEGRATION_GITLAB:
|
||||
break;
|
||||
}
|
||||
|
||||
deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({
|
||||
_id: integrationAuth._id
|
||||
deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({
|
||||
_id: integrationAuth._id
|
||||
});
|
||||
|
||||
if (deletedIntegrationAuth) {
|
||||
await Integration.deleteMany({
|
||||
integrationAuth: deletedIntegrationAuth._id
|
||||
});
|
||||
|
||||
if (deletedIntegrationAuth) {
|
||||
await Integration.deleteMany({
|
||||
integrationAuth: deletedIntegrationAuth._id
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to delete integration authorization');
|
||||
}
|
||||
|
||||
return deletedIntegrationAuth;
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import * as Sentry from "@sentry/node";
|
||||
import {
|
||||
IIntegrationAuth
|
||||
} from '../models';
|
||||
@@ -31,21 +30,15 @@ const getTeams = async ({
|
||||
}) => {
|
||||
|
||||
let teams: Team[] = [];
|
||||
try {
|
||||
switch (integrationAuth.integration) {
|
||||
case INTEGRATION_GITLAB:
|
||||
teams = await getTeamsGitLab({
|
||||
accessToken
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get integration teams');
|
||||
|
||||
switch (integrationAuth.integration) {
|
||||
case INTEGRATION_GITLAB:
|
||||
teams = await getTeamsGitLab({
|
||||
accessToken
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
@@ -63,30 +56,24 @@ const getTeamsGitLab = async ({
|
||||
accessToken: string;
|
||||
}) => {
|
||||
let teams: Team[] = [];
|
||||
try {
|
||||
const res = (await request.get(
|
||||
`${INTEGRATION_GITLAB_API_URL}/v4/groups`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Accept-Encoding": "application/json"
|
||||
}
|
||||
const res = (await request.get(
|
||||
`${INTEGRATION_GITLAB_API_URL}/v4/groups`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Accept-Encoding": "application/json"
|
||||
}
|
||||
)).data;
|
||||
|
||||
teams = res.map((t: any) => ({
|
||||
name: t.name,
|
||||
teamId: t.id
|
||||
}));
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error("Failed to get GitLab integration teams");
|
||||
}
|
||||
}
|
||||
)).data;
|
||||
|
||||
teams = res.map((t: any) => ({
|
||||
name: t.name,
|
||||
teamId: t.id
|
||||
}));
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
export {
|
||||
getTeams
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Types } from 'mongoose';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { IntegrationAuth, IWorkspace } from '../models';
|
||||
|
@@ -3,7 +3,8 @@ import {
|
||||
SMTP_HOST_SENDGRID,
|
||||
SMTP_HOST_MAILGUN,
|
||||
SMTP_HOST_SOCKETLABS,
|
||||
SMTP_HOST_ZOHOMAIL
|
||||
SMTP_HOST_ZOHOMAIL,
|
||||
SMTP_HOST_GMAIL
|
||||
} from '../variables';
|
||||
import SMTPConnection from 'nodemailer/lib/smtp-connection';
|
||||
import * as Sentry from '@sentry/node';
|
||||
@@ -46,6 +47,12 @@ export const initSmtp = async () => {
|
||||
}
|
||||
break;
|
||||
case SMTP_HOST_ZOHOMAIL:
|
||||
mailOpts.requireTLS = true;
|
||||
mailOpts.tls = {
|
||||
ciphers: 'TLSv1.2'
|
||||
}
|
||||
break;
|
||||
case SMTP_HOST_GMAIL:
|
||||
mailOpts.requireTLS = true;
|
||||
mailOpts.tls = {
|
||||
ciphers: 'TLSv1.2'
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import nacl from 'tweetnacl';
|
||||
import util from 'tweetnacl-util';
|
||||
import AesGCM from './aes-gcm';
|
||||
import * as Sentry from '@sentry/node';
|
||||
|
||||
/**
|
||||
* Return new base64, NaCl, public-private key pair.
|
||||
@@ -38,20 +37,13 @@ const encryptAsymmetric = ({
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
}) => {
|
||||
let nonce, ciphertext;
|
||||
try {
|
||||
nonce = nacl.randomBytes(24);
|
||||
ciphertext = nacl.box(
|
||||
util.decodeUTF8(plaintext),
|
||||
nonce,
|
||||
util.decodeBase64(publicKey),
|
||||
util.decodeBase64(privateKey)
|
||||
);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to perform asymmetric encryption');
|
||||
}
|
||||
const nonce = nacl.randomBytes(24);
|
||||
const ciphertext = nacl.box(
|
||||
util.decodeUTF8(plaintext),
|
||||
nonce,
|
||||
util.decodeBase64(publicKey),
|
||||
util.decodeBase64(privateKey)
|
||||
);
|
||||
|
||||
return {
|
||||
ciphertext: util.encodeBase64(ciphertext),
|
||||
@@ -80,19 +72,12 @@ const decryptAsymmetric = ({
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
}): string => {
|
||||
let plaintext: any;
|
||||
try {
|
||||
plaintext = nacl.box.open(
|
||||
util.decodeBase64(ciphertext),
|
||||
util.decodeBase64(nonce),
|
||||
util.decodeBase64(publicKey),
|
||||
util.decodeBase64(privateKey)
|
||||
);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to perform asymmetric decryption');
|
||||
}
|
||||
const plaintext: any = nacl.box.open(
|
||||
util.decodeBase64(ciphertext),
|
||||
util.decodeBase64(nonce),
|
||||
util.decodeBase64(publicKey),
|
||||
util.decodeBase64(privateKey)
|
||||
);
|
||||
|
||||
return util.encodeUTF8(plaintext);
|
||||
};
|
||||
@@ -110,17 +95,8 @@ const encryptSymmetric = ({
|
||||
plaintext: string;
|
||||
key: string;
|
||||
}) => {
|
||||
let ciphertext, iv, tag;
|
||||
try {
|
||||
const obj = AesGCM.encrypt(plaintext, key);
|
||||
ciphertext = obj.ciphertext;
|
||||
iv = obj.iv;
|
||||
tag = obj.tag;
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to perform symmetric encryption');
|
||||
}
|
||||
const obj = AesGCM.encrypt(plaintext, key);
|
||||
const { ciphertext, iv, tag } = obj;
|
||||
|
||||
return {
|
||||
ciphertext,
|
||||
@@ -150,15 +126,7 @@ const decryptSymmetric = ({
|
||||
tag: string;
|
||||
key: string;
|
||||
}): string => {
|
||||
let plaintext;
|
||||
try {
|
||||
plaintext = AesGCM.decrypt(ciphertext, iv, tag, key);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to perform symmetric decryption');
|
||||
}
|
||||
|
||||
const plaintext = AesGCM.decrypt(ciphertext, iv, tag, key);
|
||||
return plaintext;
|
||||
};
|
||||
|
||||
|
@@ -55,7 +55,8 @@ import {
|
||||
SMTP_HOST_SENDGRID,
|
||||
SMTP_HOST_MAILGUN,
|
||||
SMTP_HOST_SOCKETLABS,
|
||||
SMTP_HOST_ZOHOMAIL
|
||||
SMTP_HOST_ZOHOMAIL,
|
||||
SMTP_HOST_GMAIL
|
||||
} from './smtp';
|
||||
import { PLAN_STARTER, PLAN_PRO } from './stripe';
|
||||
import {
|
||||
@@ -138,6 +139,7 @@ export {
|
||||
SMTP_HOST_MAILGUN,
|
||||
SMTP_HOST_SOCKETLABS,
|
||||
SMTP_HOST_ZOHOMAIL,
|
||||
SMTP_HOST_GMAIL,
|
||||
PLAN_STARTER,
|
||||
PLAN_PRO,
|
||||
MFA_METHOD_EMAIL,
|
||||
|
@@ -2,10 +2,12 @@ const SMTP_HOST_SENDGRID = 'smtp.sendgrid.net';
|
||||
const SMTP_HOST_MAILGUN = 'smtp.mailgun.org';
|
||||
const SMTP_HOST_SOCKETLABS = 'smtp.socketlabs.com';
|
||||
const SMTP_HOST_ZOHOMAIL = 'smtp.zoho.com';
|
||||
const SMTP_HOST_GMAIL = 'smtp.gmail.com';
|
||||
|
||||
export {
|
||||
SMTP_HOST_SENDGRID,
|
||||
SMTP_HOST_MAILGUN,
|
||||
SMTP_HOST_SOCKETLABS,
|
||||
SMTP_HOST_ZOHOMAIL
|
||||
SMTP_HOST_ZOHOMAIL,
|
||||
SMTP_HOST_GMAIL
|
||||
}
|
@@ -28,14 +28,14 @@ describe('Crypto', () => {
|
||||
test('should throw error if publicKey is undefined', () => {
|
||||
expect(() => {
|
||||
encryptAsymmetric({ plaintext, publicKey, privateKey });
|
||||
}).toThrowError('Failed to perform asymmetric encryption');
|
||||
}).toThrowError('invalid encoding');
|
||||
});
|
||||
|
||||
test('should throw error if publicKey is empty string', () => {
|
||||
publicKey = '';
|
||||
expect(() => {
|
||||
encryptAsymmetric({ plaintext, publicKey, privateKey });
|
||||
}).toThrowError('Failed to perform asymmetric encryption');
|
||||
}).toThrowError('bad public key size');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,14 +47,14 @@ describe('Crypto', () => {
|
||||
test('should throw error if privateKey is undefined', () => {
|
||||
expect(() => {
|
||||
encryptAsymmetric({ plaintext, publicKey, privateKey });
|
||||
}).toThrowError('Failed to perform asymmetric encryption');
|
||||
}).toThrowError('invalid encoding');
|
||||
});
|
||||
|
||||
test('should throw error if privateKey is empty string', () => {
|
||||
privateKey = '';
|
||||
expect(() => {
|
||||
encryptAsymmetric({ plaintext, publicKey, privateKey });
|
||||
}).toThrowError('Failed to perform asymmetric encryption');
|
||||
}).toThrowError('bad secret key size');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('Crypto', () => {
|
||||
test('should throw error if plaintext is undefined', () => {
|
||||
expect(() => {
|
||||
encryptAsymmetric({ plaintext, publicKey, privateKey });
|
||||
}).toThrowError('Failed to perform asymmetric encryption');
|
||||
}).toThrowError('expected string');
|
||||
});
|
||||
|
||||
test('should encrypt plaintext containing special characters', () => {
|
||||
@@ -130,7 +130,7 @@ describe('Crypto', () => {
|
||||
publicKey,
|
||||
privateKey
|
||||
});
|
||||
}).toThrowError('Failed to perform asymmetric decryption');
|
||||
}).toThrowError('invalid encoding');
|
||||
});
|
||||
|
||||
test('should throw error if nonce is modified', () => {
|
||||
@@ -149,7 +149,7 @@ describe('Crypto', () => {
|
||||
publicKey,
|
||||
privateKey
|
||||
});
|
||||
}).toThrowError('Failed to perform asymmetric decryption');
|
||||
}).toThrowError('invalid encoding');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -170,7 +170,7 @@ describe('Crypto', () => {
|
||||
const invalidKey = 'invalid-key';
|
||||
expect(() => {
|
||||
encryptSymmetric({ plaintext, key: invalidKey });
|
||||
}).toThrowError('Failed to perform symmetric encryption');
|
||||
}).toThrowError('Invalid key length');
|
||||
});
|
||||
|
||||
test('should throw an error when invalid key is provided', () => {
|
||||
@@ -179,7 +179,7 @@ describe('Crypto', () => {
|
||||
|
||||
expect(() => {
|
||||
encryptSymmetric({ plaintext, key: invalidKey });
|
||||
}).toThrowError('Failed to perform symmetric encryption');
|
||||
}).toThrowError('Invalid key length');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('Crypto', () => {
|
||||
tag,
|
||||
key
|
||||
});
|
||||
}).toThrowError('Failed to perform symmetric decryption');
|
||||
}).toThrowError('Unsupported state or unable to authenticate data');
|
||||
});
|
||||
|
||||
test('should fail if iv is modified', () => {
|
||||
@@ -221,7 +221,7 @@ describe('Crypto', () => {
|
||||
tag,
|
||||
key
|
||||
});
|
||||
}).toThrowError('Failed to perform symmetric decryption');
|
||||
}).toThrowError('Unsupported state or unable to authenticate data');
|
||||
});
|
||||
|
||||
test('should fail if tag is modified', () => {
|
||||
@@ -233,7 +233,7 @@ describe('Crypto', () => {
|
||||
tag: modifiedTag,
|
||||
key
|
||||
});
|
||||
}).toThrowError('Failed to perform symmetric decryption');
|
||||
}).toThrowError(/Invalid authentication tag length: \d+/);
|
||||
});
|
||||
|
||||
test('should throw an error when decryption fails', () => {
|
||||
@@ -245,7 +245,7 @@ describe('Crypto', () => {
|
||||
tag,
|
||||
key: invalidKey
|
||||
});
|
||||
}).toThrowError('Failed to perform symmetric decryption');
|
||||
}).toThrowError('Invalid key length');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
BIN
docs/images/email-gmail-app-access.png
Normal file
BIN
docs/images/email-gmail-app-access.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 MiB |
@@ -114,9 +114,21 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Deploy Infisical",
|
||||
"group": "Self-host Infisical",
|
||||
"pages": [
|
||||
"self-hosting/overview",
|
||||
{
|
||||
"group": "Deployment options",
|
||||
"pages": [
|
||||
"self-hosting/overview",
|
||||
"self-hosting/deployment-options/kubernetes-helm",
|
||||
"self-hosting/deployment-options/aws-ec2",
|
||||
"self-hosting/deployment-options/docker-compose",
|
||||
"self-hosting/deployment-options/fly.io",
|
||||
"self-hosting/deployment-options/render",
|
||||
"self-hosting/deployment-options/standalone-infisical",
|
||||
"self-hosting/deployment-options/digital-ocean-marketplace"
|
||||
]
|
||||
},
|
||||
"self-hosting/configuration/envars",
|
||||
"self-hosting/configuration/email",
|
||||
"self-hosting/faq"
|
||||
|
@@ -23,7 +23,7 @@ By default, you need to configure the following SMTP [environment variables](htt
|
||||
- `SMTP_FROM_ADDRESS`: Email address to be used for sending emails (e.g. team@infisical.com).
|
||||
- `SMTP_FROM_NAME`: Name label to be used in `From` field (e.g. Team).
|
||||
|
||||
Below you will find details on how to configure common email providers (not in any particular order).
|
||||
Below you will find details on how to configure common email providers:
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Twilio SendGrid">
|
||||
@@ -135,6 +135,34 @@ SMTP_FROM_NAME=Infisical
|
||||
</Info>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Gmail">
|
||||
|
||||
Create an account and enable "less secure app access" in Gmail Account Settings > Security. This will allow
|
||||
applications like Infisical to authenticate with Gmail via your username and password.
|
||||
|
||||

|
||||
|
||||
With your Gmail username and password, you can set your SMTP environment variables:
|
||||
|
||||
```
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_USERNAME=hey@gmail.com # your email
|
||||
SMTP_PASSWORD=password # your password
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=true
|
||||
SMTP_FROM_ADDRESS=hey@gmail.com
|
||||
SMTP_FROM_NAME=Infisical
|
||||
```
|
||||
|
||||
<Warning>
|
||||
As per the [notice](https://support.google.com/accounts/answer/6010255?hl=en) by Google, you should note that using Gmail credentials for SMTP configuration
|
||||
will only work for Google Workspace or Google Cloud Identity customers as of May 30, 2022.
|
||||
|
||||
Put differently, the SMTP configuration is only possible with business (not personal) Gmail credentials.
|
||||
</Warning>
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Zoho Mail">
|
||||
|
||||
1. Create an account and configure [Zoho Mail](https://www.zoho.com/mail/) to send emails.
|
||||
|
17
docs/self-hosting/deployment-options/aws-ec2.mdx
Normal file
17
docs/self-hosting/deployment-options/aws-ec2.mdx
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "AWS EC2"
|
||||
description: "Learn to install Infisical on EC2 using Cloud Formation template"
|
||||
---
|
||||
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/jR-gM7vIY2c" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
|
||||
This deployment option will use AWS Cloudformation to auto deploy an instance of Infisical on a single EC2 via Docker Compose.
|
||||
|
||||
**Resources that will be provisioned**
|
||||
- 1 EC2 instance
|
||||
- 1 DocumentDB cluster
|
||||
- 1 DocumentDB instance
|
||||
- Security groups
|
||||
|
||||
<a href="https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/create/review?templateURL=https://ec2-instance-cloudformation.s3.amazonaws.com/infisical-ec2-deployment.template&stackName=infisical">
|
||||
<img width="200" src="../../images/deploy-aws-button.png" />
|
||||
</a>
|
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: "Digital Ocean"
|
||||
description: "Learn to install Infisical on Digital Ocean"
|
||||
---
|
||||
|
||||
Documentation coming soon
|
42
docs/self-hosting/deployment-options/docker-compose.mdx
Normal file
42
docs/self-hosting/deployment-options/docker-compose.mdx
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: "Docker Compose"
|
||||
description: "Learn to install Infisical using our Docker Compose template"
|
||||
---
|
||||
|
||||
1. Install Docker on your VM
|
||||
|
||||
```bash
|
||||
# Example in ubuntu
|
||||
apt-get update
|
||||
apt-get upgrade
|
||||
apt install docker-compose
|
||||
```
|
||||
|
||||
2. Download the required files
|
||||
|
||||
```bash
|
||||
# Download env file template
|
||||
wget -O .env https://raw.githubusercontent.com/Infisical/infisical/main/.env.example
|
||||
|
||||
# Download docker compose template
|
||||
wget -O docker-compose.yml https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.yml
|
||||
|
||||
# Download nginx config
|
||||
mkdir nginx && wget -O ./nginx/default.conf https://raw.githubusercontent.com/Infisical/infisical/main/nginx/default.dev.conf
|
||||
```
|
||||
|
||||
3. Tweak the `.env` according to your preferences. Refer to the available [environment variables](/self-hosting/configuration/envars)
|
||||
|
||||
```bash
|
||||
# update environment variables like mongo login
|
||||
nano .env
|
||||
```
|
||||
|
||||
4. Get the service up and running.
|
||||
|
||||
```bash
|
||||
# Start up services in detached mode
|
||||
docker-compose -f docker-compose.yml up -d
|
||||
```
|
||||
|
||||
5. Your Infisical installation is complete and should be running on [http://localhost:80](http://localhost:80). Please note that the containers are not exposed to the internet and only bind to the localhost. It's up to you to configure a firewall, SSL certificates, and implement any additional security measures.
|
60
docs/self-hosting/deployment-options/fly.io.mdx
Normal file
60
docs/self-hosting/deployment-options/fly.io.mdx
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: "Fly.io"
|
||||
description: "Learn to install Infisical on Fly.io"
|
||||
---
|
||||
|
||||
**Prerequisites**
|
||||
- Familiar with Fly.io deployment
|
||||
- Logged in via fly CLI
|
||||
|
||||
#### 1. Make a copy of the deployment config
|
||||
To begin, you'll to make a copy of the following file on your local machine
|
||||
|
||||
```toml fly.toml
|
||||
# fly.toml app configuration file generated for infisical on 2023-05-05T08:57:03-04:00
|
||||
#
|
||||
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
||||
#
|
||||
|
||||
app = "infisical"
|
||||
primary_region = "iad"
|
||||
|
||||
[build]
|
||||
image = "infisical/infisical:latest"
|
||||
|
||||
[env]
|
||||
ENCRYPTION_KEY = <>
|
||||
JWT_AUTH_SECRET = <>
|
||||
JWT_REFRESH_SECRET = <>
|
||||
JWT_SERVICE_SECRET = <>
|
||||
JWT_SIGNUP_SECRET = <>
|
||||
MONGO_URL = <>
|
||||
|
||||
[http_service]
|
||||
internal_port = 80
|
||||
|
||||
```
|
||||
|
||||
#### 2. Add environment variables
|
||||
|
||||
Before we can deploy Infisical, we'll need to provide values for the keys under `[env]` config block. For each of the following keys
|
||||
|
||||
- `ENCRYPTION_KEY`
|
||||
- `JWT_AUTH_SECRET`
|
||||
- `JWT_REFRESH_SECRET`
|
||||
- `JWT_SERVICE_SECRET`
|
||||
- `JWT_SIGNUP_SECRET`
|
||||
|
||||
you will need to generate a random 16 byte hex string. This can can be generated with `openssl rand -hex 16`.
|
||||
|
||||
|
||||
Lastly, the `MONGO_URL` environment variable requires a document database connection URL.
|
||||
You can obtain this URL by creating a document database using services such as [MongoDB](https://www.mongodb.com/), [AWS DocumentDB](https://aws.amazon.com/documentdb/), and others.
|
||||
|
||||
#### 3. Deploy
|
||||
|
||||
Run `fly launch` in the directory where you have the local version of config from step 1 and follow the instructions.
|
||||
Once done, your very own instance of Infisical should be up and running on Fly.io.
|
||||
|
||||
Please note that this version of Infisical requires at least 250MB of memory to operate smoothly.
|
||||
|
35
docs/self-hosting/deployment-options/kubernetes-helm.mdx
Normal file
35
docs/self-hosting/deployment-options/kubernetes-helm.mdx
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: "Kubernetes via Helm Chart"
|
||||
description: "Use our Helm chart to Install Infisical on your Kubernetes cluster"
|
||||
---
|
||||
<iframe width="100%" height="375" src="https://www.youtube.com/embed/ugJZSCcZaV8" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
|
||||
|
||||
**Prerequisites**
|
||||
- You have understanding of [Kubernetes](https://kubernetes.io/)
|
||||
- Installed [Helm package manager](https://helm.sh/) version v3.11.3 or greater
|
||||
- You have [kubectl](https://kubernetes.io/docs/reference/kubectl/kubectl/) installed and connected to your kubernetes cluster
|
||||
|
||||
|
||||
#### 1. Install Infisical Helm repository
|
||||
|
||||
```bash
|
||||
helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/'
|
||||
|
||||
helm repo update
|
||||
```
|
||||
|
||||
#### 2. Install the Helm chart
|
||||
|
||||
By default, the helm chart will be installed on your default namespace. If you wish to install the Chart on a different namespace, you may specify
|
||||
that by adding the `--namespace <namespace-to-install-to>` to your `helm install` command.
|
||||
|
||||
```bash
|
||||
## Installs to default namespace
|
||||
helm install infisical-helm-charts/infisical --generate-name
|
||||
```
|
||||
|
||||
#### 3. Access Infisical
|
||||
Allow 3-5 minutes for the deployment to complete. Once done, you should now be able to access Infisical on the IP address exposed via Ingress on your load balancer. If you are not sure what the IP address is run `kubectl get ingress` to view the external IP address exposing Infisical.
|
||||
|
||||
#### Custom configuration
|
||||
To configure environment variables, database and deployments, you'll need to set the parameters in a `values.yaml` file. To view all available parameters [visit here](https://github.com/Infisical/infisical/tree/main/helm-charts/infisical#parameters)
|
15
docs/self-hosting/deployment-options/render.mdx
Normal file
15
docs/self-hosting/deployment-options/render.mdx
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
title: "Render.com"
|
||||
description: "Learn to install Infisical Render.com"
|
||||
---
|
||||
|
||||
**Prerequisites**
|
||||
- An account at Render.com
|
||||
- A document DB instance
|
||||
|
||||
Deploying on Render is one of the quickest ways to have Infisical running in production.
|
||||
Before you start deployment, you will need to obtain document db connection string. This will be used for `MONGO_URL` environment variable required during installation.
|
||||
|
||||
You can create a document db database using services such as [MongoDB](https://www.mongodb.com/), [AWS DocumentDB](https://aws.amazon.com/documentdb/), and others. Once done, click the link below to start deployment.
|
||||
|
||||
### **[Deploy to Render](https://render.com/deploy?repo=https://github.com/Infisical/infisical)**
|
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: "Docker"
|
||||
description: "Learn to install Infisical purely on docker"
|
||||
---
|
||||
|
||||
Documentation coming soon
|
@@ -1,142 +1,32 @@
|
||||
---
|
||||
title: "Deployment options"
|
||||
title: "Introduction"
|
||||
description: "Explore deployment options for self hosting Infisical"
|
||||
---
|
||||
|
||||
To meet various compliance requirements, you may want to self-host Infisical instead of using [Infisical Cloud](https://app.infisical.com/).
|
||||
Self-hosted Infisical allows you to maintain your sensitive information within your own infrastructure and network, ensuring complete control over your data.
|
||||
Self-hosted Infisical allows you to maintain your sensitive information within your own infrastructure and network, ensuring complete control over your data.
|
||||
Choose from a variety of deployment options listed below to get started.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Local machine">
|
||||
## Setup and launch Infisical
|
||||
|
||||
Make sure you have Git and Docker installed and follow the instructions for your system.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Linux/macOS">
|
||||
Run the following command to clone, prepare, and start up Infisical:
|
||||
|
||||
```console
|
||||
git clone https://github.com/Infisical/infisical && cd "$(basename $_ .git)" && cp .env.example .env && docker-compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Windows Command Prompt">
|
||||
Run the following command to clone, prepare, and start up Infisical:
|
||||
|
||||
```console
|
||||
git clone https://github.com/Infisical/infisical && cd infisical && copy .env.example .env && docker-compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Other">
|
||||
Clone the repository back to your machine.
|
||||
|
||||
```console
|
||||
git clone https://github.com/Infisical/infisical
|
||||
```
|
||||
|
||||
Navigate into the root of the repository create a new `.env` file containing the contents of the [.env.example file](https://github.com/Infisical/infisical/blob/main/.env.example).
|
||||
|
||||
Finally run `docker-compose up` to start up Infisical.
|
||||
|
||||
```console
|
||||
docker-compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
Login to the web app at `http://localhost:8080` by entering the test user email `test@localhost.local` and password `testInfisical1`.
|
||||
</Tab>
|
||||
<Tab title="Quick deploy AWS">
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/jR-gM7vIY2c" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
|
||||
This deployment option will use AWS Cloudformation to auto deploy an instance of Infisical on a single EC2 via Docker Compose.
|
||||
|
||||
**Resources that will be provisioned**
|
||||
- 1 EC2 instance
|
||||
- 1 DocumentDB cluster
|
||||
- 1 DocumentDB instance
|
||||
- Security groups
|
||||
|
||||
<a href="https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/create/review?templateURL=https://ec2-instance-cloudformation.s3.amazonaws.com/infisical-ec2-deployment.template&stackName=infisical">
|
||||
<img width="200" src="../images/deploy-aws-button.png" />
|
||||
</a>
|
||||
|
||||
</Tab>
|
||||
<Tab title="Quick deploy Digital Ocean">
|
||||
<Note>This deployment option is highly available</Note>
|
||||
Coming soon
|
||||
</Tab>
|
||||
<Tab title="Helm Kubernetes">
|
||||
<Note>This deployment option is highly available</Note>
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/ugJZSCcZaV8" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
|
||||
|
||||
**Prerequisites**
|
||||
- You have understanding of [Kubernetes](https://kubernetes.io/)
|
||||
- You have understanding of [Helm package manager](https://helm.sh/)
|
||||
- You have [kubectl](https://kubernetes.io/docs/reference/kubectl/kubectl/) installed and connected to your kubernetes cluster
|
||||
|
||||
|
||||
#### 1. Install Infisical Helm repository
|
||||
|
||||
```bash
|
||||
helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/'
|
||||
|
||||
helm repo update
|
||||
```
|
||||
|
||||
#### 2. Install the Helm chart
|
||||
|
||||
By default, the helm chart will be installed on your default namespace. If you wish to install the Chart on a different namespace, you may specify
|
||||
that by adding the `--namespace <namespace-to-install-to>` to your `helm install` command.
|
||||
|
||||
```bash
|
||||
## Installs to default namespace
|
||||
helm install infisical-helm-charts/infisical --generate-name
|
||||
```
|
||||
|
||||
#### 3. Access Infisical
|
||||
Allow 3-5 minutes for the deployment to complete. Once done, you should now be able to access Infisical on the IP address exposed via Ingress on your load balancer. If you are not sure what the IP address is run `kubectl get ingress` to view the external IP address exposing Infisical.
|
||||
|
||||
#### Custom configuration
|
||||
To configure environment variables, database and deployments, you'll need to set the parameters in a `values.yaml` file. To view all available parameters [visit here](https://github.com/Infisical/infisical/tree/main/helm-charts/infisical#parameters)
|
||||
|
||||
</Tab>
|
||||
<Tab title="Bare Docker Compose">
|
||||
1. Install Docker on your VM
|
||||
|
||||
```bash
|
||||
# Example in ubuntu
|
||||
apt-get update
|
||||
apt-get upgrade
|
||||
apt install docker-compose
|
||||
```
|
||||
|
||||
2. Download the required files
|
||||
|
||||
```bash
|
||||
# Download env file template
|
||||
wget -O .env https://raw.githubusercontent.com/Infisical/infisical/main/.env.example
|
||||
|
||||
# Download docker compose template
|
||||
wget -O docker-compose.yml https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.yml
|
||||
|
||||
# Download nginx config
|
||||
mkdir nginx && wget -O ./nginx/default.conf https://raw.githubusercontent.com/Infisical/infisical/main/nginx/default.dev.conf
|
||||
```
|
||||
|
||||
3. Tweak the `.env` according to your preferences. Refer to the available [environment variables](/self-hosting/configuration/envars)
|
||||
|
||||
```bash
|
||||
# update environment variables like mongo login
|
||||
nano .env
|
||||
```
|
||||
|
||||
4. Get the service up and running.
|
||||
|
||||
```bash
|
||||
# Start up services in detached mode
|
||||
docker-compose -f docker-compose.yml up -d
|
||||
```
|
||||
|
||||
5. Your Infisical installation is complete and should be running on [http://localhost:80](http://localhost:80). Please note that the containers are not exposed to the internet and only bind to the localhost. It's up to you to configure a firewall, SSL certificates, and implement any additional security measures.
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Card title="Kubernetes" color="#ea5a0c" href="deployment-options/kubernetes-helm">
|
||||
Use our Helm chart to Install Infisical on your Kubernetes cluster
|
||||
</Card>
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Fly.io" color="#dc2626" href="deployment-options/fly.io">
|
||||
Use our standalone docker image to deploy on Fly.io
|
||||
</Card>
|
||||
<Card title="Render.com" color="#dc2626" href="deployment-options/render">
|
||||
Install on Render using our standalone docker image
|
||||
</Card>
|
||||
<Card title="AWS EC2" color="#0285c7" href="deployment-options/aws-ec2">
|
||||
Install infisical with just a few clicks using our Cloud Formation template
|
||||
</Card>
|
||||
<Card title="Digital Ocean marketplace" color="#16a34a" href="deployment-options/digital-ocean-marketplace">
|
||||
Use our one click installer to deploy Infisical
|
||||
</Card>
|
||||
<Card title="Docker Compose" color="#0285c7" href="deployment-options/docker-compose">
|
||||
Install Infisical using our Docker Compose template
|
||||
</Card>
|
||||
<Card title="Docker" color="#0285c7" href="deployment-options/standalone-infisical">
|
||||
Use the fully packaged, single docker image Infisical to deploy anywhere
|
||||
</Card>
|
||||
</CardGroup>
|
32
ecosystem.config.js
Normal file
32
ecosystem.config.js
Normal file
@@ -0,0 +1,32 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'frontend',
|
||||
script: "./scripts/start.sh",
|
||||
instances: 1,
|
||||
cwd: "./app",
|
||||
interpreter: 'sh',
|
||||
exec_mode: "fork",
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '500M',
|
||||
},
|
||||
{
|
||||
name: 'backend',
|
||||
script: 'npm',
|
||||
args: 'run start',
|
||||
cwd: "./backend",
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '500M',
|
||||
},
|
||||
{
|
||||
name: "nginx",
|
||||
script: "nginx",
|
||||
args: "-g 'daemon off;'",
|
||||
exec_interpreter: "none",
|
||||
},
|
||||
],
|
||||
};
|
@@ -1,3 +1,4 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { faAngleRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
@@ -21,6 +22,7 @@ import { Select, SelectItem, Tooltip } from '../v2';
|
||||
* @param {string} obj.onEnvChange - the action that happens when an env is changed
|
||||
* @returns
|
||||
*/
|
||||
// TODO(akhilmhdh): simply this header and nav system later
|
||||
export default function NavHeader({
|
||||
pageName,
|
||||
isProjectRelated,
|
||||
@@ -38,10 +40,10 @@ export default function NavHeader({
|
||||
}): JSX.Element {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { currentOrg } = useOrganization();
|
||||
const router = useRouter()
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="ml-6 flex flex-row items-center pt-8">
|
||||
<div className="ml-6 flex flex-row items-center pt-6">
|
||||
<div className="mr-2 flex h-6 w-6 items-center justify-center rounded-md bg-primary-900 text-mineshaft-100">
|
||||
{currentOrg?.name?.charAt(0)}
|
||||
</div>
|
||||
@@ -59,31 +61,40 @@ export default function NavHeader({
|
||||
</>
|
||||
)}
|
||||
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-3 text-sm text-gray-400" />
|
||||
{pageName === 'Secrets'
|
||||
? <a className="text-sm font-semibold text-primary/80 hover:text-primary" href={`${router.asPath.split("?")[0]}`}>{pageName}</a>
|
||||
: <div className="text-sm text-gray-400">{pageName}</div>}
|
||||
{currentEnv &&
|
||||
<>
|
||||
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-1.5 text-xs text-gray-400" />
|
||||
<div className='pl-3 rounded-md hover:bg-bunker-100/10'>
|
||||
<Tooltip content="Select environment">
|
||||
<Select
|
||||
value={userAvailableEnvs?.filter(uae => uae.name === currentEnv)[0]?.slug}
|
||||
onValueChange={(value) => {
|
||||
if (value && onEnvChange) onEnvChange(value);
|
||||
}}
|
||||
className="text-sm pl-0 font-medium text-primary/80 hover:text-primary bg-transparent"
|
||||
dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 drop-shadow-2xl"
|
||||
>
|
||||
{userAvailableEnvs?.map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={slug}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>}
|
||||
{pageName === 'Secrets' ? (
|
||||
<Link
|
||||
passHref
|
||||
legacyBehavior
|
||||
href={{ pathname: '/dashboard/[id]', query: { id: router.query.id } }}
|
||||
>
|
||||
<a className="text-sm font-semibold text-primary/80 hover:text-primary">{pageName}</a>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">{pageName}</div>
|
||||
)}
|
||||
{currentEnv && (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-1.5 text-xs text-gray-400" />
|
||||
<div className="rounded-md pl-3 hover:bg-bunker-100/10">
|
||||
<Tooltip content="Select environment">
|
||||
<Select
|
||||
value={userAvailableEnvs?.filter((uae) => uae.name === currentEnv)[0]?.slug}
|
||||
onValueChange={(value) => {
|
||||
if (value && onEnvChange) onEnvChange(value);
|
||||
}}
|
||||
className="bg-transparent pl-0 text-sm font-medium text-primary/80 hover:text-primary"
|
||||
dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 drop-shadow-2xl"
|
||||
>
|
||||
{userAvailableEnvs?.map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={slug}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -36,11 +36,11 @@ const buttonVariants = cva(
|
||||
selected: '',
|
||||
outline_bg: '',
|
||||
// a constant color not in use on hover or click goes colorSchema color
|
||||
star: 'text-bunker-200 bg-mineshaft-500'
|
||||
star: 'text-bunker-200 bg-mineshaft-700 border-mineshaft-600'
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'bg-mineshaft text-white opacity-50 cursor-not-allowed',
|
||||
false: ''
|
||||
true: 'bg-mineshaft-700 border border-mineshaft-600 text-white opacity-50 cursor-not-allowed',
|
||||
false: 'border border-primary-400'
|
||||
},
|
||||
isFullWidth: {
|
||||
true: 'w-full',
|
||||
@@ -61,7 +61,7 @@ const buttonVariants = cva(
|
||||
{
|
||||
colorSchema: 'primary',
|
||||
variant: 'star',
|
||||
className: 'hover:bg-primary hover:text-black'
|
||||
className: 'bg-mineshaft-700 border border-mineshaft-600 hover:bg-primary hover:text-black hover:border-primary-400 duration-100'
|
||||
},
|
||||
{
|
||||
colorSchema: 'primary',
|
||||
@@ -71,7 +71,7 @@ const buttonVariants = cva(
|
||||
{
|
||||
colorSchema: 'primary',
|
||||
variant: 'outline_bg',
|
||||
className: 'bg-mineshaft-800 border border-mineshaft-600 hover:bg-primary/[0.15] hover:border-primary/60 text-bunker-200'
|
||||
className: 'bg-mineshaft-700 border border-mineshaft-600 hover:bg-primary/[0.15] hover:border-primary/60 text-bunker-200 duration-100'
|
||||
},
|
||||
{
|
||||
colorSchema: 'secondary',
|
||||
|
@@ -30,7 +30,8 @@ const iconButtonVariants = cva(
|
||||
solid: '',
|
||||
outline: ['bg-transparent', 'border-2', 'border-solid'],
|
||||
plain: '',
|
||||
star: 'text-bunker-200 bg-mineshaft-500'
|
||||
star: 'text-bunker-200 bg-mineshaft-500',
|
||||
outline_bg: ''
|
||||
},
|
||||
isDisabled: {
|
||||
true: 'bg-opacity-70 cursor-not-allowed',
|
||||
@@ -53,6 +54,11 @@ const iconButtonVariants = cva(
|
||||
variant: 'star',
|
||||
className: 'hover:bg-primary hover:text-black'
|
||||
},
|
||||
{
|
||||
colorSchema: 'primary',
|
||||
variant: 'outline_bg',
|
||||
className: 'bg-mineshaft-700 border border-mineshaft-600 hover:bg-primary/[0.15] hover:border-primary/60 text-bunker-200 hover:text-bunker-100 duration-100'
|
||||
},
|
||||
{
|
||||
colorSchema: 'danger',
|
||||
variant: 'star',
|
||||
|
@@ -33,7 +33,7 @@ const inputVariants = cva(
|
||||
},
|
||||
isError: {
|
||||
true: 'focus:ring-red/50 placeholder-red-300',
|
||||
false: 'focus:ring-primary/50'
|
||||
false: 'focus:ring-mineshaft-400/80 duration-200 focus:ring-1'
|
||||
}
|
||||
},
|
||||
compoundVariants: []
|
||||
@@ -48,7 +48,7 @@ const inputParentContainerVariants = cva('inline-flex font-inter items-center bo
|
||||
},
|
||||
isError: {
|
||||
true: 'border-red',
|
||||
false: 'border-mineshaft-500'
|
||||
false: 'border-mineshaft-600'
|
||||
},
|
||||
isFullWidth: {
|
||||
true: 'w-full',
|
||||
|
@@ -13,7 +13,7 @@ export const Popover = PopoverPrimitive.Root;
|
||||
export type PopoverContentProps = {
|
||||
children?: ReactNode;
|
||||
hideCloseBtn?: boolean;
|
||||
} & PopoverPrimitive.PopperContentProps;
|
||||
} & PopoverPrimitive.PopoverContentProps;
|
||||
|
||||
export const PopoverContent = ({
|
||||
children,
|
||||
|
@@ -1 +1,6 @@
|
||||
export { useBatchSecretsOp, useGetProjectSecrets, useGetSecretVersion } from './queries';
|
||||
export {
|
||||
useBatchSecretsOp,
|
||||
useGetProjectSecrets,
|
||||
useGetProjectSecretsByKey,
|
||||
useGetSecretVersion
|
||||
} from './queries';
|
||||
|
@@ -19,7 +19,10 @@ import {
|
||||
|
||||
export const secretKeys = {
|
||||
// this is also used in secretSnapshot part
|
||||
getProjectSecret: (workspaceId: string, env: string | string[]) => [{ workspaceId, env }, 'secrets'],
|
||||
getProjectSecret: (workspaceId: string, env: string | string[]) => [
|
||||
{ workspaceId, env },
|
||||
'secrets'
|
||||
],
|
||||
getSecretVersion: (secretId: string) => [{ secretId }, 'secret-versions']
|
||||
};
|
||||
|
||||
@@ -32,11 +35,11 @@ const fetchProjectEncryptedSecrets = async (workspaceId: string, env: string | s
|
||||
}
|
||||
});
|
||||
return data.secrets;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (typeof env === 'object') {
|
||||
let allEnvData: any = [];
|
||||
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const envPoint of env) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
@@ -48,13 +51,12 @@ const fetchProjectEncryptedSecrets = async (workspaceId: string, env: string | s
|
||||
});
|
||||
allEnvData = allEnvData.concat(data.secrets);
|
||||
}
|
||||
|
||||
|
||||
return allEnvData;
|
||||
// eslint-disable-next-line no-else-return
|
||||
// eslint-disable-next-line no-else-return
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export const useGetProjectSecrets = ({
|
||||
@@ -117,7 +119,10 @@ export const useGetProjectSecrets = ({
|
||||
};
|
||||
|
||||
if (encSecret.type === 'personal') {
|
||||
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = { id: encSecret._id, value: secretValue };
|
||||
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = {
|
||||
id: encSecret._id,
|
||||
value: secretValue
|
||||
};
|
||||
} else {
|
||||
if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) {
|
||||
sharedSecrets.push(decryptedSecret);
|
||||
@@ -126,17 +131,106 @@ export const useGetProjectSecrets = ({
|
||||
}
|
||||
});
|
||||
sharedSecrets.forEach((val) => {
|
||||
if (personalSecrets?.[val.key]) {
|
||||
val.idOverride = personalSecrets[val.key].id;
|
||||
val.valueOverride = personalSecrets[val.key].value;
|
||||
const dupKey = `${val.key}-${val.env}`;
|
||||
if (personalSecrets?.[dupKey]) {
|
||||
val.idOverride = personalSecrets[dupKey].id;
|
||||
val.valueOverride = personalSecrets[dupKey].value;
|
||||
val.overrideAction = 'modified';
|
||||
}
|
||||
});
|
||||
|
||||
return { secrets: sharedSecrets };
|
||||
}
|
||||
});
|
||||
|
||||
export const useGetProjectSecretsByKey = ({
|
||||
workspaceId,
|
||||
env,
|
||||
decryptFileKey,
|
||||
isPaused
|
||||
}: GetProjectSecretsDTO) =>
|
||||
useQuery({
|
||||
// wait for all values to be available
|
||||
enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused,
|
||||
queryKey: secretKeys.getProjectSecret(workspaceId, env),
|
||||
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env),
|
||||
select: (data) => {
|
||||
const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string;
|
||||
const latestKey = decryptFileKey;
|
||||
const key = decryptAssymmetric({
|
||||
ciphertext: latestKey.encryptedKey,
|
||||
nonce: latestKey.nonce,
|
||||
publicKey: latestKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
const sharedSecrets: Record<string, DecryptedSecret[]> = {};
|
||||
const personalSecrets: Record<string, { id: string; value: string }> = {};
|
||||
// this used for add-only mode in dashboard
|
||||
// type won't be there thus only one key is shown
|
||||
const duplicateSecretKey: Record<string, boolean> = {};
|
||||
const uniqSecKeys: Record<string, boolean> = {};
|
||||
data.forEach((encSecret: EncryptedSecret) => {
|
||||
const secretKey = decryptSymmetric({
|
||||
ciphertext: encSecret.secretKeyCiphertext,
|
||||
iv: encSecret.secretKeyIV,
|
||||
tag: encSecret.secretKeyTag,
|
||||
key
|
||||
});
|
||||
if (!uniqSecKeys?.[secretKey]) uniqSecKeys[secretKey] = true;
|
||||
|
||||
const secretValue = decryptSymmetric({
|
||||
ciphertext: encSecret.secretValueCiphertext,
|
||||
iv: encSecret.secretValueIV,
|
||||
tag: encSecret.secretValueTag,
|
||||
key
|
||||
});
|
||||
|
||||
const secretComment = decryptSymmetric({
|
||||
ciphertext: encSecret.secretCommentCiphertext,
|
||||
iv: encSecret.secretCommentIV,
|
||||
tag: encSecret.secretCommentTag,
|
||||
key
|
||||
});
|
||||
|
||||
const decryptedSecret = {
|
||||
_id: encSecret._id,
|
||||
env: encSecret.environment,
|
||||
key: secretKey,
|
||||
value: secretValue,
|
||||
tags: encSecret.tags,
|
||||
comment: secretComment,
|
||||
createdAt: encSecret.createdAt,
|
||||
updatedAt: encSecret.updatedAt
|
||||
};
|
||||
|
||||
if (encSecret.type === 'personal') {
|
||||
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = {
|
||||
id: encSecret._id,
|
||||
value: secretValue
|
||||
};
|
||||
} else {
|
||||
if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) {
|
||||
if (!sharedSecrets?.[secretKey]) sharedSecrets[secretKey] = [];
|
||||
sharedSecrets[secretKey].push(decryptedSecret);
|
||||
}
|
||||
duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true;
|
||||
}
|
||||
});
|
||||
Object.keys(sharedSecrets).forEach((secName) => {
|
||||
sharedSecrets[secName].forEach((val) => {
|
||||
const dupKey = `${val.key}-${val.env}`;
|
||||
if (personalSecrets?.[dupKey]) {
|
||||
val.idOverride = personalSecrets[dupKey].id;
|
||||
val.valueOverride = personalSecrets[dupKey].value;
|
||||
val.overrideAction = 'modified';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { secrets: sharedSecrets, uniqueSecCount: Object.keys(uniqSecKeys).length };
|
||||
}
|
||||
});
|
||||
|
||||
const fetchEncryptedSecretVersion = async (secretId: string, offset: number, limit: number) => {
|
||||
const { data } = await apiRequest.get<{ secretVersions: EncryptedSecretVersion[] }>(
|
||||
`/api/v1/secret/${secretId}/secret-versions`,
|
||||
|
@@ -62,6 +62,7 @@ type SecretTagArg = { _id: string; name: string; slug: string };
|
||||
export type UpdateSecretArg = {
|
||||
_id: string;
|
||||
type: 'shared' | 'personal';
|
||||
secretName: string;
|
||||
secretKeyCiphertext: string;
|
||||
secretKeyIV: string;
|
||||
secretKeyTag: string;
|
||||
|
@@ -8,11 +8,7 @@ import { Controller, useForm } from 'react-hook-form';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import {
|
||||
faBookOpen,
|
||||
faMobile,
|
||||
faPlus,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { faBookOpen, faMobile, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import queryString from 'query-string';
|
||||
@@ -110,7 +106,6 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
) {
|
||||
router.push('/noprojects');
|
||||
} else if (router.asPath !== '/noprojects') {
|
||||
|
||||
// const pathSegments = router.asPath.split('/').filter(segment => segment.length > 0);
|
||||
|
||||
// let intendedWorkspaceId;
|
||||
@@ -123,8 +118,8 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
// .split('/')
|
||||
// [router.asPath.split('/').length - 1].split('?')[0];
|
||||
// }
|
||||
|
||||
const pathSegments = router.asPath.split('/').filter(segment => segment.length > 0);
|
||||
|
||||
const pathSegments = router.asPath.split('/').filter((segment) => segment.length > 0);
|
||||
|
||||
let intendedWorkspaceId;
|
||||
if (pathSegments.length >= 2 && pathSegments[0] === 'dashboard') {
|
||||
@@ -140,7 +135,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
// const lastPathSegment = router.asPath.split('/').pop().split('?');
|
||||
// [intendedWorkspaceId] = lastPathSegment;
|
||||
}
|
||||
|
||||
|
||||
if (!intendedWorkspaceId) return;
|
||||
|
||||
if (!['callback', 'create', 'authorize'].includes(intendedWorkspaceId)) {
|
||||
@@ -149,7 +144,8 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
|
||||
// If a user is not a member of a workspace they are trying to access, just push them to one of theirs
|
||||
if (
|
||||
!['callback', 'create', 'authorize'].includes(intendedWorkspaceId) && userWorkspaces[0]?._id !== undefined &&
|
||||
!['callback', 'create', 'authorize'].includes(intendedWorkspaceId) &&
|
||||
userWorkspaces[0]?._id !== undefined &&
|
||||
!userWorkspaces
|
||||
.map((workspace: { _id: string }) => workspace._id)
|
||||
.includes(intendedWorkspaceId)
|
||||
@@ -240,21 +236,21 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hidden h-screen w-full flex-col overflow-x-hidden md:flex dark">
|
||||
<div className="dark hidden h-screen w-full flex-col overflow-x-hidden md:flex">
|
||||
<Navbar />
|
||||
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
|
||||
<aside className="w-full border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60">
|
||||
<nav className="items-between flex h-full flex-col justify-between">
|
||||
<div>
|
||||
{currentWorkspace ? (
|
||||
<div className="w-full p-4 mt-3 mb-4">
|
||||
<p className="text-xs font-semibold ml-1.5 mb-1 uppercase text-gray-400">
|
||||
<div className="mt-3 mb-4 w-full p-4">
|
||||
<p className="ml-1.5 mb-1 text-xs font-semibold uppercase text-gray-400">
|
||||
Project
|
||||
</p>
|
||||
<Select
|
||||
defaultValue={currentWorkspace?._id}
|
||||
value={currentWorkspace?._id}
|
||||
className="w-full py-2.5 bg-mineshaft-600 font-medium truncate"
|
||||
className="w-full truncate bg-mineshaft-600 py-2.5 font-medium"
|
||||
onValueChange={(value) => {
|
||||
router.push(`/dashboard/${value}`);
|
||||
}}
|
||||
@@ -273,7 +269,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
{/* <hr className="mt-1 mb-1 h-px border-0 bg-gray-700" /> */}
|
||||
<div className="w-full">
|
||||
<Button
|
||||
className="w-full py-2 text-bunker-200 bg-mineshaft-700"
|
||||
className="w-full bg-mineshaft-700 py-2 text-bunker-200"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
@@ -286,9 +282,9 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full p-4 mt-3 mb-4">
|
||||
<div className="mt-3 mb-4 w-full p-4">
|
||||
<Button
|
||||
className="w-full py-2 text-bunker-200 bg-mineshaft-500 hover:bg-primary/90 hover:text-black"
|
||||
className="w-full bg-mineshaft-500 py-2 text-bunker-200 hover:bg-primary/90 hover:text-black"
|
||||
color="mineshaft"
|
||||
size="sm"
|
||||
onClick={() => handlePopUpOpen('addNewWs')}
|
||||
@@ -331,13 +327,13 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
</a>
|
||||
</Link>
|
||||
<Link href={`/activity/${currentWorkspace?._id}`} passHref>
|
||||
<MenuItem
|
||||
isSelected={router.asPath === `/activity/${currentWorkspace?._id}`}
|
||||
// icon={<FontAwesomeIcon icon={faFileLines} size="lg" />}
|
||||
icon="system-outline-168-view-headline"
|
||||
>
|
||||
Audit Logs
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
isSelected={router.asPath === `/activity/${currentWorkspace?._id}`}
|
||||
// icon={<FontAwesomeIcon icon={faFileLines} size="lg" />}
|
||||
icon="system-outline-168-view-headline"
|
||||
>
|
||||
Audit Logs
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<Link href={`/settings/project/${currentWorkspace?._id}`} passHref>
|
||||
<a>
|
||||
@@ -428,7 +424,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="pl-1 mt-4">
|
||||
<div className="mt-4 pl-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="addMembers"
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -14,18 +14,32 @@ export const queryClient = new QueryClient({
|
||||
}
|
||||
});
|
||||
|
||||
// memory token storage will be moved to apiRequest module until securityclient is completely depreciated
|
||||
// then all the getters will be also hidden scoped to apiRequest only
|
||||
const MemoryTokenStorage = () => {
|
||||
let authToken: string;
|
||||
|
||||
return {
|
||||
setToken: (token: string) => {
|
||||
authToken = token;
|
||||
},
|
||||
getToken: () => authToken
|
||||
};
|
||||
};
|
||||
|
||||
const signUpTempTokenStorage = MemoryTokenStorage();
|
||||
const mfaAuthTokenStorage = MemoryTokenStorage();
|
||||
const authTokenStorage = MemoryTokenStorage();
|
||||
|
||||
// set token in memory cache
|
||||
export const setSignupTempToken = (token: string) =>
|
||||
queryClient.setQueryData(SIGNUP_TEMP_TOKEN_CACHE_KEY, token);
|
||||
export const setSignupTempToken = signUpTempTokenStorage.setToken;
|
||||
|
||||
export const setMfaTempToken = (token: string) =>
|
||||
queryClient.setQueryData(MFA_TEMP_TOKEN_CACHE_KEY, token);
|
||||
export const setMfaTempToken = mfaAuthTokenStorage.setToken;
|
||||
|
||||
export const setAuthToken = (token: string) =>
|
||||
queryClient.setQueryData(AUTH_TOKEN_CACHE_KEY, token);
|
||||
export const setAuthToken = authTokenStorage.setToken;
|
||||
|
||||
export const getSignupTempToken = () => queryClient.getQueryData(SIGNUP_TEMP_TOKEN_CACHE_KEY) as string;
|
||||
export const getMfaTempToken = () => queryClient.getQueryData(MFA_TEMP_TOKEN_CACHE_KEY) as string;
|
||||
export const getAuthToken = () => queryClient.getQueryData(AUTH_TOKEN_CACHE_KEY) as string;
|
||||
export const getSignupTempToken = signUpTempTokenStorage.getToken;
|
||||
export const getMfaTempToken = mfaAuthTokenStorage.getToken;
|
||||
export const getAuthToken = authTokenStorage.getToken;
|
||||
|
||||
export const isLoggedIn = () => Boolean(getAuthToken());
|
||||
|
@@ -1,50 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
|
||||
import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider';
|
||||
import NavHeader from '@app/components/navigation/NavHeader';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalContent,
|
||||
TableContainer,
|
||||
Tooltip
|
||||
} from '@app/components/v2';
|
||||
import { Button, TableContainer, Tooltip } from '@app/components/v2';
|
||||
import { useWorkspace } from '@app/context';
|
||||
import { usePopUp } from '@app/hooks';
|
||||
import {
|
||||
useCreateWsTag,
|
||||
useGetProjectSecrets,
|
||||
useGetProjectSecretsByKey,
|
||||
useGetUserWsEnvironments,
|
||||
useGetUserWsKey,
|
||||
useGetUserWsKey
|
||||
} from '@app/hooks/api';
|
||||
import { WorkspaceEnv } from '@app/hooks/api/types';
|
||||
|
||||
import { CreateTagModal } from './components/CreateTagModal';
|
||||
import { EnvComparisonRow } from './components/EnvComparisonRow';
|
||||
import {
|
||||
FormData,
|
||||
schema
|
||||
} from './DashboardPage.utils';
|
||||
import { FormData, schema } from './DashboardPage.utils';
|
||||
|
||||
|
||||
export const DashboardEnvOverview = ({onEnvChange}: {onEnvChange: any;}) => {
|
||||
export const DashboardEnvOverview = ({ onEnvChange }: { onEnvChange: any }) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { popUp
|
||||
// , handlePopUpOpen
|
||||
, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
'secretDetails',
|
||||
'addTag',
|
||||
'secretSnapshots',
|
||||
'uploadedSecOpts',
|
||||
'compareSecrets'
|
||||
] as const);
|
||||
const [selectedEnv, setSelectedEnv] = useState<WorkspaceEnv | null>(null);
|
||||
|
||||
const { currentWorkspace, isLoading } = useWorkspace();
|
||||
@@ -68,20 +44,15 @@ export const DashboardEnvOverview = ({onEnvChange}: {onEnvChange: any;}) => {
|
||||
}
|
||||
});
|
||||
|
||||
const userAvailableEnvs = wsEnv?.filter(
|
||||
({ isReadDenied }) => !isReadDenied
|
||||
);
|
||||
|
||||
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
||||
const userAvailableEnvs = wsEnv?.filter(({ isReadDenied }) => !isReadDenied);
|
||||
|
||||
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecretsByKey({
|
||||
workspaceId,
|
||||
env: userAvailableEnvs?.map(env => env.slug) ?? [],
|
||||
env: userAvailableEnvs?.map((env) => env.slug) ?? [],
|
||||
decryptFileKey: latestFileKey!,
|
||||
isPaused: false
|
||||
});
|
||||
|
||||
// mutation calls
|
||||
const { mutateAsync: createWsTag } = useCreateWsTag();
|
||||
|
||||
const method = useForm<FormData>({
|
||||
// why any: well yup inferred ts expects other keys to defined as undefined
|
||||
defaultValues: secrets as any,
|
||||
@@ -90,39 +61,24 @@ export const DashboardEnvOverview = ({onEnvChange}: {onEnvChange: any;}) => {
|
||||
resolver: yupResolver(schema)
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
// handleSubmit,
|
||||
// getValues,
|
||||
// setValue,
|
||||
// formState: { isSubmitting, dirtyFields },
|
||||
// reset
|
||||
} = method;
|
||||
const formSecrets = useWatch({ control, name: 'secrets' });
|
||||
const numSecretsMissingPerEnv = useMemo(() => {
|
||||
// first get all sec in the env then subtract with total to get missing ones
|
||||
const secPerEnvMissing: Record<string, number> = Object.fromEntries(
|
||||
(userAvailableEnvs || [])?.map(({ slug }) => [slug, 0])
|
||||
);
|
||||
Object.keys(secrets?.secrets || {}).forEach((key) =>
|
||||
secrets?.secrets?.[key].forEach((val) => {
|
||||
secPerEnvMissing[val.env] += 1;
|
||||
})
|
||||
);
|
||||
Object.keys(secPerEnvMissing).forEach((k) => {
|
||||
secPerEnvMissing[k] = (secrets?.uniqueSecCount || 0) - secPerEnvMissing[k];
|
||||
});
|
||||
return secPerEnvMissing;
|
||||
}, [secrets, userAvailableEnvs]);
|
||||
|
||||
const isReadOnly = selectedEnv?.isWriteDenied;
|
||||
|
||||
const onCreateWsTag = async (tagName: string) => {
|
||||
try {
|
||||
await createWsTag({
|
||||
workspaceID: workspaceId,
|
||||
tagName,
|
||||
tagSlug: tagName.replace(' ', '_')
|
||||
});
|
||||
handlePopUpClose('addTag');
|
||||
createNotification({
|
||||
text: 'Successfully created a tag',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: 'Failed to create a tag',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isSecretsLoading || isEnvListLoading) {
|
||||
return (
|
||||
<div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||
@@ -132,69 +88,89 @@ export const DashboardEnvOverview = ({onEnvChange}: {onEnvChange: any;}) => {
|
||||
}
|
||||
|
||||
// when secrets is not loading and secrets list is empty
|
||||
const isDashboardSecretEmpty = !isSecretsLoading && !formSecrets?.length;
|
||||
|
||||
const numSecretsMissingPerEnv = userAvailableEnvs?.map(envir => ({[envir.slug]: [... new Set(secrets?.secrets.map((secret: any) => secret.key))].length - [... new Set(secrets?.secrets.filter(s => s.env === envir.slug).map((secret: any) => secret.key))].length})).reduce((acc, cur) => ({ ...acc, ...cur }), {})
|
||||
const isDashboardSecretEmpty = !isSecretsLoading && !Object.keys(secrets?.secrets || {})?.length;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-full px-6 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||
<FormProvider {...method}>
|
||||
<form autoComplete="off">
|
||||
{/* breadcrumb row */}
|
||||
<div className="relative right-5">
|
||||
<div className="relative right-6">
|
||||
<NavHeader pageName={t('dashboard:title')} isProjectRelated />
|
||||
</div>
|
||||
<div className="mt-6 ml-1">
|
||||
<div className="mt-6">
|
||||
<p className="text-3xl font-semibold text-bunker-100">Secrets Overview</p>
|
||||
<p className="text-md text-bunker-300">Inject your secrets using
|
||||
<a
|
||||
className="text-primary/80 hover:text-primary mx-1"
|
||||
href="https://infisical.com/docs/cli/overview"
|
||||
<p className="text-md text-bunker-300">
|
||||
Inject your secrets using
|
||||
<a
|
||||
className="mx-1 text-primary/80 hover:text-primary"
|
||||
href="https://infisical.com/docs/cli/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Infisical CLI
|
||||
</a>
|
||||
or
|
||||
<a
|
||||
className="text-primary/80 hover:text-primary mx-1"
|
||||
href="https://infisical.com/docs/sdks/overview"
|
||||
</a>
|
||||
or
|
||||
<a
|
||||
className="mx-1 text-primary/80 hover:text-primary"
|
||||
href="https://infisical.com/docs/sdks/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Infisical SDKs
|
||||
</a> </p>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-y-auto">
|
||||
<div className="sticky top-0 absolute flex flex-row h-10 bg-mineshaft-800 border border-mineshaft-600 rounded-md mt-8 min-w-[60.3rem]">
|
||||
<div className="sticky top-0 w-10 px-4 flex items-center justify-center border-none">
|
||||
<div className='text-center w-10 text-xs text-transparent'>{0}</div>
|
||||
<div className="sticky top-0 mt-8 flex h-10 min-w-[60.3rem] flex-row rounded-md border border-mineshaft-600 bg-mineshaft-800">
|
||||
<div className="sticky top-0 flex w-10 items-center justify-center border-none px-4">
|
||||
<div className="w-10 text-center text-xs text-transparent">{0}</div>
|
||||
</div>
|
||||
<div className="sticky top-0 border-none">
|
||||
<div className="min-w-[200px] lg:min-w-[220px] xl:min-w-[250px] relative flex items-center justify-start h-full w-full">
|
||||
<div className="relative flex h-full w-full min-w-[200px] items-center justify-start lg:min-w-[220px] xl:min-w-[250px]">
|
||||
<div className="text-sm font-medium ">Secret</div>
|
||||
</div>
|
||||
</div>
|
||||
{numSecretsMissingPerEnv && userAvailableEnvs?.map(env => {
|
||||
return <div key={`header-${env.slug}`} className="flex flex-row w-full bg-mineshaft-800 rounded-md items-center border-none min-w-[11rem]">
|
||||
<div className="text-sm font-medium w-full text-center text-bunker-200/[.99] flex flex-row justify-center">
|
||||
{env.name}
|
||||
{numSecretsMissingPerEnv[env.slug] > 0 && <div className="bg-red rounded-sm h-[1.1rem] w-[1.1rem] mt-0.5 text-bunker-100 ml-2.5 text-xs border border-red-400 flex items-center justify-center cursor-default">
|
||||
<Tooltip content={`${numSecretsMissingPerEnv[env.slug]} secrets missing compared to other environments`}><span className="text-bunker-100">{numSecretsMissingPerEnv[env.slug]}</span></Tooltip>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
})}
|
||||
{numSecretsMissingPerEnv &&
|
||||
userAvailableEnvs?.map((env) => {
|
||||
return (
|
||||
<div
|
||||
key={`header-${env.slug}`}
|
||||
className="flex w-full min-w-[11rem] flex-row items-center rounded-md border-none bg-mineshaft-800"
|
||||
>
|
||||
<div className="flex w-full flex-row justify-center text-center text-sm font-medium text-bunker-200/[.99]">
|
||||
{env.name}
|
||||
{numSecretsMissingPerEnv[env.slug] > 0 && (
|
||||
<div className="mt-0.5 ml-2.5 flex h-[1.1rem] w-[1.1rem] cursor-default items-center justify-center rounded-sm border border-red-400 bg-red text-xs text-bunker-100">
|
||||
<Tooltip
|
||||
content={`${
|
||||
numSecretsMissingPerEnv[env.slug]
|
||||
} secrets missing compared to other environments`}
|
||||
>
|
||||
<span className="text-bunker-100">
|
||||
{numSecretsMissingPerEnv[env.slug]}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={`${isDashboardSecretEmpty ? "" : ""} flex flex-row items-start justify-center mt-3 h-full max-h-[calc(100vh-370px)] min-w-[60.3rem] flex-grow w-full overflow-x-hidden no-scrollbar no-scrollbar::-webkit-scrollbar`}>
|
||||
<div
|
||||
className={`${
|
||||
isDashboardSecretEmpty ? '' : ''
|
||||
} no-scrollbar::-webkit-scrollbar mt-3 border rounded-md border-mineshaft-600 flex h-full max-h-[calc(100vh-370px)] w-full min-w-[60.3rem] flex-grow flex-row items-start justify-center overflow-x-hidden no-scrollbar`}
|
||||
>
|
||||
{!isDashboardSecretEmpty && (
|
||||
<TableContainer className='border-none'>
|
||||
<table className="relative secret-table w-full relative bg-bunker-800">
|
||||
<tbody className="overflow-y-auto max-h-screen">
|
||||
{[... new Set(secrets?.secrets.map((secret: any) => secret.key))].map((key, index) => (
|
||||
<TableContainer className="border-none">
|
||||
<table className="secret-table relative w-full bg-mineshaft-900">
|
||||
<tbody className="max-h-screen overflow-y-auto">
|
||||
{Object.keys(secrets?.secrets || {}).map((key, index) => (
|
||||
<EnvComparisonRow
|
||||
key={`row-${key}`}
|
||||
secrets={secrets?.secrets.filter(secret => secret.key === key)}
|
||||
secrets={secrets?.secrets?.[key]}
|
||||
isReadOnly={isReadOnly}
|
||||
index={index}
|
||||
isSecretValueHidden
|
||||
@@ -205,22 +181,15 @@ export const DashboardEnvOverview = ({onEnvChange}: {onEnvChange: any;}) => {
|
||||
</table>
|
||||
</TableContainer>
|
||||
)}
|
||||
{isDashboardSecretEmpty &&
|
||||
<div className='flex flex-row h-40 rounded-md mt-1 w-full'>
|
||||
<div className="sticky top-0 w-10 px-4 flex items-center justify-center border-none">
|
||||
<div className='text-center w-10 text-xs text-transparent'>{0}</div>
|
||||
</div>
|
||||
<div className="sticky top-0 border-none">
|
||||
<div className="min-w-[200px] lg:min-w-[220px] xl:min-w-[250px] relative flex items-center justify-start h-full w-full">
|
||||
<div className="text-sm font-medium text-transparent">Secret</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col w-full bg-mineshaft-800 text-bunker-300 rounded-md items-center justify-center border-none mx-2 min-w-[11rem]">
|
||||
{isDashboardSecretEmpty && (
|
||||
<div className="flex h-40 w-full flex-row rounded-md">
|
||||
<div className="flex w-full min-w-[11rem] flex-col items-center justify-center rounded-md border-none bg-mineshaft-800 text-bunker-300">
|
||||
<span className="mb-1">No secrets are available in this project yet.</span>
|
||||
<span>You can go into any environment to add secrets there.</span>
|
||||
</div>
|
||||
</div>}
|
||||
{/* In future, we should add an option to add environments here
|
||||
</div>
|
||||
)}
|
||||
{/* In future, we should add an option to add environments here
|
||||
<div className="ml-10 h-full flex items-start justify-center">
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus}/>}
|
||||
@@ -234,14 +203,22 @@ export const DashboardEnvOverview = ({onEnvChange}: {onEnvChange: any;}) => {
|
||||
</Button>
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="group flex flex-row items-center mt-4 min-w-[60.3rem]">
|
||||
<div className="w-10 h-10 px-4 flex items-center justify-center border-none"><div className='text-center w-10 text-xs text-transparent'>0</div></div>
|
||||
<div className="flex flex-row justify-between items-center min-w-[200px] lg:min-w-[220px] xl:min-w-[250px]">
|
||||
<span className="text-transparent">0</span>
|
||||
<button type="button" className='mr-2 text-transparent'>1</button>
|
||||
<div className="group mt-4 flex min-w-[60.3rem] flex-row items-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center border-none px-4">
|
||||
<div className="w-10 text-center text-xs text-transparent">0</div>
|
||||
</div>
|
||||
{userAvailableEnvs?.map(env => {
|
||||
return <div key={`button-${env.slug}`} className="flex flex-row w-full justify-center h-10 items-center border-none mb-1 mx-2 min-w-[11rem]">
|
||||
<div className="flex min-w-[200px] flex-row items-center justify-between lg:min-w-[220px] xl:min-w-[250px]">
|
||||
<span className="text-transparent">0</span>
|
||||
<button type="button" className="mr-2 text-transparent">
|
||||
1
|
||||
</button>
|
||||
</div>
|
||||
{userAvailableEnvs?.map((env) => {
|
||||
return (
|
||||
<div
|
||||
key={`button-${env.slug}`}
|
||||
className="mx-2 mb-1 flex h-10 w-full min-w-[11rem] flex-row items-center justify-center border-none"
|
||||
>
|
||||
<Button
|
||||
onClick={() => onEnvChange(env.slug)}
|
||||
// router.push(`${router.asPath }?env=${env.slug}`)
|
||||
@@ -253,23 +230,11 @@ export const DashboardEnvOverview = ({onEnvChange}: {onEnvChange: any;}) => {
|
||||
Explore {env.name}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<Modal
|
||||
isOpen={popUp?.addTag?.isOpen}
|
||||
onOpenChange={(open) => {
|
||||
handlePopUpToggle('addTag', open);
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
title="Create tag"
|
||||
subTitle="Specify your tag name, and the slug will be created automatically."
|
||||
>
|
||||
<CreateTagModal onCreateTag={onCreateWsTag} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FormProvider, useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
@@ -90,6 +90,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const secretContainer = useRef<HTMLDivElement | null>(null);
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
'secretDetails',
|
||||
'addTag',
|
||||
@@ -102,7 +103,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
const [snapshotId, setSnaphotId] = useState<string | null>(null);
|
||||
const [selectedEnv, setSelectedEnv] = useState<WorkspaceEnv | null>(null);
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [deletedSecretIds, setDeletedSecretIds] = useState<string[]>([]);
|
||||
const deletedSecretIds = useRef<string[]>([]);
|
||||
const { hasUnsavedChanges, setHasUnsavedChanges } = useLeaveConfirm({ initialValue: false });
|
||||
|
||||
const { currentWorkspace, isLoading } = useWorkspace();
|
||||
@@ -124,8 +125,8 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
onSuccess: (data) => {
|
||||
// get an env with one of the access available
|
||||
const env = data.find(({ isReadDenied, isWriteDenied }) => !isWriteDenied || !isReadDenied);
|
||||
if (env && data?.map(wsenv => wsenv.slug).includes(envFromTop)) {
|
||||
setSelectedEnv(data?.filter(dp => dp.slug === envFromTop)[0]);
|
||||
if (env && data?.map((wsenv) => wsenv.slug).includes(envFromTop)) {
|
||||
setSelectedEnv(data?.filter((dp) => dp.slug === envFromTop)[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -187,23 +188,15 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
handleSubmit,
|
||||
getValues,
|
||||
setValue,
|
||||
formState: { isSubmitting, dirtyFields },
|
||||
formState: { isSubmitting, isDirty },
|
||||
reset
|
||||
} = method;
|
||||
const formSecrets = useWatch({ control, name: 'secrets' });
|
||||
const { fields, prepend, append, remove, update } = useFieldArray({ control, name: 'secrets' });
|
||||
|
||||
const { fields, prepend, append, remove } = useFieldArray({ control, name: 'secrets' });
|
||||
const isRollbackMode = Boolean(snapshotId);
|
||||
const isReadOnly = selectedEnv?.isWriteDenied;
|
||||
const isAddOnly = selectedEnv?.isReadDenied && !selectedEnv?.isWriteDenied;
|
||||
const canDoRollback = !isReadOnly && !isAddOnly;
|
||||
const isSubmitDisabled =
|
||||
isReadOnly ||
|
||||
// on add only mode the formstate becomes dirty due to secrets missing some items
|
||||
// to avoid this we check dirtyFields in isAddOnly Mode
|
||||
(isAddOnly && Object.keys(dirtyFields).length === 0) ||
|
||||
(!isRollbackMode && !isAddOnly && Object.keys(dirtyFields).length === 0) ||
|
||||
isSubmitting;
|
||||
const isSubmitDisabled = isReadOnly || (!isRollbackMode && !isDirty) || isAddOnly || isSubmitting;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSnapshotChanging && Boolean(snapshotId)) {
|
||||
@@ -240,15 +233,16 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
// append non conflicting ones
|
||||
Object.keys(uploadedSec).forEach((key) => {
|
||||
if (!conflictingSecIds?.[key]) {
|
||||
append({
|
||||
delete conflictingUploadedSec[key];
|
||||
sec.push({
|
||||
...DEFAULT_SECRET_VALUE,
|
||||
key,
|
||||
value: uploadedSec[key].value,
|
||||
comment: uploadedSec[key].comments.join(',')
|
||||
});
|
||||
delete conflictingUploadedSec[key];
|
||||
}
|
||||
});
|
||||
setValue('secrets', sec, { shouldDirty: true });
|
||||
if (conflictingSec.length > 0) {
|
||||
handlePopUpOpen('uploadedSecOpts', { secrets: conflictingUploadedSec });
|
||||
}
|
||||
@@ -264,14 +258,15 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
data.forEach(({ key, index }) => {
|
||||
const { value, comments } = uploadedSec[key];
|
||||
const comment = comments.join(', ');
|
||||
update(index, {
|
||||
sec[index] = {
|
||||
...DEFAULT_SECRET_VALUE,
|
||||
key,
|
||||
value,
|
||||
comment,
|
||||
tags: sec[index].tags
|
||||
});
|
||||
};
|
||||
});
|
||||
setValue('secrets', sec, { shouldDirty: true });
|
||||
handlePopUpClose('uploadedSecOpts');
|
||||
};
|
||||
|
||||
@@ -319,7 +314,12 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
const sec = isAddOnly ? userSec.filter(({ _id }) => !_id) : userSec;
|
||||
// encrypt and format the secrets to batch api format
|
||||
// requests = [ {method:"", secret:""} ]
|
||||
const batchedSecret = transformSecretsToBatchSecretReq(deletedSecretIds, latestFileKey, sec);
|
||||
const batchedSecret = transformSecretsToBatchSecretReq(
|
||||
deletedSecretIds.current,
|
||||
latestFileKey,
|
||||
sec,
|
||||
secrets?.secrets
|
||||
);
|
||||
// type check
|
||||
if (!selectedEnv?.slug) return;
|
||||
try {
|
||||
@@ -332,6 +332,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
text: 'Successfully saved changes',
|
||||
type: 'success'
|
||||
});
|
||||
deletedSecretIds.current = [];
|
||||
if (!hasUserPushed) {
|
||||
await registerUserAction(USER_ACTION_PUSH);
|
||||
}
|
||||
@@ -355,16 +356,17 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
}
|
||||
const env = wsEnv?.find((el) => el.slug === slug);
|
||||
if (env) setSelectedEnv(env);
|
||||
router.push(`${router.asPath.split("?")[0]}?env=${slug}`)
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, env: slug }
|
||||
});
|
||||
};
|
||||
|
||||
// record all deleted ids
|
||||
// This will make final deletion easier
|
||||
const onSecretDelete = (index: number, id?: string, overrideId?: string) => {
|
||||
const ids: string[] = [];
|
||||
if (id) ids.push(id);
|
||||
if (overrideId) ids.push(overrideId);
|
||||
setDeletedSecretIds((state) => [...state, ...ids]);
|
||||
if (id) deletedSecretIds.current.push(id);
|
||||
if (overrideId) deletedSecretIds.current.push(overrideId);
|
||||
remove(index);
|
||||
// just the case if this is called from drawer
|
||||
handlePopUpClose('secretDetails');
|
||||
@@ -400,7 +402,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
}
|
||||
|
||||
// when secrets is not loading and secrets list is empty
|
||||
const isDashboardSecretEmpty = !isSecretsLoading && !formSecrets?.length;
|
||||
const isDashboardSecretEmpty = !isSecretsLoading && false;
|
||||
// when using snapshot mode and snapshot is loading and snapshot list is empty
|
||||
const isSnapshotSecretEmtpy =
|
||||
isRollbackMode && !isSnapshotSecretsLoading && !snapshotSecret?.secrets?.length;
|
||||
@@ -411,33 +413,33 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-full px-6 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||
<div className="mr-auto container px-6 text-mineshaft-50 dark:[color-scheme:dark] h-full">
|
||||
<FormProvider {...method}>
|
||||
<form autoComplete="off">
|
||||
{/* breadcrumb row */}
|
||||
<div className="relative right-5">
|
||||
<NavHeader
|
||||
pageName={t('dashboard:title')}
|
||||
currentEnv={userAvailableEnvs?.filter(envir => envir.slug === envFromTop)[0].name || ''}
|
||||
isProjectRelated
|
||||
<div className="relative right-6 mb-6 -top-2">
|
||||
<NavHeader
|
||||
pageName={t('dashboard:title')}
|
||||
currentEnv={
|
||||
userAvailableEnvs?.filter((envir) => envir.slug === envFromTop)[0].name || ''
|
||||
}
|
||||
isProjectRelated
|
||||
userAvailableEnvs={userAvailableEnvs}
|
||||
onEnvChange={onEnvChange}
|
||||
/>
|
||||
</div>
|
||||
{/* Secrets, commit and save button section */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h1 className="text-3xl font-semibold">
|
||||
{isRollbackMode ? 'Secret Snapshot' : 'Secrets'}
|
||||
</h1>
|
||||
{isRollbackMode && Boolean(snapshotSecret) && (
|
||||
<Tag colorSchema="green">
|
||||
{new Date(snapshotSecret?.createdAt || '').toLocaleString()}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isRollbackMode && (
|
||||
{/* This is only for rollbacks */}
|
||||
{isRollbackMode &&
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h1 className="text-3xl font-semibold">Secret Snapshot</h1>
|
||||
{isRollbackMode && Boolean(snapshotSecret) && (
|
||||
<Tag colorSchema="green">
|
||||
{new Date(snapshotSecret?.createdAt || '').toLocaleString()}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="star"
|
||||
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
|
||||
@@ -445,37 +447,17 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
setSnaphotId(null);
|
||||
reset({ ...secrets, isSnapshotMode: false });
|
||||
}}
|
||||
className='h-10'
|
||||
className="h-10"
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="star"
|
||||
onClick={() => handlePopUpOpen('secretSnapshots')}
|
||||
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
|
||||
isLoading={isLoadingSnapshotCount}
|
||||
isDisabled={!canDoRollback}
|
||||
className='h-10'
|
||||
>
|
||||
{snapshotCount} Commits
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={isSubmitDisabled}
|
||||
isLoading={isSubmitting}
|
||||
leftIcon={<FontAwesomeIcon icon={isRollbackMode ? faClockRotateLeft : faCheck} />}
|
||||
onClick={handleSubmit(onSaveSecret)}
|
||||
className='h-10'
|
||||
>
|
||||
{isRollbackMode ? 'Rollback' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
{/* Environment, search and other action row */}
|
||||
<div className="mt-4 flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<div className="mt-2 flex items-center space-x-2 justify-between">
|
||||
<div className="flex-grow max-w-sm">
|
||||
<Input
|
||||
className="bg-mineshaft-600 h-[2.3rem] placeholder-mineshaft-50"
|
||||
className="h-[2.3rem] bg-mineshaft-800 focus:bg-mineshaft-700/80 duration-200 placeholder-mineshaft-50"
|
||||
placeholder="Search keys..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
@@ -486,16 +468,20 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
<div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<IconButton ariaLabel="download" variant="star">
|
||||
<IconButton ariaLabel="download" variant="outline_bg">
|
||||
<FontAwesomeIcon icon={faDownload} />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto bg-mineshaft-800 border border-mineshaft-600 p-1" hideCloseBtn>
|
||||
<PopoverContent
|
||||
className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-1"
|
||||
hideCloseBtn
|
||||
>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Button
|
||||
onClick={() => downloadSecret(getValues('secrets'), selectedEnv?.slug)}
|
||||
variant="star"
|
||||
className="bg-bunker-700 h-8"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
className="h-8 bg-bunker-700"
|
||||
>
|
||||
Download as .env
|
||||
</Button>
|
||||
@@ -507,33 +493,92 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
<Tooltip content={isSecretValueHidden ? 'Reveal Secrets' : 'Hide secrets'}>
|
||||
<IconButton
|
||||
ariaLabel="reveal"
|
||||
variant="star"
|
||||
variant="outline_bg"
|
||||
onClick={() => setIsSecretValueHidden.toggle()}
|
||||
>
|
||||
<FontAwesomeIcon icon={isSecretValueHidden ? faEye : faEyeSlash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!isReadOnly && !isRollbackMode && <Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => prepend(DEFAULT_SECRET_VALUE, { shouldFocus: false })}
|
||||
isDisabled={isReadOnly || isRollbackMode}
|
||||
variant="star"
|
||||
<div className='block xl:hidden'>
|
||||
<Tooltip content='Point-in-time Recovery'>
|
||||
<IconButton
|
||||
ariaLabel="recovery"
|
||||
variant="outline_bg"
|
||||
onClick={() => setIsSecretValueHidden.toggle()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCodeCommit} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className='hidden xl:block'>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => handlePopUpOpen('secretSnapshots')}
|
||||
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
|
||||
isLoading={isLoadingSnapshotCount}
|
||||
isDisabled={!canDoRollback}
|
||||
className="h-10"
|
||||
>
|
||||
{snapshotCount} Commits
|
||||
</Button>
|
||||
</div>
|
||||
{!isReadOnly && !isRollbackMode && (
|
||||
<>
|
||||
<div className='block lg:hidden'>
|
||||
<Tooltip content='Point-in-time Recovery'>
|
||||
<IconButton
|
||||
ariaLabel="recovery"
|
||||
variant="outline_bg"
|
||||
onClick={() => setIsSecretValueHidden.toggle()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className='hidden lg:block'>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
if (secretContainer.current) {
|
||||
secretContainer.current.scroll({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
prepend(DEFAULT_SECRET_VALUE, { shouldFocus: false });
|
||||
}}
|
||||
isDisabled={isReadOnly || isRollbackMode}
|
||||
variant="outline_bg"
|
||||
className="h-10"
|
||||
>
|
||||
Add Secret
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
isDisabled={isSubmitDisabled}
|
||||
isLoading={isSubmitting}
|
||||
leftIcon={<FontAwesomeIcon icon={isRollbackMode ? faClockRotateLeft : faCheck} />}
|
||||
onClick={handleSubmit(onSaveSecret)}
|
||||
className="h-10"
|
||||
>
|
||||
Add Secret
|
||||
</Button>}
|
||||
{isRollbackMode ? 'Rollback' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${isSecretEmpty ? "flex flex-col items-center justify-center" : ""} mt-4 h-[calc(100vh-270px)] overflow-y-scroll overflow-x-hidden no-scrollbar no-scrollbar::-webkit-scrollbar`}>
|
||||
<div
|
||||
className={`${
|
||||
isSecretEmpty ? 'flex flex-col items-center justify-center' : ''
|
||||
} no-scrollbar::-webkit-scrollbar mt-3 h-[calc(100vh-220px)] overflow-x-hidden overflow-y-scroll no-scrollbar`}
|
||||
ref={secretContainer}
|
||||
>
|
||||
{!isSecretEmpty && (
|
||||
<TableContainer>
|
||||
<TableContainer className="max-h-[calc(100%-40px)] no-scrollbar no-scrollbar::-webkit-scrollbar">
|
||||
<table className="secret-table relative">
|
||||
<SecretTableHeader
|
||||
sortDir={sortDir}
|
||||
onSort={onSortSecrets}
|
||||
/>
|
||||
<tbody className="overflow-y-auto max-h-screen">
|
||||
<SecretTableHeader sortDir={sortDir} onSort={onSortSecrets} />
|
||||
<tbody className="max-h-96 overflow-y-auto">
|
||||
{fields.map(({ id, _id }, index) => (
|
||||
<SecretInputRow
|
||||
key={id}
|
||||
@@ -554,11 +599,11 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
<td colSpan={3} className="hover:bg-mineshaft-700">
|
||||
<button
|
||||
type="button"
|
||||
className="w-[calc(100vw-400px)] h-8 ml-12 font-normal text-bunker-300 flex justify-start items-center"
|
||||
className="pl-12 cursor-default w-full flex h-8 items-center justify-start font-normal text-bunker-300"
|
||||
onClick={onAppendSecret}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
<span className="w-20 ml-2">Add Secret</span>
|
||||
<span className="ml-2 w-20">Add Secret</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import crypto from 'crypto';
|
||||
|
||||
import * as yup from 'yup';
|
||||
@@ -6,7 +7,7 @@ import {
|
||||
decryptAssymmetric,
|
||||
encryptSymmetric
|
||||
} from '@app/components/utilities/cryptography/crypto';
|
||||
import { BatchSecretDTO } from '@app/hooks/api/secrets/types';
|
||||
import { BatchSecretDTO, DecryptedSecret } from '@app/hooks/api/secrets/types';
|
||||
|
||||
export enum SecretActionType {
|
||||
Created = 'created',
|
||||
@@ -147,10 +148,18 @@ const encryptASecret = (randomBytes: string, key: string, value?: string, commen
|
||||
};
|
||||
};
|
||||
|
||||
const deepCompareSecrets = (lhs: DecryptedSecret, rhs: any) =>
|
||||
lhs.key === rhs.key &&
|
||||
lhs.value === rhs.value &&
|
||||
lhs.comment === rhs.comment &&
|
||||
lhs?.valueOverride === rhs?.valueOverride &&
|
||||
JSON.stringify(lhs.tags) === JSON.stringify(rhs.tags);
|
||||
|
||||
export const transformSecretsToBatchSecretReq = (
|
||||
deletedSecretIds: string[],
|
||||
latestFileKey: any,
|
||||
secrets: FormData['secrets']
|
||||
secrets: FormData['secrets'],
|
||||
intialValues: DecryptedSecret[] = []
|
||||
) => {
|
||||
// deleted secrets
|
||||
const secretsToBeDeleted: BatchSecretDTO['requests'] = deletedSecretIds.map((id) => ({
|
||||
@@ -171,61 +180,80 @@ export const transformSecretsToBatchSecretReq = (
|
||||
})
|
||||
: crypto.randomBytes(16).toString('hex');
|
||||
|
||||
secrets?.forEach(
|
||||
({ _id, idOverride, value, valueOverride, overrideAction, tags = [], comment, key }) => {
|
||||
if (!idOverride && overrideAction === SecretActionType.Created) {
|
||||
secretsToBeCreated.push({
|
||||
method: 'POST',
|
||||
secret: {
|
||||
type: 'personal',
|
||||
tags,
|
||||
...encryptASecret(randomBytes, key, valueOverride, comment)
|
||||
}
|
||||
});
|
||||
}
|
||||
// to be created ones as they don't have server generated id
|
||||
if (!_id) {
|
||||
secretsToBeCreated.push({
|
||||
method: 'POST',
|
||||
secret: {
|
||||
type: 'shared',
|
||||
tags,
|
||||
...encryptASecret(randomBytes, key, value, comment)
|
||||
}
|
||||
});
|
||||
return; // exit as updated and delete case won't happen when created
|
||||
}
|
||||
// has an id means this is updated one
|
||||
if (_id) {
|
||||
secrets?.forEach((secret) => {
|
||||
const {
|
||||
_id,
|
||||
idOverride,
|
||||
value,
|
||||
valueOverride,
|
||||
overrideAction,
|
||||
tags = [],
|
||||
comment,
|
||||
key
|
||||
} = secret;
|
||||
if (!idOverride && overrideAction === SecretActionType.Created) {
|
||||
secretsToBeCreated.push({
|
||||
method: 'POST',
|
||||
secret: {
|
||||
type: 'personal',
|
||||
tags,
|
||||
secretName: key,
|
||||
...encryptASecret(randomBytes, key, valueOverride, comment)
|
||||
}
|
||||
});
|
||||
}
|
||||
// to be created ones as they don't have server generated id
|
||||
if (!_id) {
|
||||
secretsToBeCreated.push({
|
||||
method: 'POST',
|
||||
secret: {
|
||||
type: 'shared',
|
||||
tags,
|
||||
secretName: key,
|
||||
...encryptASecret(randomBytes, key, value, comment)
|
||||
}
|
||||
});
|
||||
return; // exit as updated and delete case won't happen when created
|
||||
}
|
||||
// has an id means this is updated one
|
||||
if (_id) {
|
||||
// check value has changed or not
|
||||
const initialSecretValue = intialValues?.find(({ _id: secId }) => secId === _id)!;
|
||||
if (!deepCompareSecrets(initialSecretValue, secret)) {
|
||||
secretsToBeUpdated.push({
|
||||
method: 'PATCH',
|
||||
secret: {
|
||||
_id,
|
||||
type: 'shared',
|
||||
tags,
|
||||
secretName: key,
|
||||
...encryptASecret(randomBytes, key, value, comment)
|
||||
}
|
||||
});
|
||||
}
|
||||
if (idOverride) {
|
||||
// if action is deleted meaning override has been removed but id is kept to collect at this point
|
||||
if (overrideAction === SecretActionType.Deleted) {
|
||||
secretsToBeDeleted.push({ method: 'DELETE', secret: { _id: idOverride } });
|
||||
} else {
|
||||
// if not deleted action then as id is there its an updated
|
||||
}
|
||||
if (idOverride) {
|
||||
// if action is deleted meaning override has been removed but id is kept to collect at this point
|
||||
if (overrideAction === SecretActionType.Deleted) {
|
||||
secretsToBeDeleted.push({ method: 'DELETE', secret: { _id: idOverride } });
|
||||
} else {
|
||||
// if not deleted action then as id is there its an updated
|
||||
const initialSecretValue = intialValues?.find(({ _id: secId }) => secId === _id)!;
|
||||
if (!deepCompareSecrets(initialSecretValue, secret)) {
|
||||
secretsToBeUpdated.push({
|
||||
method: 'PATCH',
|
||||
secret: {
|
||||
_id: idOverride,
|
||||
type: 'personal',
|
||||
tags,
|
||||
secretName: key,
|
||||
...encryptASecret(randomBytes, key, valueOverride, comment)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return secretsToBeCreated.concat(secretsToBeUpdated, secretsToBeDeleted);
|
||||
};
|
||||
|
@@ -1,12 +1,8 @@
|
||||
/* eslint-disable react/jsx-no-useless-fragment */
|
||||
import { SyntheticEvent, useRef, useState } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { faCircle, faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import guidGenerator from '@app/components/utilities/randomId';
|
||||
|
||||
import { FormData } from '../../DashboardPage.utils';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
type Props = {
|
||||
index: number;
|
||||
@@ -19,84 +15,97 @@ type Props = {
|
||||
|
||||
const REGEX = /([$]{.*?})/g;
|
||||
|
||||
const DashboardInput = ({ isOverridden, isSecretValueHidden, isReadOnly, secret, index }: { isOverridden: boolean, isSecretValueHidden: boolean, isReadOnly?: boolean, secret: any, index: number } ): JSX.Element => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const syncScroll = (e: SyntheticEvent<HTMLDivElement>) => {
|
||||
if (ref.current === null) return;
|
||||
|
||||
ref.current.scrollTop = e.currentTarget.scrollTop;
|
||||
ref.current.scrollLeft = e.currentTarget.scrollLeft;
|
||||
};
|
||||
|
||||
return <td key={`row-${secret?.key || ''}--`} className={`flex cursor-default flex-row w-full justify-center h-10 items-center ${!(secret?.value || secret?.value === '') ? "bg-red-400/10" : "bg-mineshaft-900/30"}`}>
|
||||
<div className="group relative whitespace-pre flex flex-col justify-center w-full cursor-default">
|
||||
<input
|
||||
// {...register(`secrets.${index}.valueOverride`)}
|
||||
defaultValue={(isOverridden ? secret.valueOverride : secret?.value || '')}
|
||||
onScroll={syncScroll}
|
||||
readOnly={isReadOnly}
|
||||
className={`${
|
||||
isSecretValueHidden
|
||||
? 'text-transparent focus:text-transparent active:text-transparent'
|
||||
: ''
|
||||
} z-10 peer cursor-default font-mono ph-no-capture bg-transparent caret-transparent text-transparent text-sm px-2 py-2 w-full outline-none duration-200 no-scrollbar no-scrollbar::-webkit-scrollbar`}
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${
|
||||
isSecretValueHidden && !isOverridden && secret?.value
|
||||
? 'text-bunker-800 group-hover:text-gray-400 peer-focus:text-gray-100 peer-active:text-gray-400 duration-200'
|
||||
: ''
|
||||
} ${!secret?.value && "text-bunker-400 justify-center"}
|
||||
absolute cursor-default flex flex-row whitespace-pre font-mono z-0 ${isSecretValueHidden && secret?.value ? 'invisible' : 'visible'} peer-focus:visible mt-0.5 ph-no-capture overflow-x-scroll bg-transparent h-10 text-sm px-2 py-2 w-full min-w-16 outline-none duration-100 no-scrollbar no-scrollbar::-webkit-scrollbar`}
|
||||
>
|
||||
{(secret?.value || secret?.value === '') && (isOverridden ? secret.valueOverride : secret?.value)?.split('').length === 0 && <span className='text-bunker-400/80 font-sans w-full'>EMPTY</span>}
|
||||
{(secret?.value || secret?.value === '') && (isOverridden ? secret.valueOverride : secret?.value)?.split(REGEX).map((word: string) => {
|
||||
if (word.match(REGEX) !== null) {
|
||||
return (
|
||||
<span className="ph-no-capture text-yellow" key={word}>
|
||||
{word.slice(0, 2)}
|
||||
<span className="ph-no-capture text-yellow-200/80">
|
||||
{word.slice(2, word.length - 1)}
|
||||
</span>
|
||||
{word.slice(word.length - 1, word.length) === '}' ? (
|
||||
<span className="ph-no-capture text-yellow">
|
||||
{word.slice(word.length - 1, word.length)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="ph-no-capture text-yellow-400">
|
||||
{word.slice(word.length - 1, word.length)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span key={`${word}_${index + 1}`} className="ph-no-capture">
|
||||
{word}
|
||||
const DashboardInput = ({
|
||||
isOverridden,
|
||||
isSecretValueHidden,
|
||||
secret,
|
||||
isReadOnly = true
|
||||
}: {
|
||||
isOverridden: boolean;
|
||||
isSecretValueHidden: boolean;
|
||||
isReadOnly?: boolean;
|
||||
secret?: any;
|
||||
}): JSX.Element => {
|
||||
const syntaxHighlight = useCallback((val: string) => {
|
||||
if (val === undefined)
|
||||
return (
|
||||
<span className="cursor-default font-sans text-xs italic text-red-500/80">missing</span>
|
||||
);
|
||||
if (val?.length === 0)
|
||||
return <span className="w-full font-sans text-bunker-400/80">EMPTY</span>;
|
||||
return val?.split(REGEX).map((word, index) =>
|
||||
word.match(REGEX) !== null ? (
|
||||
<span className="ph-no-capture text-yellow" key={`${val}-${index + 1}`}>
|
||||
{word.slice(0, 2)}
|
||||
<span className="ph-no-capture text-yellow-200/80">{word.slice(2, word.length - 1)}</span>
|
||||
{word.slice(word.length - 1, word.length) === '}' ? (
|
||||
<span className="ph-no-capture text-yellow">
|
||||
{word.slice(word.length - 1, word.length)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{!(secret?.value || secret?.value === '') && <span className='text-red-500/80 cursor-default font-sans text-xs italic'>missing</span>}
|
||||
</div>
|
||||
{(isSecretValueHidden && secret?.value) && (
|
||||
<div className='absolute flex flex-row justify-between items-center z-0 peer pr-2 peer-active:hidden peer-focus:hidden group-hover:bg-white/[0.00] duration-100 h-10 w-full text-bunker-400 text-clip'>
|
||||
<div className="px-2 flex flex-row items-center overflow-x-scroll no-scrollbar no-scrollbar::-webkit-scrollbar">
|
||||
{(isOverridden ? secret.valueOverride : secret?.value || '')?.split('').map(() => (
|
||||
<FontAwesomeIcon
|
||||
key={guidGenerator()}
|
||||
className="text-xxs mr-0.5"
|
||||
icon={faCircle}
|
||||
/>
|
||||
))}
|
||||
{(isOverridden ? secret.valueOverride : secret?.value || '')?.split('').length === 0 && <span className='text-bunker-400/80 text-sm'>EMPTY</span>}
|
||||
</div>
|
||||
) : (
|
||||
<span className="ph-no-capture text-yellow-400">
|
||||
{word.slice(word.length - 1, word.length)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span key={word} className="ph-no-capture">
|
||||
{word}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<td
|
||||
key={`row-${secret?.key || ''}--`}
|
||||
className={`flex h-10 w-full cursor-default flex-row items-center justify-center ${
|
||||
!(secret?.value || secret?.value === '') ? 'bg-red-800/10' : 'bg-mineshaft-900/30'
|
||||
}`}
|
||||
>
|
||||
<div className="group relative flex w-full cursor-default flex-col justify-center whitespace-pre">
|
||||
<input
|
||||
value={isOverridden ? secret.valueOverride : secret?.value || ''}
|
||||
readOnly={isReadOnly}
|
||||
className={twMerge(
|
||||
'ph-no-capture no-scrollbar::-webkit-scrollbar duration-50 peer z-10 w-full cursor-default bg-transparent px-2 py-2 font-mono text-sm text-transparent caret-transparent outline-none no-scrollbar',
|
||||
isSecretValueHidden && 'text-transparent focus:text-transparent active:text-transparent'
|
||||
)}
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div
|
||||
className={twMerge(
|
||||
'ph-no-capture min-w-16 no-scrollbar::-webkit-scrollbar duration-50 absolute z-0 mt-0.5 flex h-10 w-full cursor-default flex-row overflow-x-scroll whitespace-pre bg-transparent px-2 py-2 font-mono text-sm outline-none no-scrollbar peer-focus:visible',
|
||||
isSecretValueHidden && secret?.value ? 'invisible' : 'visible',
|
||||
isSecretValueHidden &&
|
||||
secret?.value &&
|
||||
'duration-50 text-bunker-800 group-hover:text-gray-400 peer-focus:text-gray-100 peer-active:text-gray-400',
|
||||
!secret?.value && 'justify-center text-bunker-400'
|
||||
)}
|
||||
>
|
||||
{syntaxHighlight(secret?.value)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
}
|
||||
{isSecretValueHidden && secret?.value && (
|
||||
<div className="duration-50 peer absolute z-0 flex h-10 w-full flex-row items-center justify-between text-clip pr-2 text-bunker-400 group-hover:bg-white/[0.00] peer-focus:hidden peer-active:hidden">
|
||||
<div className="no-scrollbar::-webkit-scrollbar flex flex-row items-center overflow-x-scroll px-2 no-scrollbar">
|
||||
{(isOverridden ? secret.valueOverride : secret?.value || '')
|
||||
?.split('')
|
||||
.map((_a: string, index: number) => (
|
||||
<FontAwesomeIcon
|
||||
key={`${secret?.value}_${index + 1}`}
|
||||
className="mr-0.5 text-xxs"
|
||||
icon={faCircle}
|
||||
/>
|
||||
))}
|
||||
{(isOverridden ? secret.valueOverride : secret?.value || '')?.split('').length ===
|
||||
0 && <span className="text-sm text-bunker-400/80">EMPTY</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
};
|
||||
|
||||
export const EnvComparisonRow = ({
|
||||
index,
|
||||
@@ -105,27 +114,39 @@ export const EnvComparisonRow = ({
|
||||
isReadOnly,
|
||||
userAvailableEnvs
|
||||
}: Props): JSX.Element => {
|
||||
const {
|
||||
// register, setValue,
|
||||
control } = useFormContext<FormData>();
|
||||
|
||||
// to get details on a secret
|
||||
const secret = useWatch({ name: `secrets.${index}`, control });
|
||||
|
||||
const [areValuesHiddenThisRow, setAreValuesHiddenThisRow] = useState(true);
|
||||
|
||||
const getSecretByEnv = useCallback(
|
||||
(secEnv: string, secs?: any[]) => secs?.find(({ env }) => env === secEnv),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<tr className="group min-w-full flex flex-row items-center hover:bg-bunker-700">
|
||||
<td className="w-10 h-10 px-4 flex items-center justify-center border-none"><div className='text-center w-10 text-xs text-bunker-400'>{index + 1}</div></td>
|
||||
<td className="flex flex-row justify-between items-center h-full min-w-[200px] lg:min-w-[220px] xl:min-w-[250px]">
|
||||
<div className="flex truncate flex-row items-center h-8 cursor-default">{secrets![0].key || ''}</div>
|
||||
<button type="button" className='mr-1 ml-2 text-bunker-400 hover:text-bunker-300 invisible group-hover:visible' onClick={() => setAreValuesHiddenThisRow(!areValuesHiddenThisRow)}>
|
||||
<tr className="group flex min-w-full flex-row items-center hover:bg-mineshaft-800">
|
||||
<td className="flex h-10 w-10 items-center justify-center border-none px-4">
|
||||
<div className="w-10 text-center text-xs text-bunker-400">{index + 1}</div>
|
||||
</td>
|
||||
<td className="flex h-full min-w-[200px] flex-row items-center justify-between lg:min-w-[220px] xl:min-w-[250px]">
|
||||
<div className="flex h-8 cursor-default flex-row items-center truncate">
|
||||
{secrets![0].key || ''}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="invisible mr-1 ml-2 text-bunker-400 hover:text-bunker-300 group-hover:visible"
|
||||
onClick={() => setAreValuesHiddenThisRow(!areValuesHiddenThisRow)}
|
||||
>
|
||||
<FontAwesomeIcon icon={areValuesHiddenThisRow ? faEye : faEyeSlash} />
|
||||
</button>
|
||||
</td>
|
||||
{userAvailableEnvs?.map(env => {
|
||||
return <DashboardInput key={`row-${secret?.key || ''}-${env.slug}`} isOverridden={false} isSecretValueHidden={areValuesHiddenThisRow && isSecretValueHidden} isReadOnly={isReadOnly} secret={secrets?.filter(sec => sec.env === env.slug)[0]} index={index} />
|
||||
})}
|
||||
{userAvailableEnvs?.map(({ slug }) => (
|
||||
<DashboardInput
|
||||
isReadOnly={isReadOnly}
|
||||
key={`row-${secrets![0].key || ''}-${slug}`}
|
||||
isOverridden={false}
|
||||
secret={getSecretByEnv(slug, secrets)}
|
||||
isSecretValueHidden={areValuesHiddenThisRow && isSecretValueHidden}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { faCircle, faCircleDot, faShuffle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
@@ -44,14 +44,14 @@ export const SecretDetailDrawer = ({
|
||||
const [canRevealSecVal, setCanRevealSecVal] = useToggle();
|
||||
const [canRevealSecOverride, setCanRevealSecOverride] = useToggle();
|
||||
|
||||
const { register, setValue, watch } = useFormContext<FormData>();
|
||||
const secret = watch(`secrets.${index}`);
|
||||
const { register, setValue, control, getValues } = useFormContext<FormData>();
|
||||
|
||||
const overrideAction = useWatch({ control, name: `secrets.${index}.overrideAction` });
|
||||
const isOverridden =
|
||||
secret?.overrideAction === SecretActionType.Created ||
|
||||
secret?.overrideAction === SecretActionType.Modified;
|
||||
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
|
||||
|
||||
const onSecretOverride = () => {
|
||||
const secret = getValues(`secrets.${index}`);
|
||||
if (isOverridden) {
|
||||
// when user created a new override but then removes
|
||||
if (SecretActionType.Created) {
|
||||
@@ -67,21 +67,17 @@ export const SecretDetailDrawer = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (!secret) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer onOpenChange={onOpenChange} isOpen={isDrawerOpen}>
|
||||
<DrawerContent
|
||||
className="border-l border-mineshaft-500 bg-bunker dark"
|
||||
className="dark border-l border-mineshaft-500 bg-bunker"
|
||||
title="Secret"
|
||||
footerContent={
|
||||
<div className="flex flex-col space-y-2 pt-4 shadow-md">
|
||||
<div>
|
||||
<Button
|
||||
variant="star"
|
||||
onClick={() => onEnvCompare(secret?.key)}
|
||||
onClick={() => onEnvCompare(getValues(`secrets.${index}.key`))}
|
||||
isFullWidth
|
||||
isDisabled={isReadOnly}
|
||||
>
|
||||
@@ -95,7 +91,10 @@ export const SecretDetailDrawer = ({
|
||||
<Button
|
||||
colorSchema="danger"
|
||||
isDisabled={isReadOnly}
|
||||
onClick={() => onSecretDelete(index, secret._id, secret.idOverride)}
|
||||
onClick={() => {
|
||||
const secret = getValues(`secrets.${index}`);
|
||||
onSecretDelete(index, secret._id, secret.idOverride);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
@@ -173,9 +172,9 @@ export const SecretDetailDrawer = ({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormControl>
|
||||
<div className="mb-4 text-sm text-bunker-300 dark">
|
||||
<div className="dark mb-4 text-sm text-bunker-300">
|
||||
<div className="mb-2">Version History</div>
|
||||
<div className="flex h-48 flex-col space-y-2 border border-mineshaft-600 overflow-y-auto overflow-x-hidden rounded-md bg-bunker-800 p-2 dark:[color-scheme:dark]">
|
||||
<div className="flex h-48 flex-col space-y-2 overflow-y-auto overflow-x-hidden rounded-md border border-mineshaft-600 bg-bunker-800 p-2 dark:[color-scheme:dark]">
|
||||
{secretVersion?.map(({ createdAt, value, id }, i) => (
|
||||
<div key={id} className="flex flex-col space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -202,7 +201,12 @@ export const SecretDetailDrawer = ({
|
||||
</div>
|
||||
</div>
|
||||
<FormControl label="Comments & Notes">
|
||||
<TextArea className="border border-mineshaft-600 text-sm" isDisabled={isReadOnly} {...register(`secrets.${index}.comment`)} rows={5} />
|
||||
<TextArea
|
||||
className="border border-mineshaft-600 text-sm"
|
||||
isDisabled={isReadOnly}
|
||||
{...register(`secrets.${index}.comment`)}
|
||||
rows={5}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
|
@@ -78,53 +78,55 @@ export const SecretDropzone = ({ isSmaller, onParsedEnv, onAddNewSecret }: Props
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={twMerge(
|
||||
'relative mb-4 mt-4 max-w-[calc(100vw-292px)] flex w-full cursor-pointer text-mineshaft-200 items-center py-8 justify-center space-x-2 rounded-md bg-mineshaft-900 px-2 mx-0.5 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100',
|
||||
isDragActive && 'opacity-100',
|
||||
!isSmaller && 'flex-col space-y-4 max-w-3xl py-20',
|
||||
isLoading && 'bg-bunker-800'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="mb-16 flex items-center justify-center pt-16">
|
||||
<img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faUpload} size={isSmaller ? '2x' : '5x'} />
|
||||
<div className="mx-1">
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={twMerge(
|
||||
'relative mb-4 mt-4 max-w-[calc(100vw-292px)] flex w-full cursor-pointer text-mineshaft-200 items-center py-8 justify-center space-x-2 rounded-md bg-mineshaft-900 px-2 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100',
|
||||
isDragActive && 'opacity-100',
|
||||
!isSmaller && 'flex-col space-y-4 max-w-3xl py-20',
|
||||
isLoading && 'bg-bunker-800'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="mb-16 flex items-center justify-center pt-16">
|
||||
<img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="">{t(isSmaller ? 'common:drop-zone-keys' : 'common:drop-zone')}</p>
|
||||
</div>
|
||||
<input
|
||||
id="fileSelect"
|
||||
type="file"
|
||||
className="absolute h-full w-full cursor-pointer opacity-0"
|
||||
accept=".txt,.env,.yml,.yaml"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
{!isSmaller && (
|
||||
<>
|
||||
<div className="flex w-full flex-row items-center justify-center py-4">
|
||||
<div className="w-1/5 border-t border-mineshaft-700" />
|
||||
<p className="mx-4 text-xs text-mineshaft-400">OR</p>
|
||||
<div className="w-1/5 border-t border-mineshaft-700" />
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="star" onClick={onAddNewSecret}>
|
||||
Add a new secret
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}{' '}
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faUpload} size={isSmaller ? '2x' : '5x'} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="">{t(isSmaller ? 'common:drop-zone-keys' : 'common:drop-zone')}</p>
|
||||
</div>
|
||||
<input
|
||||
id="fileSelect"
|
||||
type="file"
|
||||
className="absolute h-full w-full cursor-pointer opacity-0"
|
||||
accept=".txt,.env,.yml,.yaml"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
{!isSmaller && (
|
||||
<>
|
||||
<div className="flex w-full flex-row items-center justify-center py-4">
|
||||
<div className="w-1/5 border-t border-mineshaft-700" />
|
||||
<p className="mx-4 text-xs text-mineshaft-400">OR</p>
|
||||
<div className="w-1/5 border-t border-mineshaft-700" />
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="star" onClick={onAddNewSecret}>
|
||||
Add a new secret
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}{' '}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -0,0 +1,107 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { faCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { FormData } from '../../DashboardPage.utils';
|
||||
|
||||
type Props = {
|
||||
isReadOnly?: boolean;
|
||||
isSecretValueHidden?: boolean;
|
||||
isOverridden?: boolean;
|
||||
index: number;
|
||||
};
|
||||
|
||||
const REGEX = /([$]{.*?})/g;
|
||||
|
||||
export const MaskedInput = ({ isReadOnly, isSecretValueHidden, index, isOverridden }: Props) => {
|
||||
const { register, control } = useFormContext<FormData>();
|
||||
|
||||
const secretValue = useWatch({ control, name: `secrets.${index}.value` });
|
||||
const secretValueOverride = useWatch({ control, name: `secrets.${index}.valueOverride` });
|
||||
const value = isOverridden ? secretValueOverride : secretValue;
|
||||
|
||||
const syntaxHighlight = useCallback((val: string) => {
|
||||
if (val?.length === 0) return <span className="font-sans text-bunker-400/80">EMPTY</span>;
|
||||
return val?.split(REGEX).map((word) =>
|
||||
word.match(REGEX) !== null ? (
|
||||
<span className="ph-no-capture text-yellow" key={`${val}-${index + 1}`}>
|
||||
{word.slice(0, 2)}
|
||||
<span className="ph-no-capture text-yellow-200/80">{word.slice(2, word.length - 1)}</span>
|
||||
{word.slice(word.length - 1, word.length) === '}' ? (
|
||||
<span className="ph-no-capture text-yellow">
|
||||
{word.slice(word.length - 1, word.length)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="ph-no-capture text-yellow-400">
|
||||
{word.slice(word.length - 1, word.length)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span key={`${word}_${index + 1}`} className="ph-no-capture">
|
||||
{word}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="group relative flex w-full flex-col justify-center whitespace-pre px-1.5">
|
||||
{isOverridden ? (
|
||||
<input
|
||||
{...register(`secrets.${index}.valueOverride`)}
|
||||
readOnly={isReadOnly}
|
||||
className={twMerge(
|
||||
'ph-no-capture min-w-16 no-scrollbar::-webkit-scrollbar duration-50 peer z-10 w-full bg-transparent px-2 py-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar',
|
||||
!isSecretValueHidden &&
|
||||
'text-transparent focus:text-transparent active:text-transparent'
|
||||
)}
|
||||
spellCheck="false"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
{...register(`secrets.${index}.value`)}
|
||||
readOnly={isReadOnly}
|
||||
className={twMerge(
|
||||
'ph-no-capture min-w-16 no-scrollbar::-webkit-scrollbar duration-50 peer z-10 w-full bg-transparent px-2 py-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar',
|
||||
!isSecretValueHidden &&
|
||||
'text-transparent focus:text-transparent active:text-transparent'
|
||||
)}
|
||||
spellCheck="false"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
'ph-no-capture min-w-16 no-scrollbar::-webkit-scrollbar duration-50 absolute z-0 mt-0.5 flex h-10 w-full flex-row overflow-x-scroll whitespace-pre bg-transparent px-2 py-2 font-mono text-sm outline-none no-scrollbar peer-focus:visible',
|
||||
isSecretValueHidden ? 'invisible' : 'visible',
|
||||
isOverridden
|
||||
? 'text-primary-300'
|
||||
: 'duration-50 text-gray-400 group-hover:text-gray-400 peer-focus:text-gray-100 peer-active:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{syntaxHighlight(value || '')}
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
'duration-50 peer absolute z-0 flex h-10 w-full flex-row items-center justify-between text-clip pr-2 text-bunker-400 group-hover:bg-white/[0.00] peer-focus:hidden peer-active:hidden',
|
||||
!isSecretValueHidden ? 'invisible' : 'visible'
|
||||
)}
|
||||
>
|
||||
<div className="no-scrollbar::-webkit-scrollbar flex flex-row items-center overflow-x-scroll px-2 no-scrollbar">
|
||||
{value?.split('').map((val, i) => (
|
||||
<FontAwesomeIcon
|
||||
key={`${value}_${val}_${i + 1}`}
|
||||
className="mr-0.5 text-xxs"
|
||||
icon={faCircle}
|
||||
/>
|
||||
))}
|
||||
{value?.split('').length === 0 && (
|
||||
<span className="text-sm text-bunker-400/80">EMPTY</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable react/jsx-no-useless-fragment */
|
||||
import { SyntheticEvent, useRef } from 'react';
|
||||
import { memo, useRef } from 'react';
|
||||
import { Controller, useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
import {
|
||||
faCircle,
|
||||
faCodeBranch,
|
||||
faComment,
|
||||
faEllipsis,
|
||||
faInfoCircle,
|
||||
faKey,
|
||||
faPlus,
|
||||
faTags,
|
||||
faXmark
|
||||
@@ -15,7 +15,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { cx } from 'cva';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import guidGenerator from '@app/components/utilities/randomId';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -32,10 +31,10 @@ import {
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from '@app/components/v2';
|
||||
import { useToggle } from '@app/hooks';
|
||||
import { WsTag } from '@app/hooks/api/types';
|
||||
|
||||
import { FormData, SecretActionType } from '../../DashboardPage.utils';
|
||||
import { MaskedInput } from './MaskedInput';
|
||||
|
||||
type Props = {
|
||||
index: number;
|
||||
@@ -64,361 +63,327 @@ const tagColors = [
|
||||
{ bg: 'bg-[#C40B13]/40', text: 'text-[#FFDEDE]/70' },
|
||||
{ bg: 'bg-[#332FD0]/40', text: 'text-[#DFF6FF]/70' }
|
||||
];
|
||||
const REGEX = /([$]{.*?})/g;
|
||||
|
||||
export const SecretInputRow = ({
|
||||
index,
|
||||
isSecretValueHidden,
|
||||
onRowExpand,
|
||||
isReadOnly,
|
||||
isRollbackMode,
|
||||
isAddOnly,
|
||||
wsTags,
|
||||
onCreateTagOpen,
|
||||
onSecretDelete,
|
||||
searchTerm
|
||||
}: Props): JSX.Element => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const syncScroll = (e: SyntheticEvent<HTMLDivElement>) => {
|
||||
if (ref.current === null) return;
|
||||
export const SecretInputRow = memo(
|
||||
({
|
||||
index,
|
||||
isSecretValueHidden,
|
||||
onRowExpand,
|
||||
isReadOnly,
|
||||
isRollbackMode,
|
||||
isAddOnly,
|
||||
wsTags,
|
||||
onCreateTagOpen,
|
||||
onSecretDelete,
|
||||
searchTerm
|
||||
}: Props): JSX.Element => {
|
||||
const isKeySubDisabled = useRef<boolean>(false);
|
||||
const { register, setValue, control } = useFormContext<FormData>();
|
||||
// comment management in a row
|
||||
const {
|
||||
fields: secretTags,
|
||||
remove,
|
||||
append
|
||||
} = useFieldArray({ control, name: `secrets.${index}.tags` });
|
||||
|
||||
ref.current.scrollTop = e.currentTarget.scrollTop;
|
||||
ref.current.scrollLeft = e.currentTarget.scrollLeft;
|
||||
};
|
||||
const { register, setValue, control } = useFormContext<FormData>();
|
||||
const [canRevealSecret] = useToggle();
|
||||
// comment management in a row
|
||||
const {
|
||||
fields: secretTags,
|
||||
remove,
|
||||
append
|
||||
} = useFieldArray({ control, name: `secrets.${index}.tags` });
|
||||
// to get details on a secret
|
||||
const overrideAction = useWatch({ control, name: `secrets.${index}.overrideAction` });
|
||||
const idOverride = useWatch({ control, name: `secrets.${index}.idOverride` });
|
||||
const secComment = useWatch({ control, name: `secrets.${index}.comment` });
|
||||
const hasComment = Boolean(secComment);
|
||||
const secKey = useWatch({
|
||||
control,
|
||||
name: `secrets.${index}.key`,
|
||||
disabled: isKeySubDisabled.current
|
||||
});
|
||||
const secId = useWatch({ control, name: `secrets.${index}._id` });
|
||||
|
||||
// to get details on a secret
|
||||
const secret = useWatch({ name: `secrets.${index}`, control });
|
||||
const hasComment = Boolean(secret.comment);
|
||||
const tags = secret.tags || [];
|
||||
const selectedTagIds = tags.reduce<Record<string, boolean>>(
|
||||
(prev, curr) => ({ ...prev, [curr.slug]: true }),
|
||||
{}
|
||||
);
|
||||
const tags = useWatch({ control, name: `secrets.${index}.tags`, defaultValue: [] }) || [];
|
||||
const selectedTagIds = tags.reduce<Record<string, boolean>>(
|
||||
(prev, curr) => ({ ...prev, [curr.slug]: true }),
|
||||
{}
|
||||
);
|
||||
|
||||
// when secret is override by personal values
|
||||
const isOverridden =
|
||||
secret.overrideAction === SecretActionType.Created ||
|
||||
secret.overrideAction === SecretActionType.Modified;
|
||||
// when secret is override by personal values
|
||||
const isOverridden =
|
||||
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
|
||||
|
||||
const onSecretOverride = () => {
|
||||
if (isOverridden) {
|
||||
// when user created a new override but then removes
|
||||
if (secret?.overrideAction === SecretActionType.Created)
|
||||
const onSecretOverride = () => {
|
||||
if (isOverridden) {
|
||||
// when user created a new override but then removes
|
||||
if (overrideAction === SecretActionType.Created)
|
||||
setValue(`secrets.${index}.valueOverride`, '');
|
||||
setValue(`secrets.${index}.overrideAction`, SecretActionType.Deleted, {
|
||||
shouldDirty: true
|
||||
});
|
||||
} else {
|
||||
setValue(`secrets.${index}.valueOverride`, '');
|
||||
setValue(`secrets.${index}.overrideAction`, SecretActionType.Deleted, { shouldDirty: true });
|
||||
} else {
|
||||
setValue(`secrets.${index}.valueOverride`, '');
|
||||
setValue(
|
||||
`secrets.${index}.overrideAction`,
|
||||
secret?.idOverride ? SecretActionType.Modified : SecretActionType.Created,
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
setValue(
|
||||
`secrets.${index}.overrideAction`,
|
||||
idOverride ? SecretActionType.Modified : SecretActionType.Created,
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectTag = (selectedTag: WsTag) => {
|
||||
const shouldAppend = !selectedTagIds[selectedTag.slug];
|
||||
if (shouldAppend) {
|
||||
append(selectedTag);
|
||||
} else {
|
||||
const pos = tags.findIndex(({ slug }) => selectedTag.slug === slug);
|
||||
remove(pos);
|
||||
}
|
||||
};
|
||||
|
||||
const isCreatedSecret = !secId;
|
||||
const shouldBeBlockedInAddOnly = !isCreatedSecret && isAddOnly;
|
||||
|
||||
// Why this instead of filter in parent
|
||||
// Because rhf field.map has default values so basically
|
||||
// keys are not updated there and index needs to kept so that we can monitor
|
||||
// values individually here
|
||||
if (
|
||||
!(
|
||||
secKey?.toUpperCase().includes(searchTerm?.toUpperCase()) ||
|
||||
tags
|
||||
?.map((tag) => tag.name)
|
||||
.join(' ')
|
||||
?.toUpperCase()
|
||||
.includes(searchTerm?.toUpperCase()) ||
|
||||
secComment?.toUpperCase().includes(searchTerm?.toUpperCase())
|
||||
)
|
||||
) {
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectTag = (selectedTag: WsTag) => {
|
||||
const shouldAppend = !selectedTagIds[selectedTag.slug];
|
||||
if (shouldAppend) {
|
||||
append(selectedTag);
|
||||
} else {
|
||||
const pos = tags.findIndex(({ slug }) => selectedTag.slug === slug);
|
||||
remove(pos);
|
||||
}
|
||||
};
|
||||
|
||||
const isCreatedSecret = !secret?._id;
|
||||
const shouldBeBlockedInAddOnly = !isCreatedSecret && isAddOnly;
|
||||
|
||||
// Why this instead of filter in parent
|
||||
// Because rhf field.map has default values so basically
|
||||
// keys are not updated there and index needs to kept so that we can monitor
|
||||
// values individually here
|
||||
if (
|
||||
!(
|
||||
secret.key?.toUpperCase().includes(searchTerm?.toUpperCase()) ||
|
||||
tags
|
||||
?.map((tag) => tag.name)
|
||||
.join(' ')
|
||||
?.toUpperCase()
|
||||
.includes(searchTerm?.toUpperCase()) ||
|
||||
secret.comment?.toUpperCase().includes(searchTerm?.toUpperCase())
|
||||
)
|
||||
) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="group min-w-full flex flex-row items-center" key={index}>
|
||||
<td className="w-10 h-10 px-4 flex items-center justify-center"><div className='text-center w-10 text-xs text-bunker-400'>{index + 1}</div></td>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name={`secrets.${index}.key`}
|
||||
render={({ fieldState: { error }, field }) => (
|
||||
<HoverCard openDelay={0} open={error?.message ? undefined : false}>
|
||||
<HoverCardTrigger asChild>
|
||||
<td className={cx(error?.message ? 'rounded ring ring-red/50' : null)}>
|
||||
<div className="min-w-[220px] lg:min-w-[240px] xl:min-w-[280px] relative flex items-center justify-end w-full">
|
||||
<Input
|
||||
autoComplete="off"
|
||||
variant="plain"
|
||||
isDisabled={isReadOnly || shouldBeBlockedInAddOnly || isRollbackMode}
|
||||
className="w-full focus:text-bunker-100 focus:ring-transparent"
|
||||
{...field}
|
||||
/>
|
||||
<div className="w-max flex flex-row items-center justify-end">
|
||||
<Tooltip content="Comment">
|
||||
<div className={`${hasComment ? "w-5" : "w-0"} overflow-hidden group-hover:w-5 mt-0.5`}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<IconButton
|
||||
className={twMerge(
|
||||
'w-0 overflow-hidden p-0 group-hover:w-5',
|
||||
hasComment && 'w-5 text-primary'
|
||||
)}
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="add-tag"
|
||||
>
|
||||
<FontAwesomeIcon icon={faComment} />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto bg-mineshaft-800 border border-mineshaft-600 drop-shadow-2xl p-2">
|
||||
<FormControl label="Comment" className="mb-0">
|
||||
<TextArea
|
||||
isDisabled={isReadOnly || isRollbackMode || shouldBeBlockedInAddOnly}
|
||||
className="border border-mineshaft-600 text-sm"
|
||||
{...register(`secrets.${index}.comment`)}
|
||||
rows={8}
|
||||
cols={30}
|
||||
/>
|
||||
</FormControl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{!isAddOnly && (
|
||||
<div>
|
||||
<Tooltip content="Override with a personal value">
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={twMerge(
|
||||
'w-0 overflow-hidden p-0 group-hover:w-6 group-hover:ml-1 mt-0.5',
|
||||
isOverridden && 'w-6 text-primary ml-1'
|
||||
)}
|
||||
onClick={onSecretOverride}
|
||||
size="md"
|
||||
isDisabled={isRollbackMode || isReadOnly}
|
||||
ariaLabel="info"
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="text-base" />
|
||||
</div>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<tr className="group flex flex-row items-center" key={index}>
|
||||
<td className="flex h-10 w-10 items-center justify-center px-4 border-none">
|
||||
{/* <div className="w-10 text-center text-xs text-bunker-400">{index + 1}</div> */}
|
||||
<div className="w-10 text-center text-xs text-bunker-400"><FontAwesomeIcon icon={faKey} className="w-4 h-4 text-bunker-400/60 pl-2.5 pt-0.5" /></div>
|
||||
</td>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name={`secrets.${index}.key`}
|
||||
render={({ fieldState: { error }, field }) => (
|
||||
<HoverCard openDelay={0} open={error?.message ? undefined : false}>
|
||||
<HoverCardTrigger asChild>
|
||||
<td className={cx(error?.message ? 'rounded ring ring-red/50' : null)}>
|
||||
<div className="relative flex w-full min-w-[220px] items-center justify-end lg:min-w-[240px] xl:min-w-[280px]">
|
||||
<Input
|
||||
autoComplete="off"
|
||||
onFocus={() => {
|
||||
isKeySubDisabled.current = true;
|
||||
}}
|
||||
variant="plain"
|
||||
isDisabled={isReadOnly || shouldBeBlockedInAddOnly || isRollbackMode}
|
||||
className="w-full focus:text-bunker-100 focus:ring-transparent"
|
||||
{...field}
|
||||
onBlur={() => {
|
||||
isKeySubDisabled.current = false;
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-auto py-2 pt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="text-red" />
|
||||
</div>
|
||||
<div className="text-sm">{error?.message}</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
/>
|
||||
<td className="flex flex-row w-full justify-center h-8 items-center">
|
||||
<div className="group relative whitespace-pre flex flex-col justify-center w-full px-1.5">
|
||||
{isOverridden
|
||||
? <input
|
||||
{...register(`secrets.${index}.valueOverride`)}
|
||||
onScroll={syncScroll}
|
||||
readOnly={isReadOnly || isRollbackMode || (isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)}
|
||||
className={`${
|
||||
(!canRevealSecret && isSecretValueHidden)
|
||||
? 'text-transparent focus:text-transparent active:text-transparent'
|
||||
: ''
|
||||
} z-10 peer font-mono ph-no-capture bg-transparent caret-white text-transparent text-sm px-2 py-2 w-full min-w-16 outline-none duration-200 no-scrollbar no-scrollbar::-webkit-scrollbar`}
|
||||
spellCheck="false"
|
||||
/>
|
||||
: <input
|
||||
{...register(`secrets.${index}.value`)}
|
||||
onScroll={syncScroll}
|
||||
readOnly={isReadOnly || isRollbackMode || (isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)}
|
||||
className={`${
|
||||
(!canRevealSecret && isSecretValueHidden)
|
||||
? 'text-transparent focus:text-transparent active:text-transparent'
|
||||
: ''
|
||||
} z-10 peer font-mono ph-no-capture bg-transparent caret-white text-transparent text-sm px-2 py-2 w-full min-w-16 outline-none duration-200 no-scrollbar no-scrollbar::-webkit-scrollbar`}
|
||||
spellCheck="false"
|
||||
/>}
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${
|
||||
(!canRevealSecret && isSecretValueHidden) && !isOverridden
|
||||
? 'text-bunker-800 group-hover:text-gray-400 peer-focus:text-gray-100 peer-active:text-gray-400 duration-200'
|
||||
: ''
|
||||
} ${isOverridden ? 'text-primary-300' : 'text-gray-400'}
|
||||
absolute flex flex-row whitespace-pre font-mono z-0 ${(!canRevealSecret && isSecretValueHidden) ? 'invisible' : 'visible'} peer-focus:visible mt-0.5 ph-no-capture overflow-x-scroll bg-transparent h-10 text-sm px-2 py-2 w-full min-w-16 outline-none duration-100 no-scrollbar no-scrollbar::-webkit-scrollbar`}
|
||||
>
|
||||
{(isOverridden ? secret.valueOverride : secret.value)?.split('').length === 0 && <span className='text-bunker-400/80 font-sans'>EMPTY</span>}
|
||||
{(isOverridden ? secret.valueOverride : secret.value)?.split(REGEX).map((word) => {
|
||||
if (word.match(REGEX) !== null) {
|
||||
return (
|
||||
<span className="ph-no-capture text-yellow" key={guidGenerator()}>
|
||||
{word.slice(0, 2)}
|
||||
<span className="ph-no-capture text-yellow-200/80">
|
||||
{word.slice(2, word.length - 1)}
|
||||
</span>
|
||||
{word.slice(word.length - 1, word.length) === '}' ? (
|
||||
<span className="ph-no-capture text-yellow">
|
||||
{word.slice(word.length - 1, word.length)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="ph-no-capture text-yellow-400">
|
||||
{word.slice(word.length - 1, word.length)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span key={`${word}_${index + 1}`} className="ph-no-capture">
|
||||
{word}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(!canRevealSecret && isSecretValueHidden) && (
|
||||
<div className='absolute flex flex-row justify-between items-center z-0 peer pr-2 peer-active:hidden peer-focus:hidden group-hover:bg-white/[0.00] duration-100 h-10 w-full text-bunker-400 text-clip'>
|
||||
<div className="px-2 flex flex-row items-center overflow-x-scroll no-scrollbar no-scrollbar::-webkit-scrollbar">
|
||||
{(isOverridden ? secret.valueOverride : secret.value)?.split('').map(() => (
|
||||
<FontAwesomeIcon
|
||||
key={guidGenerator()}
|
||||
className="text-xxs mr-0.5"
|
||||
icon={faCircle}
|
||||
/>
|
||||
))}
|
||||
{(isOverridden ? secret.valueOverride : secret.value)?.split('').length === 0 && <span className='text-bunker-400/80 text-sm'>EMPTY</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="flex items-center min-w-sm h-10">
|
||||
<div className="flex items-center pl-2">
|
||||
{secretTags.map(({ id, slug }, i) => (
|
||||
<Tag
|
||||
className={cx(
|
||||
tagColors[i % tagColors.length].bg,
|
||||
tagColors[i % tagColors.length].text
|
||||
)}
|
||||
isDisabled={isReadOnly || isAddOnly || isRollbackMode}
|
||||
onClose={() => remove(i)}
|
||||
key={id}
|
||||
>
|
||||
{slug}
|
||||
</Tag>
|
||||
))}
|
||||
{!(isReadOnly || isAddOnly || isRollbackMode) && (
|
||||
<div className="w-0 overflow-hidden group-hover:w-8">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
</td>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-auto py-2 pt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<Tooltip content="Add tags">
|
||||
<IconButton variant="star" size="xs" ariaLabel="add-tag" className="py-[0.42rem]">
|
||||
<FontAwesomeIcon icon={faTags} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="text-red" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="left"
|
||||
className="max-h-96 w-auto min-w-[200px] overflow-y-auto overflow-x-hidden p-2 text-bunker-200 bg-mineshaft-800 border border-mineshaft-600"
|
||||
hideCloseBtn
|
||||
>
|
||||
<div className="mb-2 text-sm font-medium text-center text-bunker-200 px-2">Add tags to {secret.key || "this secret"}</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
{wsTags?.map((wsTag) => (
|
||||
<Button
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
'justify-start bg-mineshaft-600 text-bunker-100 hover:bg-mineshaft-500',
|
||||
selectedTagIds?.[wsTag.slug] && 'text-primary'
|
||||
)}
|
||||
onClick={() => onSelectTag(wsTag)}
|
||||
leftIcon={
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-primary mr-0"
|
||||
id="autoCapitalization"
|
||||
isChecked={selectedTagIds?.[wsTag.slug]}
|
||||
onCheckedChange={() => {}}
|
||||
>
|
||||
{}
|
||||
</Checkbox>
|
||||
}
|
||||
key={wsTag._id}
|
||||
>
|
||||
{wsTag.slug}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="star"
|
||||
color="primary"
|
||||
size="sm"
|
||||
className="mt-4 justify-start bg-mineshaft-600 h-7 px-1"
|
||||
onClick={onCreateTagOpen}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add new tag
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="text-sm">{error?.message}</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-0 group-hover:w-14 invisible group-hover:visible duration-0 items-center justify-end space-x-2 overflow-hidden transition-all">
|
||||
{!isAddOnly && (
|
||||
<div>
|
||||
<Tooltip content="Settings">
|
||||
<IconButton size="lg" colorSchema="primary" variant="plain" onClick={onRowExpand} ariaLabel="expand">
|
||||
<FontAwesomeIcon icon={faEllipsis} />
|
||||
/>
|
||||
<td className="flex h-10 border-none w-full flex-grow flex-row items-center justify-center border-r border-red">
|
||||
<MaskedInput
|
||||
isReadOnly={
|
||||
isReadOnly || isRollbackMode || (isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
|
||||
}
|
||||
isOverridden={isOverridden}
|
||||
isSecretValueHidden={isSecretValueHidden}
|
||||
index={index}
|
||||
/>
|
||||
</td>
|
||||
<td className="min-w-sm flex h-10 items-center">
|
||||
<div className="flex items-center pl-2">
|
||||
{secretTags.map(({ id, slug }, i) => (
|
||||
<Tag
|
||||
className={cx(
|
||||
tagColors[i % tagColors.length].bg,
|
||||
tagColors[i % tagColors.length].text
|
||||
)}
|
||||
isDisabled={isReadOnly || isAddOnly || isRollbackMode}
|
||||
onClose={() => remove(i)}
|
||||
key={id}
|
||||
>
|
||||
{slug}
|
||||
</Tag>
|
||||
))}
|
||||
{!(isReadOnly || isAddOnly || isRollbackMode) && (
|
||||
<div className="overflow-hidden duration-0 ml-1">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="w-0 data-[state=open]:w-6 group-hover:w-6">
|
||||
<Tooltip content="Add tags">
|
||||
<IconButton
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="add-tag"
|
||||
className="py-[0.42rem]"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTags} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="left"
|
||||
className="max-h-96 w-auto min-w-[200px] overflow-y-auto overflow-x-hidden border border-mineshaft-600 bg-mineshaft-800 p-2 text-bunker-200"
|
||||
hideCloseBtn
|
||||
>
|
||||
<div className="mb-2 px-2 text-center text-sm font-medium text-bunker-200">
|
||||
Add tags to {secKey || 'this secret'}
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
{wsTags?.map((wsTag) => (
|
||||
<Button
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
'justify-start bg-mineshaft-600 text-bunker-100 hover:bg-mineshaft-500',
|
||||
selectedTagIds?.[wsTag.slug] && 'text-primary'
|
||||
)}
|
||||
onClick={() => onSelectTag(wsTag)}
|
||||
leftIcon={
|
||||
<Checkbox
|
||||
className="mr-0 data-[state=checked]:bg-primary"
|
||||
id="autoCapitalization"
|
||||
isChecked={selectedTagIds?.[wsTag.slug]}
|
||||
onCheckedChange={() => {}}
|
||||
>
|
||||
{}
|
||||
</Checkbox>
|
||||
}
|
||||
key={wsTag._id}
|
||||
>
|
||||
{wsTag.slug}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="star"
|
||||
color="primary"
|
||||
size="sm"
|
||||
className="mt-4 h-7 justify-start bg-mineshaft-600 px-1"
|
||||
onClick={onCreateTagOpen}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add new tag
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row items-center h-full pr-2">
|
||||
{!isAddOnly && (
|
||||
<div>
|
||||
<Tooltip content="Override with a personal value">
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={twMerge(
|
||||
'mt-0.5 w-0 overflow-hidden p-0 group-hover:ml-1 group-hover:w-7',
|
||||
isOverridden && 'ml-1 w-7 text-primary'
|
||||
)}
|
||||
onClick={onSecretOverride}
|
||||
size="md"
|
||||
isDisabled={isRollbackMode || isReadOnly}
|
||||
ariaLabel="info"
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="text-base" />
|
||||
</div>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<Tooltip content="Comment">
|
||||
<div
|
||||
className={`mt-0.5 overflow-hidden `}
|
||||
>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<IconButton
|
||||
className={twMerge(
|
||||
'overflow-hidden p-0 w-7',
|
||||
'data-[state=open]:w-7 group-hover:w-7 w-0',
|
||||
hasComment ? 'text-primary w-7' : 'group-hover:w-7'
|
||||
)}
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="add-tag"
|
||||
>
|
||||
<FontAwesomeIcon icon={faComment} />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl" sticky="always">
|
||||
<FormControl label="Comment" className="mb-0">
|
||||
<TextArea
|
||||
isDisabled={
|
||||
isReadOnly || isRollbackMode || shouldBeBlockedInAddOnly
|
||||
}
|
||||
className="border border-mineshaft-600 text-sm"
|
||||
{...register(`secrets.${index}.comment`)}
|
||||
rows={8}
|
||||
cols={30}
|
||||
/>
|
||||
</FormControl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="duration-0 w-0 flex items-center justify-end space-x-2.5 overflow-hidden transition-all w-16 border-l border-mineshaft-600 h-10">
|
||||
{!isAddOnly && (
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Settings">
|
||||
<IconButton
|
||||
size="lg"
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
onClick={onRowExpand}
|
||||
ariaLabel="expand"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsis} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Delete">
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete"
|
||||
isDisabled={isReadOnly || isRollbackMode}
|
||||
onClick={() => onSecretDelete(index, secId, idOverride)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Tooltip content="Delete">
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete"
|
||||
isDisabled={isReadOnly || isRollbackMode}
|
||||
onClick={() => onSecretDelete(index, secret._id, secret?.idOverride)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SecretInputRow.displayName = 'SecretInputRow';
|
||||
|
@@ -1,4 +1,3 @@
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { faArrowDown, faArrowUp } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
@@ -9,36 +8,29 @@ type Props = {
|
||||
onSort: () => void;
|
||||
};
|
||||
|
||||
export const SecretTableHeader = ({
|
||||
sortDir,
|
||||
onSort
|
||||
}: Props): JSX.Element => (
|
||||
<thead>
|
||||
<tr className="absolute flex flex-row sticky top-0">
|
||||
<td className="w-10 px-4 flex items-center justify-center">
|
||||
<div className='text-center w-10 text-xs text-transparent'>{0}</div>
|
||||
export const SecretTableHeader = ({ sortDir, onSort }: Props): JSX.Element => (
|
||||
<thead className="sticky top-0 z-50 bg-mineshaft-800">
|
||||
<tr className="top-0 flex flex-row">
|
||||
<td className="flex w-10 items-center justify-center px-4 border-none">
|
||||
<div className="w-10 text-center text-xs text-transparent">{0}</div>
|
||||
</td>
|
||||
<Controller
|
||||
defaultValue=""
|
||||
name="na"
|
||||
render={() => (
|
||||
<td className='flex items-center'>
|
||||
<div className="min-w-[220px] lg:min-w-[240px] xl:min-w-[280px] relative flex items-center justify-start pl-2.5 w-full">
|
||||
<div className="inline-flex items-end text-md font-medium">
|
||||
Key
|
||||
<IconButton variant="plain" className="ml-2" ariaLabel="sort" onClick={onSort}>
|
||||
<FontAwesomeIcon icon={sortDir === 'asc' ? faArrowDown : faArrowUp} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="w-max flex flex-row items-center justify-end">
|
||||
<div className="w-5 overflow-hidden group-hover:w-5 mt-1"/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
/>
|
||||
<th className="flex flex-row w-full"><div className="text-sm font-medium">Value</div></th>
|
||||
<td className="flex items-center">
|
||||
<div className="relative flex w-full min-w-[220px] items-center justify-start pl-2.5 lg:min-w-[240px] xl:min-w-[280px]">
|
||||
<div className="text-md inline-flex items-end font-medium">
|
||||
Key
|
||||
<IconButton variant="plain" className="ml-2" ariaLabel="sort" onClick={onSort}>
|
||||
<FontAwesomeIcon icon={sortDir === 'asc' ? faArrowDown : faArrowUp} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="flex w-max flex-row items-center justify-end">
|
||||
<div className="mt-1 w-5 overflow-hidden group-hover:w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<th className="flex w-full flex-row">
|
||||
<div className="text-sm font-medium">Value</div>
|
||||
</th>
|
||||
</tr>
|
||||
<tr className='h-0 w-full border border-mineshaft-600'/>
|
||||
<tr className="h-0 w-full border border-mineshaft-600" />
|
||||
</thead>
|
||||
);
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Dispatch, SetStateAction, useMemo, useState } from 'react';
|
||||
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useRouter } from 'next/router';
|
||||
import { faCheck, faCopy, faMagnifyingGlass, faPlus, faTrash, faUsers } from '@fortawesome/free-solid-svg-icons';
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
THead,
|
||||
Tr,
|
||||
UpgradePlanModal} from '@app/components/v2';
|
||||
import { usePopUp } from '@app/hooks';
|
||||
import { usePopUp, useToggle } from '@app/hooks';
|
||||
import { useFetchServerStatus } from '@app/hooks/api/serverDetails';
|
||||
import { OrgUser, Workspace } from '@app/hooks/api/types';
|
||||
|
||||
@@ -70,6 +70,7 @@ export const OrgMembersTable = ({
|
||||
const router = useRouter();
|
||||
const [searchMemberFilter, setSearchMemberFilter] = useState('');
|
||||
const {data: serverDetails } = useFetchServerStatus()
|
||||
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
'addMember',
|
||||
'removeMember',
|
||||
@@ -116,9 +117,17 @@ export const OrgMembersTable = ({
|
||||
[members, searchMemberFilter]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (isInviteLinkCopied) {
|
||||
timer = setTimeout(() => setInviteLinkCopied.off(), 2000);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [isInviteLinkCopied]);
|
||||
|
||||
const copyTokenToClipboard = () => {
|
||||
navigator.clipboard.writeText(completeInviteLink as string);
|
||||
// setIsTokenCopied.on();
|
||||
setInviteLinkCopied.on();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -303,7 +312,7 @@ export const OrgMembersTable = ({
|
||||
className="group relative"
|
||||
onClick={copyTokenToClipboard}
|
||||
>
|
||||
<FontAwesomeIcon icon={false ? faCheck : faCopy} />
|
||||
<FontAwesomeIcon icon={isInviteLinkCopied ? faCheck : faCopy} />
|
||||
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">click to copy</span>
|
||||
</IconButton>
|
||||
</div>
|
||||
@@ -328,4 +337,4 @@ export const OrgMembersTable = ({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
36
nginx/default-stand-alone-docker.conf
Normal file
36
nginx/default-stand-alone-docker.conf
Normal file
@@ -0,0 +1,36 @@
|
||||
events {}
|
||||
http {
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location /api {
|
||||
proxy_set_header X-Real-RIP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-NginX-Proxy true;
|
||||
|
||||
proxy_pass http://localhost:4000; # for backend
|
||||
proxy_redirect off;
|
||||
|
||||
# proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict";
|
||||
proxy_cookie_path / "/; HttpOnly; SameSite=strict";
|
||||
}
|
||||
|
||||
location / {
|
||||
include /etc/nginx/mime.types;
|
||||
|
||||
proxy_set_header X-Real-RIP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-NginX-Proxy true;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
proxy_pass http://localhost:3000; # for frontend
|
||||
proxy_redirect off;
|
||||
}
|
||||
}
|
||||
}
|
23
render.yaml
Normal file
23
render.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
- type: web
|
||||
name: infisical
|
||||
env: docker
|
||||
dockerfilePath: ./Dockerfile.standalone-infisical
|
||||
autoDeploy: false
|
||||
# healthCheckPath: /api/status
|
||||
repo: https://github.com/Infisical/infisical.git
|
||||
envVars:
|
||||
- key: ENCRYPTION_KEY
|
||||
generateValue: true
|
||||
- key: JWT_SIGNUP_SECRET
|
||||
generateValue: true
|
||||
- key: JWT_REFRESH_SECRET
|
||||
generateValue: true
|
||||
- key: JWT_SERVICE_SECRET
|
||||
generateValue: true
|
||||
- key: JWT_AUTH_SECRET
|
||||
generateValue: true
|
||||
- key: MONGO_URL
|
||||
sync: false
|
||||
- key: PORT
|
||||
value: 443
|
Reference in New Issue
Block a user