diff --git a/.github/workflows/cypress-platform.yml b/.github/workflows/cypress-platform.yml index c6a0db4be6..a29c46d495 100644 --- a/.github/workflows/cypress-platform.yml +++ b/.github/workflows/cypress-platform.yml @@ -12,91 +12,106 @@ env: jobs: Cypress-Platform: runs-on: ubuntu-22.04 - if: contains(github.event.pull_request.labels.*.name, 'run-cypress') || - contains(github.event.pull_request.labels.*.name, 'run-cypress-platform-ce') || - contains(github.event.pull_request.labels.*.name, 'run-cypress-platform-ee') || - contains(github.event.pull_request.labels.*.name, 'run-cypress-ce') + if: contains(github.event.pull_request.labels.*.name, 'run-cypress') || + contains(github.event.pull_request.labels.*.name, 'run-cypress-platform-ce') || + contains(github.event.pull_request.labels.*.name, 'run-cypress-platform-ee') || + contains(github.event.pull_request.labels.*.name, 'run-cypress-ce') strategy: + fail-fast: false matrix: - edition: >- - ${{ - contains(github.event.pull_request.labels.*.name, 'run-cypress') && fromJson('["ce", "ee"]') || - contains(github.event.pull_request.labels.*.name, 'run-cypress-ce') && fromJson('["ce"]') || - contains(github.event.pull_request.labels.*.name, 'run-cypress-platform-ce') && fromJson('["ce"]') || - contains(github.event.pull_request.labels.*.name, 'run-cypress-platform-ee') && fromJson('["ee"]') || - fromJson('[]') - }} + edition: + - ${{ contains(github.event.pull_request.labels.*.name, 'run-cypress') && 'ce' || contains(github.event.pull_request.labels.*.name, 'run-cypress-platform-ce') && 'ce' || contains(github.event.pull_request.labels.*.name, 'run-cypress-ce') && 'ce' || '' }} + - ${{ contains(github.event.pull_request.labels.*.name, 'run-cypress') && 'ee' || contains(github.event.pull_request.labels.*.name, 'run-cypress-platform-ee') && 'ee' || '' }} + exclude: + - edition: "" steps: - - name: Setup Node.js - uses: actions/setup-node@v2 - with: - node-version: 18.18.2 - - - name: Set up Git authentication for private submodules + - name: Debug labels and matrix edition run: | - git config --global url."https://x-access-token:${{ secrets.CUSTOM_GITHUB_TOKEN }}@github.com/".insteadOf "https://github.com/" + echo "Labels: ${{ toJSON(github.event.pull_request.labels.*.name) }}" + echo "Matrix edition: ${{ matrix.edition }}" - - name: Checkout with Submodules + - name: Checkout uses: actions/checkout@v3 with: ref: ${{ github.event.pull_request.head.ref }} - - name: Checking out the correct branch for submodules EE + # Create Docker Buildx builder with platform configuration + - name: Set up Docker Buildx + run: | + mkdir -p ~/.docker/cli-plugins + curl -SL https://github.com/docker/buildx/releases/download/v0.11.0/buildx-v0.11.0.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx + chmod a+x ~/.docker/cli-plugins/docker-buildx + docker buildx create --name mybuilder --platform linux/arm64,linux/amd64 + docker buildx use mybuilder + + - name: Set DOCKER_CLI_EXPERIMENTAL + run: echo "DOCKER_CLI_EXPERIMENTAL=enabled" >> $GITHUB_ENV + + - name: use mybuilder buildx + run: docker buildx use mybuilder + + - name: Docker Login + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set SAFE_BRANCH_NAME + run: echo "SAFE_BRANCH_NAME=$(echo ${{ env.BRANCH_NAME }} | tr '/' '-')" >> $GITHUB_ENV + + - name: Build CE Docker image + if: matrix.edition == 'ce' + uses: docker/build-push-action@v4 + with: + context: . + file: docker/ce-production.Dockerfile + push: true + tags: tooljet/tj-osv:${{ env.SAFE_BRANCH_NAME }}-ce + platforms: linux/amd64 + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build EE Docker image if: matrix.edition == 'ee' - run: | - git submodule update --init --recursive - git submodule foreach --recursive ' - git checkout ${{ env.BRANCH_NAME }} 2>/dev/null || git checkout main' - - - name: Set up Docker - uses: docker-practice/actions-setup-docker@master - - - name: Install and build dependencies - run: | - npm cache clean --force - npm install - npm install --prefix server - npm install --prefix frontend - npm run build:plugins - - - name: Local development setup - run: | - sudo docker network create tooljet - sudo docker run -d --name postgres -p 5432:5432 -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres -e POSTGRES_PORT=5432 -d postgres:13 - - - name: Run PostgREST Docker Container - run: | - sudo docker run -d --name postgrest --network tooljet -p 3001:3000 \ - -e PGRST_DB_URI="postgres://postgres:postgres@localhost:5432/tooljet" \ - -e PGRST_DB_ANON_ROLE="postgres" \ - -e PGRST_JWT_SECRET="r9iMKoe5CRMgvJBBtp4HrqN7QiPpUToj" \ - -e PGRST_DB_PRE_CONFIG=postgrest.pre_config \ - postgrest/postgrest:v12.2.0 + uses: docker/build-push-action@v4 + with: + context: . + build-args: | + CUSTOM_GITHUB_TOKEN=${{ secrets.CUSTOM_GITHUB_TOKEN }} + BRANCH_NAME=${{ github.event.pull_request.head.ref }} + file: cypress-tests/cypress.Dockerfile + push: true + tags: tooljet/tj-osv:${{ env.SAFE_BRANCH_NAME }}-ee + platforms: linux/amd64 + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} - name: Set up environment variables run: | - echo "TOOLJET_EDITION=${{ matrix.edition == 'ee' && 'ee' || 'ce' }}" >> .env - echo "TOOLJET_HOST=http://localhost:8082" >> .env + echo "TOOLJET_EDITION=${{ matrix.edition }}" >> .env + echo "TOOLJET_HOST=http://localhost:3000" >> .env echo "LOCKBOX_MASTER_KEY=cd97331a419c09387bef49787f7da8d2a81d30733f0de6bed23ad8356d2068b2" >> .env echo "SECRET_KEY_BASE=7073b9a35a15dd20914ae17e36a693093f25b74b96517a5fec461fc901c51e011cd142c731bee48c5081ec8bac321c1f259ef097ef2a16f25df17a3798c03426" >> .env echo "PG_DB=tooljet_development" >> .env echo "PG_USER=postgres" >> .env - echo "PG_HOST=localhost" >> .env + echo "PG_HOST=postgres" >> .env echo "PG_PASS=postgres" >> .env echo "PG_PORT=5432" >> .env echo "ENABLE_TOOLJET_DB=true" >> .env echo "TOOLJET_DB=tooljet_db" >> .env echo "TOOLJET_DB_USER=postgres" >> .env - echo "TOOLJET_DB_HOST=localhost" >> .env + echo "TOOLJET_DB_HOST=postgres" >> .env echo "TOOLJET_DB_PASS=postgres" >> .env echo "TOOLJET_DB_STATEMENT_TIMEOUT=60000" >> .env echo "TOOLJET_DB_RECONFIG=true" >> .env echo "PGRST_JWT_SECRET=r9iMKoe5CRMgvJBBtp4HrqN7QiPpUToj" >> .env echo "PGRST_HOST=localhost:3001" >> .env echo "PGRST_DB_PRE_CONFIG=postgrest.pre_config" >> .env - echo "PGRST_DB_URI=postgres://postgres:postgres@localhost:5432/tooljet" >> .env + echo "PGRST_DB_URI=postgres://postgres:postgres@postgres/tooljet_db" >> .env echo "ENABLE_MARKETPLACE_FEATURE=true" >> .env echo "ENABLE_MARKETPLACE_DEV_MODE=true" >> .env echo "ENABLE_PRIVATE_APP_EMBED=true" >> .env @@ -105,29 +120,50 @@ jobs: echo "SSO_GIT_OAUTH2_CLIENT_ID=1234567890" >> .env echo "SSO_GIT_OAUTH2_CLIENT_SECRET=3346shfvkdjjsfkvxce32854e026a4531ed" >> .env - - name: Set up database - run: | - npm run --prefix server db:create - npm run --prefix server db:reset - sleep 5 + # Only add EE-specific env vars if edition is ee + if [ "${{ matrix.edition }}" = "ee" ]; then + echo "SSO_OPENID_NAME=tj-oidc-simulator" >> .env + echo "SSO_OPENID_CLIENT_ID=${{ secrets.SSO_OPENID_CLIENT_ID }}" >> .env + echo "SSO_OPENID_CLIENT_SECRET=${{ secrets.SSO_OPENID_CLIENT_SECRET }}" >> .env + echo "SSO_OPENID_WELL_KNOWN_URL=http://34.66.166.236:8080/.well-known/openid-configuration" >> .env + echo "LICENSE_KEY=${{ secrets.RENDER_LICENSE_KEY }}" >> .env + fi - - name: Start services + - name: Pulling the docker-compose file + run: curl -LO https://tooljet-test.s3.us-west-1.amazonaws.com/docker-compose.yaml && mkdir postgres_data + + - name: Update docker-compose file run: | - cd plugins && npm start & - cd server && npm run start:dev & - cd frontend && npm start & + # Update docker-compose.yaml with the appropriate image based on edition + if [ "${{ matrix.edition }}" = "ce" ]; then + sed -i '/^[[:space:]]*tooljet:/,/^$/ s|^\([[:space:]]*image:[[:space:]]*\).*|\1tooljet/tj-osv:${{ env.SAFE_BRANCH_NAME }}-ce|' docker-compose.yaml + elif [ "${{ matrix.edition }}" = "ee" ]; then + sed -i '/^[[:space:]]*tooljet:/,/^$/ s|^\([[:space:]]*image:[[:space:]]*\).*|\1tooljet/tj-osv:${{ env.SAFE_BRANCH_NAME }}-ee|' docker-compose.yaml + fi + + - name: Install Docker Compose + run: | + curl -L "https://github.com/docker/compose/releases/download/v2.10.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + chmod +x /usr/local/bin/docker-compose + + - name: Run docker-compose file + run: docker-compose up -d + + - name: Checking containers + run: docker ps -a + + - name: docker logs + run: sudo docker logs Tooljet-app - name: Wait for the server to be ready run: | - timeout 300 bash -c ' - until curl --silent --fail http://localhost:8082; do + timeout 500 bash -c ' + until curl --silent --fail http://localhost:3000; do sleep 5 done' - - name: Postgres logs - run: docker logs postgrest - - - name: Create Cypress environment file + - name: Create Cypress environment file for CE + if: matrix.edition == 'ce' id: create-json uses: jsdaniell/create-json@1.1.2 with: @@ -135,13 +171,30 @@ jobs: json: ${{ secrets.CYPRESS_SECRETS }} dir: "./cypress-tests" - - name: Run Cypress tests + - name: Run Cypress tests for CE + if: matrix.edition == 'ce' uses: cypress-io/github-action@v6 with: working-directory: ./cypress-tests - config: "baseUrl=http://localhost:8082" + config: "baseUrl=http://localhost:3000" config-file: cypress-platform.config.js + - name: Create Cypress environment file for EE + if: matrix.edition == 'ee' + uses: jsdaniell/create-json@1.1.2 + with: + name: "cypress.env.json" + json: ${{ secrets.CYPRESS_EE_SECRETS }} + dir: "./cypress-tests" + + - name: Run Cypress tests for EE + if: matrix.edition == 'ee' + uses: cypress-io/github-action@v6 + with: + working-directory: ./cypress-tests + config: "baseUrl=http://localhost:3000" + config-file: cypress-ee-platform.config.js + - name: Capture Screenshots uses: actions/upload-artifact@v4 if: always() diff --git a/.version b/.version index 4eba2a62eb..f982feb41b 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -3.13.0 +3.14.0 diff --git a/cypress-tests/cypress-ee-platform.config.js b/cypress-tests/cypress-ee-platform.config.js new file mode 100644 index 0000000000..02b8c1d952 --- /dev/null +++ b/cypress-tests/cypress-ee-platform.config.js @@ -0,0 +1,114 @@ +const { defineConfig } = require("cypress"); +const { rmdir } = require("fs"); +const fs = require("fs"); +const XLSX = require("node-xlsx"); +const pg = require("pg"); +const path = require("path"); +const pdf = require("pdf-parse"); + +const environments = { + 'run-cypress-platform': { + baseUrl: "http://localhost:3000", + configFile: "cypress-platform.config.js" + }, + 'run-cypress-platform-subpath': { + baseUrl: "http://localhost:3000/apps", + configFile: "cypress-platform.config.js" + }, + 'run-cypress-platform-proxy': { + baseUrl: "http://localhost:4001", + configFile: "cypress-platform.config.js" + }, + 'run-cypress-platform-proxy-subpath': { + baseUrl: "http://localhost:4001/apps", + configFile: "cypress-platform.config.js" + } +}; + +const githubLabel = process.env.GITHUB_LABEL || 'run-cypress-platform'; +const environment = environments[githubLabel]; + +module.exports = defineConfig({ + execTimeout: 1800000, + defaultCommandTimeout: 30000, + requestTimeout: 30000, + pageLoadTimeout: 30000, + responseTimeout: 30000, + viewportWidth: 1440, + viewportHeight: 960, + chromeWebSecurity: false, + trashAssetsBeforeRuns: true, + e2e: { + setupNodeEvents (on, config) { + config.baseUrl = environment.baseUrl; + + on("task", { + readPdf (pathToPdf) { + return new Promise((resolve) => { + const pdfPath = path.resolve(pathToPdf); + let dataBuffer = fs.readFileSync(pdfPath); + pdf(dataBuffer).then(function ({ text }) { + resolve(text); + }); + }); + }, + }); + + on("task", { + readXlsx (filePath) { + return new Promise((resolve, reject) => { + try { + let dataBuffer = fs.readFileSync(filePath); + const jsonData = XLSX.parse(dataBuffer); + resolve(jsonData[0]["data"].toString()); + } catch (e) { + reject(e); + } + }); + }, + }); + + on("task", { + deleteFolder (folderName) { + return new Promise((resolve, reject) => { + rmdir(folderName, { maxRetries: 10, recursive: true }, (err) => { + if (err) { + console.error(err); + return reject(err); + } + resolve(null); + }); + }); + }, + }); + + on("task", { + dbConnection ({ dbconfig, sql }) { + const client = new pg.Pool(dbconfig); + return client.query(sql); + }, + }); + + return require("./cypress/plugins/index.js")(on, config); + }, + downloadsFolder: "cypress/downloads", + experimentalRunAllSpecs: true, + experimentalModfyObstructiveThirdPartyCode: true, + baseUrl: environment.baseUrl, + configFile: environment.configFile, + specPattern: [ + "cypress/e2e/happyPath/platform/firstUser/firstUserOnboarding.cy.js", + "cypress/e2e/happyPath/platform/commonTestcases/**/*.cy.js", + "cypress/e2e/happyPath/platform/eeTestcases/**/*.cy.js", + ], + numTestsKeptInMemory: 1, + redirectionLimit: 15, + experimentalMemoryManagement: true, + video: false, + videoUploadOnPasses: false, + retries: { + runMode: 2, + openMode: 0, + }, + }, +}); \ No newline at end of file diff --git a/cypress-tests/cypress-platform.config.js b/cypress-tests/cypress-platform.config.js index b565a0c1d1..6b1954140a 100644 --- a/cypress-tests/cypress-platform.config.js +++ b/cypress-tests/cypress-platform.config.js @@ -8,7 +8,7 @@ const pdf = require("pdf-parse"); const environments = { 'run-cypress-platform': { - baseUrl: "http://localhost:8082", + baseUrl: "http://localhost:3000", configFile: "cypress-platform.config.js" }, 'run-cypress-platform-subpath': { diff --git a/cypress-tests/cypress.Dockerfile b/cypress-tests/cypress.Dockerfile new file mode 100644 index 0000000000..373b3bafd3 --- /dev/null +++ b/cypress-tests/cypress.Dockerfile @@ -0,0 +1,189 @@ +FROM node:18.18.2-buster AS builder +# Fix for JS heap limit allocation issue +ENV NODE_OPTIONS="--max-old-space-size=4096" + +RUN mkdir -p /app + +WORKDIR /app + +ARG CUSTOM_GITHUB_TOKEN +ARG BRANCH_NAME + +# Clone and checkout the frontend repositorys +RUN git config --global url."https://x-access-token:${CUSTOM_GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/" + +RUN git config --global http.version HTTP/1.1 +RUN git config --global http.postBuffer 524288000 +RUN git clone https://github.com/ToolJet/ToolJet.git . + +# The branch name needs to be changed the branch with modularisation in CE repo +RUN git checkout ${BRANCH_NAME} + +RUN git submodule update --init --recursive + +# Checkout the same branch in submodules if it exists, otherwise stay on default branch +RUN git submodule foreach 'git checkout ${BRANCH_NAME} || true' + +# Scripts for building +COPY ./package.json ./package.json + +# Build plugins +COPY ./plugins/package.json ./plugins/package-lock.json ./plugins/ +RUN npm --prefix plugins install +COPY ./plugins/ ./plugins/ +RUN NODE_ENV=production npm --prefix plugins run build +RUN npm --prefix plugins prune --production + +ENV TOOLJET_EDITION=ee + +# Build frontend +COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/ +RUN npm --prefix frontend install +COPY ./frontend/ ./frontend/ +RUN npm --prefix frontend run build --production +RUN npm --prefix frontend prune --production + +ENV NODE_ENV=production +ENV TOOLJET_EDITION=ee + +# Build server +COPY ./server/package.json ./server/package-lock.json ./server/ +RUN npm --prefix server install +COPY ./server/ ./server/ +RUN npm install -g @nestjs/cli +RUN npm --prefix server run build + +FROM node:18.18.2-bullseye + +RUN apt-get update -yq \ + && apt-get install curl wget gnupg zip -yq \ + && apt-get install -yq build-essential \ + && apt -y install redis \ + && apt-get clean -y + +# copy postgrest executable +COPY --from=postgrest/postgrest:v12.2.0 /bin/postgrest /bin + +ENV NODE_ENV=production +ENV TOOLJET_EDITION=ee +ENV NODE_OPTIONS="--max-old-space-size=4096" +RUN apt-get update && apt-get install -y freetds-dev libaio1 wget supervisor + +# Install Instantclient Basic Light Oracle and Dependencies +WORKDIR /opt/oracle +RUN wget https://tooljet-plugins-production.s3.us-east-2.amazonaws.com/marketplace-assets/oracledb/instantclients/instantclient-basiclite-linuxx64.zip && \ + wget https://tooljet-plugins-production.s3.us-east-2.amazonaws.com/marketplace-assets/oracledb/instantclients/instantclient-basiclite-linux.x64-11.2.0.4.0.zip && \ + unzip instantclient-basiclite-linuxx64.zip && rm -f instantclient-basiclite-linuxx64.zip && \ + unzip instantclient-basiclite-linux.x64-11.2.0.4.0.zip && rm -f instantclient-basiclite-linux.x64-11.2.0.4.0.zip && \ + cd /opt/oracle/instantclient_21_10 && rm -f *jdbc* *occi* *mysql* *mql1* *ipc1* *jar uidrvci genezi adrci && \ + cd /opt/oracle/instantclient_11_2 && rm -f *jdbc* *occi* *mysql* *mql1* *ipc1* *jar uidrvci genezi adrci && \ + echo /opt/oracle/instantclient* > /etc/ld.so.conf.d/oracle-instantclient.conf && ldconfig +# Set the Instant Client library paths +ENV LD_LIBRARY_PATH="/opt/oracle/instantclient_11_2:/opt/oracle/instantclient_21_10:${LD_LIBRARY_PATH}" + +WORKDIR / + +# copy npm scripts +COPY --from=builder /app/package.json ./app/package.json +# copy plugins dependencies +COPY --from=builder /app/plugins/dist ./app/plugins/dist +COPY --from=builder /app/plugins/client.js ./app/plugins/client.js +COPY --from=builder /app/plugins/node_modules ./app/plugins/node_modules +COPY --from=builder /app/plugins/packages/common ./app/plugins/packages/common +COPY --from=builder /app/plugins/package.json ./app/plugins/package.json +# copy frontend build +COPY --from=builder /app/frontend/build ./app/frontend/build +# copy server build +COPY --from=builder /app/server/package.json ./app/server/package.json +COPY --from=builder /app/server/.version ./app/server/.version +COPY --from=builder /app/server/ee/keys ./app/server/ee/keys +COPY --from=builder /app/server/node_modules ./app/server/node_modules +COPY --from=builder /app/server/templates ./app/server/templates +COPY --from=builder /app/server/scripts ./app/server/scripts +COPY --from=builder /app/server/dist ./app/server/dist + +WORKDIR /app + +# Install PostgreSQL +USER root +RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - +RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ bullseye-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list +RUN apt update && apt -y install postgresql-13 postgresql-client-13 supervisor --fix-missing + + +# Explicitly create PG main directory with correct ownership +RUN mkdir -p /var/lib/postgresql/13/main && \ + chown -R postgres:postgres /var/lib/postgresql + +RUN mkdir -p /var/log/supervisor /var/run/postgresql && \ + chown -R postgres:postgres /var/run/postgresql /var/log/supervisor + +# Remove existing data and create directory with proper ownership +RUN rm -rf /var/lib/postgresql/13/main && \ + mkdir -p /var/lib/postgresql/13/main && \ + chown -R postgres:postgres /var/lib/postgresql + +# Initialize PostgreSQL +RUN su - postgres -c "/usr/lib/postgresql/13/bin/initdb -D /var/lib/postgresql/13/main" + +# Configure Supervisor to manage PostgREST, ToolJet, and Redis +RUN echo "[supervisord] \n" \ + "nodaemon=true \n" \ + "user=root \n" \ + "\n" \ + "[program:redis] \n" \ + "command=redis-server /etc/redis/redis.conf \n" \ + "user=redis \n" \ + "autostart=true \n" \ + "autorestart=true \n" \ + "stderr_logfile=/var/log/redis/redis-server.log \n" \ + "stdout_logfile=/var/log/redis/redis-server.log \n" \ + "\n" \ + "[program:postgrest] \n" \ + "command=/bin/postgrest \n" \ + "autostart=true \n" \ + "autorestart=true \n" \ + "\n" \ + "[program:tooljet] \n" \ + "user=root \n" \ + "command=/bin/bash -c '/app/server/scripts/boot.sh' \n" \ + "autostart=true \n" \ + "autorestart=true \n" \ + "stderr_logfile=/dev/stdout \n" \ + "stderr_logfile_maxbytes=0 \n" \ + "stdout_logfile=/dev/stdout \n" \ + "stdout_logfile_maxbytes=0 \n" | sed 's/ //' > /etc/supervisor/conf.d/supervisord.conf + +# ENV defaults +ENV TOOLJET_HOST=http://localhost \ + PORT=3000 \ + NODE_ENV=production \ + LOCKBOX_MASTER_KEY=replace_with_lockbox_master_key \ + SECRET_KEY_BASE=replace_with_secret_key_base \ + PG_DB=tooljet_production \ + PG_USER=postgres \ + PG_PASS=postgres \ + PG_HOST=localhost \ + ENABLE_TOOLJET_DB=true \ + TOOLJET_DB_HOST=localhost \ + TOOLJET_DB_USER=postgres \ + TOOLJET_DB_PASS=postgres \ + TOOLJET_DB=tooljet_db \ + PGRST_HOST=http://localhost:3001 \ + PGRST_SERVER_PORT=3001 \ + PGRST_DB_URI=postgres://postgres:postgres@localhost/tooljet_db \ + PGRST_JWT_SECRET=r9iMKoe5CRMgvJBBtp4HrqN7QiPpUToj \ + PGRST_DB_PRE_CONFIG=postgrest.pre_config \ + REDIS_HOST=localhost \ + REDIS_PORT=6379 \ + REDIS_USER= \ + REDIS_PASSWORD= \ + ORM_LOGGING=true \ + DEPLOYMENT_PLATFORM=docker:local \ + HOME=/home/appuser \ + TERM=xterm + + +RUN chmod +x ./server/scripts/preview.sh +# Set the entrypoint +ENTRYPOINT ["./server/scripts/preview.sh"] diff --git a/cypress-tests/cypress/commands/commands.js b/cypress-tests/cypress/commands/commands.js index d242eb1895..3bb63cc926 100644 --- a/cypress-tests/cypress/commands/commands.js +++ b/cypress-tests/cypress/commands/commands.js @@ -226,9 +226,9 @@ Cypress.Commands.add( .invoke("text") .then((text) => { cy.wrap(subject).realType(createBackspaceText(text)), - { - delay: 0, - }; + { + delay: 0, + }; }); } ); @@ -429,7 +429,6 @@ Cypress.Commands.add("visitSlug", ({ actualUrl }) => { }); }); - Cypress.Commands.add("releaseApp", () => { if (Cypress.env("environment") !== "Community") { cy.get(commonEeSelectors.promoteButton).click(); @@ -549,7 +548,7 @@ Cypress.Commands.add("installMarketplacePlugin", (pluginName) => { } }); - function installPlugin (pluginName) { + function installPlugin(pluginName) { cy.get('[data-cy="-list-item"]').eq(1).click(); cy.wait(1000); @@ -605,3 +604,20 @@ Cypress.Commands.add("uninstallMarketplacePlugin", (pluginName) => { }); }); }); + +Cypress.Commands.add( + "verifyRequiredFieldValidation", + (fieldName, expectedColor) => { + cy.get(commonSelectors.textField(fieldName)).should( + "have.css", + "border-color", + expectedColor + ); + cy.get(commonSelectors.labelFieldValidation(fieldName)) + .should("be.visible") + .and("have.text", `${fieldName} is required`); + cy.get(commonSelectors.labelFieldAlert(fieldName)) + .should("be.visible") + .and("have.text", `${fieldName} is required`); + } +); diff --git a/cypress-tests/cypress/constants/selectors/common.js b/cypress-tests/cypress/constants/selectors/common.js index ec104d67bc..7a238f56c7 100644 --- a/cypress-tests/cypress/constants/selectors/common.js +++ b/cypress-tests/cypress/constants/selectors/common.js @@ -1,5 +1,5 @@ export const cyParamName = (paramName = "") => { - return paramName.toLowerCase().replace(/\s+/g, "-"); + return String(paramName).toLowerCase().replace(/\s+/g, "-"); }; export const commonSelectors = { @@ -278,6 +278,16 @@ export const commonSelectors = { defaultModalTitle: '[data-cy="modal-title"]', workspaceConstantsIcon: '[data-cy="icon-workspace-constants"]', confirmationButton: '[data-cy="confirmation-button"]', + + textField: (fieldName) => { + return `[data-cy="${cyParamName(fieldName)}-text-field"]`; + }, + labelFieldValidation: (fieldName) => { + return `[data-cy="${cyParamName(fieldName)}-is-required-validation-label"]`; + }, + labelFieldAlert: (fieldName) => { + return `[data-cy="${cyParamName(fieldName)}-is-required-field-alert-text"]`; + }, }; export const commonWidgetSelector = { diff --git a/cypress-tests/cypress/constants/selectors/dataSource.js b/cypress-tests/cypress/constants/selectors/dataSource.js index bf5fc06dfa..86f5a24c58 100644 --- a/cypress-tests/cypress/constants/selectors/dataSource.js +++ b/cypress-tests/cypress/constants/selectors/dataSource.js @@ -101,6 +101,8 @@ export const dataSourceSelector = { unSavedModalTitle: '[data-cy="unsaved-changes-title"]', eventQuerySelectionField: '[data-cy="query-selection-field"]', connectionAlertText: '[data-cy="connection-alert-text"]', + requiredIndicator: '[data-cy="required-indicator"]', + informationIcon: '[data-cy="information-icon"]', deleteDSButton: (datasourceName) => { return `[data-cy="${cyParamName(datasourceName)}-delete-button"]`; }, @@ -110,4 +112,37 @@ export const dataSourceSelector = { dataSourceNameButton: (dataSourceName) => { return `[data-cy="${cyParamName(dataSourceName)}-button"]`; }, + dropdownLabel: (label) => { + return `[data-cy="${cyParamName(label)}-dropdown-label"]`; + }, + textField: (fieldName) => { + return `[data-cy="${cyParamName(fieldName)}-text-field"]`; + }, + subSection: (header) => { + return `[data-cy="${cyParamName(header)}-section"]`; + }, + toggleInput: (toggleName) => { + return `[data-cy="${cyParamName(toggleName)}-toggle-input"]`; + }, + button: (buttonName) => { + return `[data-cy="button-${cyParamName(buttonName)}"]`; + }, + keyInputField: (header, index) => { + return `[data-cy="${cyParamName(header)}-key-input-field-${cyParamName(index)}"]`; + }, + valueInputField: (header, index) => { + return `[data-cy="${cyParamName(header)}-value-input-field-${cyParamName(index)}"]`; + }, + deleteButton: (header, index) => { + return `[data-cy="${cyParamName(header)}-delete-button-${cyParamName(index)}"]`; + }, + addMoreButton: (header) => { + return `[data-cy="${cyParamName(header)}-add-button"]`; + }, + dropdownField: (fieldName) => { + return `[data-cy="${cyParamName(fieldName)}-select-dropdown"]`; + }, + labelFieldValidation: (fieldName) => { + return `[data-cy="${cyParamName(fieldName)}-is-required-validation-label"]`; + }, }; diff --git a/cypress-tests/cypress/constants/selectors/postgreSql.js b/cypress-tests/cypress/constants/selectors/postgreSql.js index 4f38357961..112da90779 100644 --- a/cypress-tests/cypress/constants/selectors/postgreSql.js +++ b/cypress-tests/cypress/constants/selectors/postgreSql.js @@ -87,6 +87,8 @@ export const postgreSqlSelector = { recordsInputField: '[data-cy="records-input-field"]', eventQuerySelectionField: '[data-cy="query-selection-field"]', + sslToggleInput: '[data-cy="ssl-enabled-toggle-input"]', + labelEncryptedText: '[data-cy="encrypted-text"]', }; export const airTableSelector = { diff --git a/cypress-tests/cypress/constants/texts/airTable.js b/cypress-tests/cypress/constants/texts/airTable.js index 44df3cf9e1..1604fd7590 100644 --- a/cypress-tests/cypress/constants/texts/airTable.js +++ b/cypress-tests/cypress/constants/texts/airTable.js @@ -1,6 +1,7 @@ export const airtableText = { - airtable: "Airtable", - cypressairtable: "cypress-Airtable", - ApiKey: "Personal access token", - apikeyPlaceholder: "**************", - }; \ No newline at end of file + airtable: "Airtable", + cypressairtable: "cypress-Airtable", + ApiKey: "Personal access token", + apikeyPlaceholder: "**************", + invalidAccessToken: "Authentication failed: Invalid personal access token", +}; diff --git a/cypress-tests/cypress/constants/texts/postgreSql.js b/cypress-tests/cypress/constants/texts/postgreSql.js index 9db745b58d..d5c85c197b 100644 --- a/cypress-tests/cypress/constants/texts/postgreSql.js +++ b/cypress-tests/cypress/constants/texts/postgreSql.js @@ -17,13 +17,17 @@ export const postgreSqlText = { allCloudStorage: "Cloud Storages (4)", postgreSQL: "PostgreSQL", + labelConnectionType: "Connection type", + manualConnectionOption: "Manual connection", + connectionStringOption: "Connection string", labelHost: "Host", labelPort: "Port", labelSSL: "SSL", labelDbName: "Database name", labelUserName: "Username", labelPassword: "Password", - label: "Encrypted", + labelEncrypted: "Encrypted", + labelConnectionOptions: "Connection options", sslCertificate: "SSL certificate", whiteListIpText: "Please white-list our IP address if the data source is not publicly accessible", @@ -74,6 +78,8 @@ export const postgreSqlText = { guiOptionBulkUpdate: "Bulk update using primary key", buttonTextTestConnection: "Test connection", + editButtonText: "Edit", + unableAcquireConnectionAlertText: "Unable to acquire a connection", tabAdvanced: "Advanced", labelNoEventhandler: "No event handlers", diff --git a/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/airTableHappyPath.cy.js b/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/airTableHappyPath.cy.js index 62a6bffcb1..0f3cf9c7a5 100644 --- a/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/airTableHappyPath.cy.js +++ b/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/airTableHappyPath.cy.js @@ -3,24 +3,15 @@ import { postgreSqlSelector, airTableSelector } from "Selectors/postgreSql"; import { postgreSqlText } from "Texts/postgreSql"; import { airtableText } from "Texts/airTable"; import { commonSelectors } from "Selectors/common"; -import { commonText } from "Texts/common"; - -import { - fillDataSourceTextField, - selectAndAddDataSource, -} from "Support/utils/postgreSql"; - -import { - deleteDatasource, - closeDSModal, - deleteAppandDatasourceAfterExecution, -} from "Support/utils/dataSource"; - +import { closeDSModal } from "Support/utils/dataSource"; import { dataSourceSelector } from "../../../../../constants/selectors/dataSource"; const data = {}; - data.queryName = fake.lastName.toLowerCase().replaceAll("[^A-Za-z]", ""); +const airTable_apiKey = Cypress.env("airTable_apikey"); +const airTable_baseId = Cypress.env("airtabelbaseId"); +const airTable_tableName = Cypress.env("airtable_tableName"); +const airTable_recordID = Cypress.env("airtable_recordId"); describe("Data source Airtable", () => { beforeEach(() => { @@ -54,18 +45,71 @@ describe("Data source Airtable", () => { postgreSqlText.allCloudStorage ); - selectAndAddDataSource("databases", airtableText.airtable, data.dsName); - - cy.get(postgreSqlSelector.buttonSave).verifyVisibleElement( + cy.apiCreateGDS( + `${Cypress.env("server_host")}/api/data-sources`, + `cypress-${data.dsName}-airtable`, + "airtable", + [ + { + key: "personal_access_token", + value: `${Cypress.env("airTable_apikey")}`, + encrypted: true, + }, + ] + ); + cy.reload(); + cy.get( + dataSourceSelector.dataSourceNameButton(`cypress-${data.dsName}-airtable`) + ) + .should("be.visible") + .click(); + cy.get( + dataSourceSelector.labelFieldName(airtableText.ApiKey) + ).verifyVisibleElement("have.text", `${airtableText.ApiKey}*`); + cy.get(postgreSqlSelector.labelEncryptedText).verifyVisibleElement( "have.text", - postgreSqlText.buttonTextSave + postgreSqlText.labelEncrypted + ); + cy.get(dataSourceSelector.button(postgreSqlText.editButtonText)).should( + "be.visible" + ); + cy.get(dataSourceSelector.button(postgreSqlText.editButtonText)).click(); + cy.verifyRequiredFieldValidation(airtableText.ApiKey, "rgb(226, 99, 103)"); + cy.get(dataSourceSelector.textField(airtableText.ApiKey)).should( + "be.visible" + ); + cy.get(postgreSqlSelector.labelIpWhitelist).verifyVisibleElement( + "have.text", + postgreSqlText.whiteListIpText + ); + cy.get(postgreSqlSelector.buttonCopyIp).verifyVisibleElement( + "have.text", + postgreSqlText.textCopy ); - cy.verifyToastMessage( - commonSelectors.toastMessage, - postgreSqlText.toastDSSaved + cy.get(postgreSqlSelector.linkReadDocumentation).verifyVisibleElement( + "have.text", + postgreSqlText.readDocumentation ); - deleteDatasource(`cypress-${data.dsName}-airtable`); + cy.get(postgreSqlSelector.buttonTestConnection) + .verifyVisibleElement( + "have.text", + postgreSqlText.buttonTextTestConnection + ) + .click(); + cy.get(postgreSqlSelector.connectionFailedText).verifyVisibleElement( + "have.text", + postgreSqlText.couldNotConnect + ); + cy.get(postgreSqlSelector.buttonSave) + .verifyVisibleElement("have.text", postgreSqlText.buttonTextSave) + .and("be.disabled"); + cy.get(dataSourceSelector.connectionAlertText).verifyVisibleElement( + "have.text", + airtableText.invalidAccessToken + ); + + cy.apiDeleteGDS(`cypress-${data.dsName}-airtable`); }); it("Should verify the functionality of AirTable connection form.", () => { @@ -95,7 +139,7 @@ describe("Data source Airtable", () => { cy.get(dataSourceSelector.connectionAlertText).verifyVisibleElement( "have.text", - "Authentication failed: Invalid personal access token" + airtableText.invalidAccessToken ); cy.reload(); cy.apiUpdateGDS({ @@ -123,11 +167,6 @@ describe("Data source Airtable", () => { }); it("Should able to run the query with valid conection", () => { - const airTable_apiKey = Cypress.env("airTable_apikey"); - const airTable_baseId = Cypress.env("airtabelbaseId"); - const airTable_tableName = Cypress.env("airtable_tableName"); - const airTable_recordID = Cypress.env("airtable_recordId"); - cy.apiCreateGDS( `${Cypress.env("server_host")}/api/data-sources`, `cypress-${data.dsName}-airtable`, diff --git a/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/postgresHappyPath.cy.js b/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/postgresHappyPath.cy.js index 43268fb85d..b86ca7cb17 100644 --- a/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/postgresHappyPath.cy.js +++ b/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/postgresHappyPath.cy.js @@ -27,7 +27,7 @@ describe("Data sources", () => { .replaceAll("[^A-Za-z]", ""); }); - it.skip("Should verify elements on connection form", () => { + it("Should verify elements on connection form with validation", () => { cy.log(process.env.NODE_ENV); cy.log(postgreSqlText.allDatabase()); cy.get(commonSelectors.globalDataSourceIcon).click(); @@ -81,30 +81,147 @@ describe("Data sources", () => { `cypress-${data.dataSourceName}-postgresql` ); - cy.get(postgreSqlSelector.labelHost).verifyVisibleElement( + cy.get( + dataSourceSelector.dropdownLabel(postgreSqlText.labelConnectionType) + ).verifyVisibleElement("have.text", postgreSqlText.labelConnectionType); + cy.get(dataSourceSelector.dropdownField(postgreSqlText.labelConnectionType)) + .should("be.visible") + .click(); + cy.contains( + `[id*="react-select-"]`, + postgreSqlText.connectionStringOption + ).click(); + + cy.get( + dataSourceSelector.dropdownField(postgreSqlText.labelConnectionType) + ).should("be.visible"); + cy.get( + dataSourceSelector.labelFieldName(postgreSqlText.connectionStringOption) + ).verifyVisibleElement( "have.text", - postgreSqlText.labelHost + `${postgreSqlText.connectionStringOption}*` ); - cy.get(postgreSqlSelector.labelPort).verifyVisibleElement( + cy.get(postgreSqlSelector.labelEncryptedText).verifyVisibleElement( "have.text", - postgreSqlText.labelPort + postgreSqlText.labelEncrypted ); + cy.get(dataSourceSelector.button(postgreSqlText.editButtonText)).should( + "be.visible" + ); + cy.get(dataSourceSelector.button(postgreSqlText.editButtonText)).click(); + cy.verifyRequiredFieldValidation( + postgreSqlText.connectionStringOption, + "rgb(226, 99, 103)" + ); + cy.get( + dataSourceSelector.textField(postgreSqlText.connectionStringOption) + ).should("be.visible"); + cy.get(postgreSqlSelector.labelIpWhitelist).verifyVisibleElement( + "have.text", + postgreSqlText.whiteListIpText + ); + cy.get(postgreSqlSelector.buttonCopyIp).verifyVisibleElement( + "have.text", + postgreSqlText.textCopy + ); + + cy.get(postgreSqlSelector.linkReadDocumentation).verifyVisibleElement( + "have.text", + postgreSqlText.readDocumentation + ); + cy.get(postgreSqlSelector.buttonTestConnection) + .verifyVisibleElement( + "have.text", + postgreSqlText.buttonTextTestConnection + ) + .click(); + cy.get(postgreSqlSelector.connectionFailedText).verifyVisibleElement( + "have.text", + postgreSqlText.couldNotConnect + ); + cy.get(postgreSqlSelector.buttonSave) + .verifyVisibleElement("have.text", postgreSqlText.buttonTextSave) + .and("be.disabled"); + cy.get(dataSourceSelector.connectionAlertText).verifyVisibleElement( + "have.text", + postgreSqlText.unableAcquireConnectionAlertText + ); + + cy.get(dataSourceSelector.dropdownField(postgreSqlText.labelConnectionType)) + .should("be.visible") + .click(); + cy.contains( + `[id*="react-select-"]`, + postgreSqlText.manualConnectionOption + ).click(); + + cy.get( + dataSourceSelector.dropdownField(postgreSqlText.labelConnectionType) + ).should("be.visible"); + + const requiredFields = [ + postgreSqlText.labelHost, + postgreSqlText.labelPort, + postgreSqlText.labelUserName, + postgreSqlText.labelPassword, + ]; + const sections = [ + postgreSqlText.labelHost, + postgreSqlText.labelPort, + postgreSqlText.labelDbName, + postgreSqlText.labelUserName, + postgreSqlText.labelPassword, + postgreSqlText.labelConnectionOptions, + ]; + sections.forEach((section) => { + if (section === postgreSqlText.labelConnectionOptions) { + cy.get(dataSourceSelector.keyInputField(section, 0)).should( + "be.visible" + ); + cy.get(dataSourceSelector.valueInputField(section, 0)).should( + "be.visible" + ); + cy.get(dataSourceSelector.deleteButton(section, 0)).should( + "be.visible" + ); + cy.get(dataSourceSelector.addMoreButton(section)).should("be.visible"); + } else if (requiredFields.includes(section)) { + cy.get(dataSourceSelector.labelFieldName(section)).verifyVisibleElement( + "have.text", + `${section}*` + ); + cy.get(dataSourceSelector.textField(section)).should("be.visible"); + if (section === postgreSqlText.labelPassword) { + cy.get( + dataSourceSelector.button(postgreSqlText.editButtonText) + ).click(); + cy.verifyRequiredFieldValidation(section, "rgb(215, 45, 57)"); + } else { + cy.get(dataSourceSelector.textField(section)).click(); + cy.get(commonSelectors.textField(section)).should( + "have.css", + "border-color", + "rgba(0, 0, 0, 0)" + ); + cy.get(dataSourceSelector.textField(section)) + .type("123") + .clear() + .blur(); + cy.verifyRequiredFieldValidation(section, "rgb(215, 45, 57)"); + } + } else { + cy.get(dataSourceSelector.labelFieldName(section)).verifyVisibleElement( + "have.text", + section + ); + cy.get(dataSourceSelector.textField(section)).should("be.visible"); + } + }); cy.get(postgreSqlSelector.labelSsl).verifyVisibleElement( "have.text", postgreSqlText.labelSSL ); - cy.get(postgreSqlSelector.labelDbName).verifyVisibleElement( - "have.text", - postgreSqlText.labelDbName - ); - cy.get(postgreSqlSelector.labelUserName).verifyVisibleElement( - "have.text", - postgreSqlText.labelUserName - ); - cy.get(postgreSqlSelector.labelPassword).verifyVisibleElement( - "have.text", - postgreSqlText.labelPassword - ); + cy.get(postgreSqlSelector.sslToggleInput).should("be.visible"); cy.get(postgreSqlSelector.labelSSLCertificate).verifyVisibleElement( "have.text", postgreSqlText.sslCertificate @@ -132,72 +249,85 @@ describe("Data sources", () => { "have.text", postgreSqlText.couldNotConnect ); - cy.get(postgreSqlSelector.buttonSave).verifyVisibleElement( + cy.get(postgreSqlSelector.buttonSave) + .verifyVisibleElement("have.text", postgreSqlText.buttonTextSave) + .and("be.disabled"); + cy.get(dataSourceSelector.connectionAlertText).verifyVisibleElement( "have.text", - postgreSqlText.buttonTextSave + "connect ECONNREFUSED 127.0.0.1:5432" ); - cy.get(dataSourceSelector.connectionAlertText).should("be.visible"); - deleteDatasource(`cypress-${data.dataSourceName}-postgresql`); + + cy.apiDeleteGDS(`cypress-${data.dataSourceName}-postgresql`); }); - it.skip("Should verify the functionality of PostgreSQL connection form.", () => { - selectAndAddDataSource( - "databases", - postgreSqlText.postgreSQL, - data.dataSourceName + it("Should verify the functionality of PostgreSQL connection form.", () => { + cy.get(commonSelectors.globalDataSourceIcon).click(); + cy.apiCreateGDS( + `${Cypress.env("server_host")}/api/data-sources`, + `cypress-${data.dataSourceName}-manual-pgsql`, + "postgresql", + [ + { key: "connection_type", value: "manual", encrypted: false }, + { key: "host", value: `${Cypress.env("pg_host")}`, encrypted: false }, + { key: "port", value: 5432, encrypted: false }, + { key: "ssl_enabled", value: false, encrypted: false }, + { key: "database", value: "postgres", encrypted: false }, + { key: "ssl_certificate", value: "none", encrypted: false }, + { + key: "username", + value: `${Cypress.env("pg_user")}`, + encrypted: false, + }, + { + key: "password", + value: `${Cypress.env("pg_password")}`, + encrypted: true, + }, + { key: "ca_cert", value: null, encrypted: true }, + { key: "client_key", value: null, encrypted: true }, + { key: "client_cert", value: null, encrypted: true }, + { key: "root_cert", value: null, encrypted: true }, + { key: "connection_string", value: null, encrypted: true }, + ] ); - - fillDataSourceTextField( - postgreSqlText.labelHost, - postgreSqlText.placeholderEnterHost, - Cypress.env("pg_host") - ); - fillDataSourceTextField( - postgreSqlText.labelPort, - postgreSqlText.placeholderEnterPort, - "5432" - ); - cy.get('[data-cy="-toggle-input"]').then(($el) => { - if ($el.is(":checked")) { - cy.get('[data-cy="-toggle-input"]').uncheck(); - } - }); - fillDataSourceTextField( - postgreSqlText.labelDbName, - postgreSqlText.placeholderNameOfDB, - "postgres" - ); - fillDataSourceTextField( - postgreSqlText.labelUserName, - postgreSqlText.placeholderEnterUserName, - "postgres" - ); - fillDataSourceTextField( - postgreSqlText.labelPassword, - "**************", - Cypress.env("pg_password") - ); - + cy.get( + dataSourceSelector.dataSourceNameButton( + `cypress-${data.dataSourceName}-manual-pgsql` + ) + ) + .should("be.visible") + .click(); cy.get(postgreSqlSelector.buttonTestConnection).click(); cy.get(postgreSqlSelector.textConnectionVerified, { timeout: 10000, }).should("have.text", postgreSqlText.labelConnectionVerified); - cy.get(postgreSqlSelector.buttonSave).click(); - - cy.verifyToastMessage( - commonSelectors.toastMessage, - postgreSqlText.toastDSSaved + cy.apiDeleteGDS(`cypress-${data.dataSourceName}-manual-pgsql`); + cy.reload(); + cy.apiCreateGDS( + `${Cypress.env("server_host")}/api/data-sources`, + `cypress-${data.dataSourceName}-string-pgsql`, + "postgresql", + [ + { key: "connection_type", value: "string", encrypted: false }, + { + key: "connection_string", + value: `${Cypress.env("pg_string")}`, + encrypted: true, + }, + ] ); - - cy.get(commonSelectors.globalDataSourceIcon).click(); cy.get( - `[data-cy="cypress-${data.dataSourceName}-postgresql-button"]` - ).verifyVisibleElement( - "have.text", - `cypress-${data.dataSourceName}-postgresql` - ); - - deleteDatasource(`cypress-${data.dataSourceName}-postgresql`); + dataSourceSelector.dataSourceNameButton( + `cypress-${data.dataSourceName}-string-pgsql` + ) + ) + .should("be.visible") + .click(); + cy.get(postgreSqlSelector.buttonTestConnection).click(); + cy.get(postgreSqlSelector.textConnectionVerified, { + timeout: 10000, + }).should("have.text", postgreSqlText.labelConnectionVerified); + cy.apiDeleteGDS(`cypress-${data.dataSourceName}-string-pgsql`); }); it.skip("Should verify elements of the Query section.", () => { diff --git a/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/restAPIHappyPath.cy.js b/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/restAPIHappyPath.cy.js index 9ec852d6ce..09559e2ba7 100644 --- a/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/restAPIHappyPath.cy.js +++ b/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/restAPIHappyPath.cy.js @@ -426,6 +426,7 @@ describe("Data source Rest API", () => { }); } ); + cy.apiDeleteApp(`${fake.companyName}-restAPI-CURD-App`); cy.apiDeleteGDS(`cypress-${data.dataSourceName}-restapi`); }); it("Should verify response for basic authentication type connection", () => { @@ -488,6 +489,7 @@ describe("Data source Rest API", () => { method: "GET", urlSuffix: "/basic-auth/invaliduser/invalidpass", }); + cy.apiDeleteApp(`${fake.companyName}-restAPI-Basic-App`); cy.apiDeleteGDS(`cypress-${data.dataSourceName}-restapi`); }); it("Should verify response for bearer authentication type connection", () => { @@ -545,6 +547,7 @@ describe("Data source Rest API", () => { urlSuffix: "/bearer", expectedResponseShape: { authenticated: true, token: "my-token-123" }, }); + cy.apiDeleteApp(`${fake.companyName}-restAPI-Bearer-App`); cy.intercept("GET", "api/data_sources?**").as("datasource"); cy.apiCreateGDS( `${Cypress.env("server_host")}/api/data-sources`, @@ -597,6 +600,7 @@ describe("Data source Rest API", () => { method: "GET", urlSuffix: "/bearer", }); + cy.apiDeleteApp(`${fake.companyName}-restAPI-Bearer-invalid`); cy.apiDeleteGDS(`cypress-${data.dataSourceName}-restapi`); }); it.skip("Should verify response for authentication code grant type connection", () => { diff --git a/cypress-tests/cypress/support/utils/apps.js b/cypress-tests/cypress/support/utils/apps.js index 0ddfd72ac7..2a6b9da2b9 100644 --- a/cypress-tests/cypress/support/utils/apps.js +++ b/cypress-tests/cypress/support/utils/apps.js @@ -135,7 +135,7 @@ export const resolveHost = () => { const baseUrl = Cypress.config("baseUrl"); const urlMapping = { - "http://localhost:8082": "http://localhost:8082", + "http://localhost:3000": "http://localhost:3000", "http://localhost:3000/apps": "http://localhost:3000/apps", "http://localhost:4001": "http://localhost:3000", "http://localhost:4001/apps": "http://localhost:3000/apps", diff --git a/frontend/.version b/frontend/.version index 4eba2a62eb..f982feb41b 100644 --- a/frontend/.version +++ b/frontend/.version @@ -1 +1 @@ -3.13.0 +3.14.0 diff --git a/frontend/ee b/frontend/ee index 777446d71e..4ca98b6bb6 160000 --- a/frontend/ee +++ b/frontend/ee @@ -1 +1 @@ -Subproject commit 777446d71e78e5941d34353606a12d982820438f +Subproject commit 4ca98b6bb66d1d9845f8b326100945a969488f94 diff --git a/frontend/src/MarketplacePage/InstalledPlugins.jsx b/frontend/src/MarketplacePage/InstalledPlugins.jsx index d0da8512a5..6f67f559d7 100644 --- a/frontend/src/MarketplacePage/InstalledPlugins.jsx +++ b/frontend/src/MarketplacePage/InstalledPlugins.jsx @@ -1,12 +1,13 @@ import React from 'react'; import cx from 'classnames'; -import { pluginsService, marketplaceService } from '@/_services'; +import { pluginsService, marketplaceService, globalDatasourceService } from '@/_services'; import { toast } from 'react-hot-toast'; import Spinner from '@/_ui/Spinner'; import { capitalizeFirstLetter, useTagsByPluginId } from './utils'; import { ConfirmDialog } from '@/_components'; import Icon from '@/_ui/Icon/SolidIcons'; import config from 'config'; +import Modal from '@/HomePage/Modal'; export const InstalledPlugins = () => { const [allPlugins, setAllPlugins] = React.useState([]); @@ -81,6 +82,7 @@ const InstalledPluginCard = ({ plugin, marketplacePlugin, fetchPlugins, isDevMod const [updating, setUpdating] = React.useState(false); const [isDeleteModalVisible, setDeleteModalVisibility] = React.useState(false); const [isDeletingPlugin, setDeletingPlugin] = React.useState(false); + const [showDependentQueriesInfo, setShowDependentQueriesInfo] = React.useState(false); const darkMode = localStorage.getItem('darkMode') === 'true'; const { id, name, pluginId } = plugin; @@ -140,6 +142,21 @@ const InstalledPluginCard = ({ plugin, marketplacePlugin, fetchPlugins, isDevMod toast.success(`${capitalizeFirstLetter(name)} reloaded`); }; + const getQueriesLinkedToMarketplacePlugin = (plugin) => { + globalDatasourceService + .getQueriesLinkedToMarketplacePlugin(plugin.id) + .then((data) => { + if (data?.dependent_queries) { + setShowDependentQueriesInfo(true); + } else { + setDeleteModalVisibility(true); + } + }) + .catch(({ error }) => { + toast.error(error); + }); + }; + const pluginDeleteMessage = ( <> Deleting {capitalizeFirstLetter(name)} plugin will result in the permanent removal of all @@ -150,6 +167,15 @@ const InstalledPluginCard = ({ plugin, marketplacePlugin, fetchPlugins, isDevMod return ( <> + setShowDependentQueriesInfo(false)} + > +
+ Cannot delete the {plugin?.name} plugin as it is used in the apps +
+
setDeleteModalVisibility(true)} + onClick={() => getQueriesLinkedToMarketplacePlugin(plugin)} > Remove
diff --git a/frontend/src/MarketplacePage/MarketplaceCard.jsx b/frontend/src/MarketplacePage/MarketplaceCard.jsx index 5ed9bb6e82..1a2c07cf61 100644 --- a/frontend/src/MarketplacePage/MarketplaceCard.jsx +++ b/frontend/src/MarketplacePage/MarketplaceCard.jsx @@ -17,6 +17,11 @@ export const MarketplaceCard = ({ id, name, repo, description, version, isInstal }, [isInstalled]); const installPlugin = async () => { + if (installed) { + toast.error(`${capitalizeFirstLetter(name)} is already installed.`); + return; + } + const body = { id, name, diff --git a/frontend/src/_components/ApiEndpointInput.jsx b/frontend/src/_components/ApiEndpointInput.jsx index 193413ee07..9c872af659 100644 --- a/frontend/src/_components/ApiEndpointInput.jsx +++ b/frontend/src/_components/ApiEndpointInput.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useLayoutEffect, useRef } from 'react'; import { openapiService } from '@/_services'; import Select from '@/_ui/Select'; import { queryManagerSelectComponentStyle } from '@/_ui/Select/styles'; @@ -110,7 +110,7 @@ const ApiEndpointInput = (props) => { if (isEmpty(paths)) return []; const pathGroups = Object.keys(paths).reduce((acc, path) => { - const operations = Object.keys(paths[path]); + const operations = Object.keys(paths[path]).filter((op) => Object.keys(operationColorMapping).includes(op)); const category = path.split('/')[2]; operations.forEach((operation) => categorizeOperations(operation, path, acc, category)); return acc; @@ -135,7 +135,7 @@ const ApiEndpointInput = (props) => { {loadingSpec && (
- {props.t('stripe', 'Please wait while we load the OpenAPI specification.')} + Please wait while we load the OpenAPI specification.
)} {options && !loadingSpec && ( @@ -227,57 +227,64 @@ const RenderParameterFields = ({ parameters, type, label, options, changeParam, } const paramLabelWithDescription = (param) => { + const label = type === 'request' ? param : param.name; + const description = type === 'request' ? parameters[param]?.description : param.description; + return ( - -
- + +
+
); }; const paramLabelWithoutDescription = (param) => { - return ( - - ); - }; + const label = type === 'request' ? param : param.name; - const paramType = (param) => { return ( -
- {type === 'query' && - param?.schema?.anyOf && - param?.schema?.anyOf.map((type, i) => - i < param.schema?.anyOf.length - 1 - ? type.type.substring(0, 3).toUpperCase() + '|' - : type.type.substring(0, 3).toUpperCase() - )} - {(type === 'path' || (type === 'query' && !param?.schema?.anyOf)) && - param?.schema?.type?.substring(0, 3).toUpperCase()} - {type === 'request' && parameters[param].type?.substring(0, 3).toUpperCase()} +
+
); }; + const paramType = (param) => { + let paramTypeValue; + + if (type === 'query') { + if (param?.schema?.anyOf) { + return ( +
+ {param.schema.anyOf.map((typeObj, i) => + i < param.schema.anyOf.length - 1 + ? (typeObj.type || '').toString().substring(0, 3).toUpperCase() + '|' + : (typeObj.type || '').toString().substring(0, 3).toUpperCase() + )} +
+ ); + } + paramTypeValue = param?.schema?.type; + } else if (type === 'path') { + paramTypeValue = param?.schema?.type; + } else if (type === 'request') { + paramTypeValue = parameters[param]?.type; + } + + const displayType = Array.isArray(paramTypeValue) ? paramTypeValue[0] : paramTypeValue; + + return
{displayType?.toString().substring(0, 3).toUpperCase() || ''}
; + }; + const paramDetails = (param) => { return ( -
- {(type === 'request' && parameters[param].description) || param?.description - ? paramLabelWithDescription(param) - : paramLabelWithoutDescription(param)} - {param.required && *} +
+
+ {(type === 'request' && parameters[param].description) || param?.description + ? paramLabelWithDescription(param) + : paramLabelWithoutDescription(param)} + {param.required && *} +
{paramType(param)}
); @@ -359,3 +366,34 @@ RenderParameterFields.propTypes = { removeParam: PropTypes.func, darkMode: PropTypes.bool, }; + +const AutoWidthText = ({ value, className }) => { + const spanRef = useRef(null); + const [width, setWidth] = useState(0); + + useLayoutEffect(() => { + if (spanRef.current) { + setWidth(spanRef.current.offsetWidth); + } + }, [value]); + + return ( +
+ + {value} + + {value} +
+ ); +}; diff --git a/frontend/src/_components/DynamicForm.jsx b/frontend/src/_components/DynamicForm.jsx index a71974afc4..bdc7f71054 100644 --- a/frontend/src/_components/DynamicForm.jsx +++ b/frontend/src/_components/DynamicForm.jsx @@ -245,7 +245,7 @@ const DynamicForm = ({ encrypted, placeholders = {}, editorType = 'basic', - specUrl = '', + spec_url = '', disabled = false, buttonText, text, @@ -486,7 +486,7 @@ const DynamicForm = ({ }; case 'react-component-api-endpoint': return { - specUrl: specUrl, + specUrl: spec_url, optionsChanged, options, darkMode, diff --git a/frontend/src/_components/Slack.jsx b/frontend/src/_components/Slack.jsx index d986bccd5f..1301572357 100644 --- a/frontend/src/_components/Slack.jsx +++ b/frontend/src/_components/Slack.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { datasourceService } from '@/_services'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-hot-toast'; @@ -15,8 +15,16 @@ const Slack = ({ isDisabled, }) => { const [authStatus, setAuthStatus] = useState(null); - const whiteLabelText = retrieveWhiteLabelText(); + const [whiteLabelText, setWhiteLabelText] = useState(''); + const plugin_id = selectedDataSource?.plugin?.id; const { t } = useTranslation(); + useEffect(() => { + async function fetchLabel() { + const text = await retrieveWhiteLabelText(); + setWhiteLabelText(text); + } + fetchLabel(); + }, []); function authGoogle() { const provider = 'slack'; @@ -29,7 +37,7 @@ const Slack = ({ } datasourceService - .fetchOauth2BaseUrl(provider) + .fetchOauth2BaseUrl(provider, plugin_id, {}) .then((data) => { const authUrl = `${data.url}&scope=${scope}&access_type=offline&prompt=select_account`; diff --git a/frontend/src/_components/Zendesk.jsx b/frontend/src/_components/Zendesk.jsx index 7d03035152..c61927c52e 100644 --- a/frontend/src/_components/Zendesk.jsx +++ b/frontend/src/_components/Zendesk.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { toast } from 'react-hot-toast'; import { useTranslation } from 'react-i18next'; import Input from '@/_ui/Input'; @@ -18,17 +18,26 @@ const Zendesk = ({ isDisabled, optionsChanged, }) => { + const [whiteLabelText, setWhiteLabelText] = useState(''); const [authStatus, setAuthStatus] = useState(null); - const whiteLabelText = retrieveWhiteLabelText(); + useEffect(() => { + async function fetchLabel() { + const text = await retrieveWhiteLabelText(); + setWhiteLabelText(text); + } + fetchLabel(); + }, []); function authZendesk() { const provider = 'zendesk'; setAuthStatus('waiting_for_url'); const scope = options?.access_type?.value === 'read' ? 'read' : 'read%20write'; + const subDomain = options?.subdomain?.value; + const client_id = options?.client_id?.value; try { - const authUrl = `https://${options?.subdomain?.value}.zendesk.com/oauth/authorizations/new?response_type=code&client_id=${options?.client_id?.value}&redirect_uri=${window.location.origin}/oauth2/authorize&scope=${scope}`; + const authUrl = `https://${subDomain}.zendesk.com/oauth/authorizations/new?response_type=code&client_id=${client_id}&redirect_uri=${window.location.origin}/oauth2/authorize&scope=${scope}`; localStorage.setItem('sourceWaitingForOAuth', 'newSource'); localStorage.setItem('currentAppEnvironmentIdForOauth', currentAppEnvironmentId); optionchanged('provider', provider).then(() => { diff --git a/frontend/src/_services/datasource.service.js b/frontend/src/_services/datasource.service.js index 9ea24e99d0..49a6ade912 100644 --- a/frontend/src/_services/datasource.service.js +++ b/frontend/src/_services/datasource.service.js @@ -98,7 +98,7 @@ function setOauth2Token(dataSourceId, body, current_organization_id) { function fetchOauth2BaseUrl(provider, plugin_id = null, source_options = {}) { const payload = { provider, ...(plugin_id && { plugin_id }), ...(source_options && { source_options }) }; const requestOptions = { - method: 'GET', + method: 'POST', headers: authHeader(), credentials: 'include', body: JSON.stringify(payload), diff --git a/frontend/src/_services/globalDatasource.service.js b/frontend/src/_services/globalDatasource.service.js index aebdcca0f1..14963c19f5 100644 --- a/frontend/src/_services/globalDatasource.service.js +++ b/frontend/src/_services/globalDatasource.service.js @@ -9,6 +9,8 @@ export const globalDatasourceService = { convertToGlobal, getDataSourceByEnvironmentId, getForApp, + getQueriesLinkedToDatasource, + getQueriesLinkedToMarketplacePlugin, }; function getForApp(organizationId, appVersionId, environmentId) { @@ -68,3 +70,15 @@ function getDataSourceByEnvironmentId(dataSourceId, environmentId) { handleResponse ); } + +function getQueriesLinkedToMarketplacePlugin(pluginId) { + const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' }; + return fetch(`${config.apiUrl}/data-sources/dependent-queries/marketplace-plugin/${pluginId}`, requestOptions).then( + handleResponse + ); +} + +function getQueriesLinkedToDatasource(dataSourceId) { + const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' }; + return fetch(`${config.apiUrl}/data-sources/dependent-queries/${dataSourceId}`, requestOptions).then(handleResponse); +} diff --git a/frontend/src/_services/user.service.js b/frontend/src/_services/user.service.js index 0046ca7c11..a2017ceb22 100644 --- a/frontend/src/_services/user.service.js +++ b/frontend/src/_services/user.service.js @@ -12,6 +12,7 @@ export const userService = { getAvatar, updateAvatar, updateUserType, + updateUserTypeInstance, getUserLimits, changeUserPassword, generateUserPassword, @@ -80,6 +81,16 @@ function updateUserType(userUpdateBody) { return fetch(`${config.apiUrl}/users/user-type`, requestOptions).then(handleResponse); } +function updateUserTypeInstance(userUpdateBody) { + const requestOptions = { + method: 'PATCH', + headers: authHeader(), + body: JSON.stringify(userUpdateBody), + credentials: 'include', + }; + return fetch(`${config.apiUrl}/users/user-type/instance`, requestOptions).then(handleResponse); +} + function changePassword(currentPassword, newPassword) { const body = { currentPassword, newPassword }; const requestOptions = { method: 'PATCH', headers: authHeader(), credentials: 'include', body: JSON.stringify(body) }; diff --git a/frontend/src/modules/dataSources/components/DataSourceManager/DataSourceManager.jsx b/frontend/src/modules/dataSources/components/DataSourceManager/DataSourceManager.jsx index ea556429dd..9f601bb412 100644 --- a/frontend/src/modules/dataSources/components/DataSourceManager/DataSourceManager.jsx +++ b/frontend/src/modules/dataSources/components/DataSourceManager/DataSourceManager.jsx @@ -21,7 +21,7 @@ import config from 'config'; import { capitalize, isEmpty } from 'lodash'; import { Card } from '@/_ui/Card'; import { withTranslation, useTranslation } from 'react-i18next'; -import { camelizeKeys, decamelizeKeys } from 'humps'; +import { camelizeKeys, decamelizeKeys, decamelize } from 'humps'; import { ButtonSolid } from '@/_ui/AppButton/AppButton'; import SolidIcon from '@/_ui/Icon/SolidIcons'; import { useAppVersionStore } from '@/_stores/appVersionStore'; @@ -249,14 +249,26 @@ class DataSourceManagerComponent extends React.Component { const scope = this.state?.scope || selectedDataSource?.scope; const parsedOptions = Object?.keys(options)?.map((key) => { - const keyMeta = dataSourceMeta.options[key]; + let keyMeta = dataSourceMeta.options[key]; + let isEncrypted = false; + if (keyMeta) { + isEncrypted = keyMeta.encrypted; + } + + // to resolve any casing mis-match + if (decamelize(key) !== key) { + const newKey = decamelize(key); + isEncrypted = dataSourceMeta.options[newKey]?.encrypted; + } + return { key: key, value: options[key].value, - encrypted: keyMeta ? keyMeta.encrypted : false, + encrypted: isEncrypted, ...(!options[key]?.value && { credential_id: options[key]?.credential_id }), }; }); + if (OAuthDs.includes(kind)) { const value = localStorage.getItem('OAuthCode'); parsedOptions.push({ key: 'code', value, encrypted: false }); diff --git a/frontend/src/modules/dataSources/components/List/index.jsx b/frontend/src/modules/dataSources/components/List/index.jsx index f3c691ad66..3ceb1864dc 100644 --- a/frontend/src/modules/dataSources/components/List/index.jsx +++ b/frontend/src/modules/dataSources/components/List/index.jsx @@ -9,6 +9,7 @@ import SolidIcon from '@/_ui/Icon/SolidIcons'; import { SearchBox } from '@/_components/SearchBox'; import { DATA_SOURCE_TYPE } from '@/_helpers/constants'; import FolderSkeleton from '@/_ui/FolderSkeleton/FolderSkeleton'; +import Modal from '@/HomePage/Modal'; export const List = ({ updateSelectedDatasource }) => { const { @@ -28,6 +29,7 @@ export const List = ({ updateSelectedDatasource }) => { const [isDeleteModalVisible, setDeleteModalVisibility] = React.useState(false); const [filteredData, setFilteredData] = useState(dataSources); const [showInput, setShowInput] = useState(false); + const [showDependentQueriesInfo, setShowDependentQueriesInfo] = useState(false); const darkMode = localStorage.getItem('darkMode') === 'true'; @@ -50,7 +52,7 @@ export const List = ({ updateSelectedDatasource }) => { setCurrentEnvironment(environments[0]); toggleDataSourceManagerModal(true); updateSelectedDatasource(selectedSource?.name); - setDeleteModalVisibility(true); + getQueriesLinkedToDatasource(selectedSource); }; const executeDataSourceDeletion = () => { @@ -74,6 +76,21 @@ export const List = ({ updateSelectedDatasource }) => { }); }; + const getQueriesLinkedToDatasource = (selectedSource) => { + globalDatasourceService + .getQueriesLinkedToDatasource(selectedSource.id) + .then((data) => { + if (data?.dependent_queries) { + setShowDependentQueriesInfo(true); + } else { + setDeleteModalVisibility(true); + } + }) + .catch(({ error }) => { + toast.error(error); + }); + }; + const cancelDeleteDataSource = () => { setDeleteModalVisibility(false); }; @@ -171,6 +188,16 @@ export const List = ({ updateSelectedDatasource }) => { )}
+ setShowDependentQueriesInfo(false)} + > +
+ Cannot delete {selectedDataSource?.name ? selectedDataSource.name : 'datasource'} as it is used in the + apps +
+
=16.0.0" } }, - "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", - "engines": { - "node": ">=14" - } - }, "node_modules/@gar/promisify": { "version": "1.1.3", "dev": true, @@ -10075,9 +10070,9 @@ } }, "node_modules/@mistralai/mistralai": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.6.0.tgz", - "integrity": "sha512-PQwGV3+n7FbE7Dp3Vnd8DAa3ffx6WuVV966Gfmf4QvzwcO3Mvxpz0SnJ/PjaZcsCwApBCZpNyQzvarAKEQLKeQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.7.0.tgz", + "integrity": "sha512-yM12kf1mGxSBCZWVvSA8gMvLG1lZ+MilvHUJskU4QWVWc+uYOgupZPRgDarPerzEp6/jm9XDR/rCO7U3ElNAOg==", "dependencies": { "zod-to-json-schema": "^3.24.1" }, @@ -11639,16 +11634,16 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, "node_modules/@qdrant/js-client-rest": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/@qdrant/js-client-rest/-/js-client-rest-1.14.0.tgz", - "integrity": "sha512-2sM2g17FSkN2sNCSeAfqxHRr+SPEVnUQLXBjVv/whm4YQ4JjZ53Jiy1iShk95G+xBf3hKBhJdj8itRnor03IYw==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@qdrant/js-client-rest/-/js-client-rest-1.14.1.tgz", + "integrity": "sha512-CkCCTDc4gCXq+hhjB3yDw9Hs/PxCJ0bKqk/LjAAmuL9+nDm/RPue4C/tGOIMlzouTQ2l6J6t+JPeM//j38VFug==", "dependencies": { "@qdrant/openapi-typescript-fetch": "1.2.6", "@sevinf/maybe": "0.5.0", - "undici": "~5.28.5" + "undici": "^6.0.0" }, "engines": { - "node": ">=18.0.0", + "node": ">=18.17.0", "pnpm": ">=8" }, "peerDependencies": { @@ -12565,6 +12560,10 @@ "resolved": "plugins/azurerepos", "link": true }, + "node_modules/@tooljet-marketplace/clickup": { + "resolved": "plugins/clickup", + "link": true + }, "node_modules/@tooljet-marketplace/cohere": { "resolved": "plugins/cohere", "link": true @@ -23425,14 +23424,11 @@ "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==" }, "node_modules/undici": { - "version": "5.28.5", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.5.tgz", - "integrity": "sha512-zICwjrDrcrUE0pyyJc1I2QzBkLM8FINsgOrt6WjA+BgajVq9Nxu2PbFFXUrAggLfDXlZGZBVZYw7WNV5KiBiBA==", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", "engines": { - "node": ">=14.0" + "node": ">=18.17" } }, "node_modules/undici-types": { @@ -23970,9 +23966,9 @@ } }, "node_modules/zod": { - "version": "3.25.13", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.13.tgz", - "integrity": "sha512-Q8mvk2iWi7rTDfpQBsu4ziE7A6AxgzJ5hzRyRYQkoV3A3niYsXVwDaP1Kbz3nWav6S+VZ6k2OznFn8ZyDHvIrg==", + "version": "3.25.30", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.30.tgz", + "integrity": "sha512-VolhdEtu6TJr/fzGuHA/SZ5ixvXqA6ADOG9VRcQ3rdOKmF5hkmcJbyaQjUH5BgmpA9gej++zYRX7zjSmdReIwA==", "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -23987,7 +23983,6 @@ } }, "plugins/anthropic": { - "name": "@tooljet-marketplace/anthropic", "version": "1.0.0", "dependencies": { "@anthropic-ai/sdk": "^0.32.1", @@ -24022,7 +24017,6 @@ } }, "plugins/azurerepos": { - "name": "@tooljet-marketplace/azurerepos", "version": "1.0.0", "dependencies": { "@tooljet-marketplace/common": "^1.0.0", @@ -24033,8 +24027,17 @@ "typescript": "^4.7.4" } }, + "plugins/clickup": { + "version": "1.0.0", + "dependencies": { + "@tooljet-marketplace/common": "^1.0.0" + }, + "devDependencies": { + "@vercel/ncc": "^0.34.0", + "typescript": "^4.7.4" + } + }, "plugins/cohere": { - "name": "@tooljet-marketplace/cohere", "version": "1.0.0", "dependencies": { "@tooljet-marketplace/common": "^1.0.0", @@ -24065,7 +24068,6 @@ } }, "plugins/gemini": { - "name": "@tooljet-marketplace/gemini", "version": "1.0.0", "dependencies": { "@google/generative-ai": "^0.21.0", @@ -24101,7 +24103,6 @@ } }, "plugins/hugging_face": { - "name": "@tooljet-marketplace/huggingface", "version": "1.0.0", "dependencies": { "@tooljet-marketplace/common": "^1.0.0" @@ -24121,7 +24122,6 @@ } }, "plugins/mistral_ai": { - "name": "@tooljet-marketplace/mistral", "version": "1.0.0", "dependencies": { "@mistralai/mistralai": "^1.4.0", @@ -24206,7 +24206,6 @@ } }, "plugins/qdrant": { - "name": "@tooljet-marketplace/qdrant", "version": "1.0.0", "dependencies": { "@qdrant/js-client-rest": "^1.12.0", @@ -24287,7 +24286,6 @@ } }, "plugins/weaviate": { - "name": "@tooljet-marketplace/weaviate", "version": "1.0.0", "dependencies": { "@tooljet-marketplace/common": "^1.0.0", @@ -24299,4 +24297,4 @@ } } } -} +} \ No newline at end of file diff --git a/marketplace/plugins/clickup/.gitignore b/marketplace/plugins/clickup/.gitignore new file mode 100644 index 0000000000..23e6609462 --- /dev/null +++ b/marketplace/plugins/clickup/.gitignore @@ -0,0 +1,5 @@ +node_modules +lib/*.d.* +lib/*.js +lib/*.js.map +dist/* \ No newline at end of file diff --git a/marketplace/plugins/clickup/README.md b/marketplace/plugins/clickup/README.md new file mode 100644 index 0000000000..d6030e2327 --- /dev/null +++ b/marketplace/plugins/clickup/README.md @@ -0,0 +1,4 @@ + +# ClickUp + +Documentation on: https://docs.tooljet.com/docs/data-sources/clickup \ No newline at end of file diff --git a/marketplace/plugins/clickup/__tests__/index.js b/marketplace/plugins/clickup/__tests__/index.js new file mode 100644 index 0000000000..6875592b04 --- /dev/null +++ b/marketplace/plugins/clickup/__tests__/index.js @@ -0,0 +1,7 @@ +'use strict'; + +const clickup = require('../lib'); + +describe('clickup', () => { + it.todo('needs tests'); +}); diff --git a/marketplace/plugins/clickup/lib/icon.svg b/marketplace/plugins/clickup/lib/icon.svg new file mode 100644 index 0000000000..2f983484c1 --- /dev/null +++ b/marketplace/plugins/clickup/lib/icon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/marketplace/plugins/clickup/lib/index.ts b/marketplace/plugins/clickup/lib/index.ts new file mode 100644 index 0000000000..dc6e051bae --- /dev/null +++ b/marketplace/plugins/clickup/lib/index.ts @@ -0,0 +1,114 @@ +import { QueryError, QueryResult, QueryService, ConnectionTestResult } from '@tooljet-marketplace/common'; +import { SourceOptions } from './types'; +import got, { Headers } from 'got'; + +export default class Clickup implements QueryService { + authHeader(token: string): Headers { + return { Authorization: token }; + } + + async run(sourceOptions: SourceOptions, queryOptions: any, dataSourceId: string): Promise { + const operation = queryOptions.operation; + const apiKey = sourceOptions.apiKey; + const baseUrl = 'https://api.clickup.com/api'; + const path = queryOptions['path']; + + const pathParams = queryOptions['params']['path']; + const queryParams = queryOptions['params']['query']; + const bodyParams = queryOptions['params']['request']; + + // Replace path params in URL + let modifiedPath = path; + for (const param of Object.keys(pathParams)) { + modifiedPath = modifiedPath.replace(`{${param}}`, pathParams[param]); + } + + const url = `${baseUrl}${modifiedPath}`; + + try { + let response; + + if (operation === 'get' || operation === 'delete') { + response = await got(url, { + method: operation, + headers: this.authHeader(apiKey), + searchParams: queryParams, + }); + } else { + // post, put, patch operations + const resolvedBodyParams = this.resolveBodyparams(bodyParams); + response = await got(url, { + method: operation, + headers: this.authHeader(apiKey), + json: resolvedBodyParams, + searchParams: queryParams, + }); + } + + return { + status: 'ok', + data: JSON.parse(response.body), + }; + } catch (err) { + const errorMessage = err.message || 'An unknown error occurred'; + const errorDetails: any = {}; + + if (err.response) { + const { statusCode, body } = err.response; + errorDetails.statusCode = statusCode; + + try { + const parsedBody = JSON.parse(body); + errorDetails.error = parsedBody.err || null; + errorDetails.code = parsedBody.ECODE || null; + } catch (parseError) { + errorDetails.rawBody = body; + } + } + + throw new QueryError('Query could not be completed', errorMessage, errorDetails); + } + } + + async testConnection(sourceOptions: SourceOptions): Promise { + const apiKey = sourceOptions.apiKey; + + try { + const response = await got('https://api.clickup.com/api/v2/user', { + headers: this.authHeader(apiKey), + }); + + const data = JSON.parse(response.body); + + if (data?.user?.id) { + return { + status: 'ok', + }; + } else { + throw new QueryError('User information not found', 'Invalid API key or insufficient permissions', {}); + } + } catch (error) { + throw new QueryError('Connection could not be established', error.response?.body || error.message, {}); + } + } + + private resolveBodyparams(bodyParams: object): object { + if (typeof bodyParams === 'string') { + return bodyParams; + } + + const expectedResult = {}; + + for (const key of Object.keys(bodyParams)) { + if (typeof bodyParams[key] === 'object') { + for (const subKey of Object.keys(bodyParams[key])) { + expectedResult[`${key}[${subKey}]`] = bodyParams[key][subKey]; + } + } else { + expectedResult[key] = bodyParams[key]; + } + } + + return expectedResult; + } +} diff --git a/marketplace/plugins/clickup/lib/manifest.json b/marketplace/plugins/clickup/lib/manifest.json new file mode 100644 index 0000000000..37eaad21a6 --- /dev/null +++ b/marketplace/plugins/clickup/lib/manifest.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://raw.githubusercontent.com/ToolJet/ToolJet/develop/plugins/schemas/manifest.schema.json", + "title": "ClickUp datasource", + "description": "Clickup plugin for task, list, and doc management", + "type": "api", + "source": { + "name": "ClickUp", + "kind": "clickup", + "exposedVariables": { + "isLoading": false, + "data": {}, + "rawData": {} + }, + "options": { + "apiKey": { + "type": "string", + "encrypted": true + } + } + }, + "defaults": {}, + "properties": { + "apiKey": { + "label": "API Key", + "key": "apiKey", + "type": "password", + "description": "Enter your Personal API Token" + } + }, + "required": [ + "apiKey" + ] +} \ No newline at end of file diff --git a/marketplace/plugins/clickup/lib/operations.json b/marketplace/plugins/clickup/lib/operations.json new file mode 100644 index 0000000000..fa2ef8b045 --- /dev/null +++ b/marketplace/plugins/clickup/lib/operations.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://raw.githubusercontent.com/ToolJet/ToolJet/develop/plugins/schemas/operations.schema.json", + "title": "ClickUp datasource", + "description": "A schema defining ClickUp datasource", + "type": "api", + "defaults": {}, + "properties": { + "operation": { + "label": "", + "key": "clickup_operation", + "type": "react-component-api-endpoint", + "description": "Single select dropdown for operation", + "spec_url": "https://developer.clickup.com/openapi/673cf4cfdca96a0019533cad" + } + } +} \ No newline at end of file diff --git a/marketplace/plugins/clickup/lib/types.ts b/marketplace/plugins/clickup/lib/types.ts new file mode 100644 index 0000000000..00e976f831 --- /dev/null +++ b/marketplace/plugins/clickup/lib/types.ts @@ -0,0 +1,3 @@ +export type SourceOptions = { + apiKey: string; +}; diff --git a/marketplace/plugins/clickup/package.json b/marketplace/plugins/clickup/package.json new file mode 100644 index 0000000000..e46d35689d --- /dev/null +++ b/marketplace/plugins/clickup/package.json @@ -0,0 +1,26 @@ +{ + "name": "@tooljet-marketplace/clickup", + "version": "1.0.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "directories": { + "lib": "lib", + "test": "__tests__" + }, + "files": [ + "lib" + ], + "scripts": { + "test": "echo \"Error: run tests from root\" && exit 1", + "build": "ncc build lib/index.ts -o dist", + "watch": "ncc build lib/index.ts -o dist --watch" + }, + "homepage": "https://github.com/tooljet/tooljet#readme", + "dependencies": { + "@tooljet-marketplace/common": "^1.0.0" + }, + "devDependencies": { + "typescript": "^4.7.4", + "@vercel/ncc": "^0.34.0" + } +} diff --git a/marketplace/plugins/clickup/tsconfig.json b/marketplace/plugins/clickup/tsconfig.json new file mode 100644 index 0000000000..a18a801b14 --- /dev/null +++ b/marketplace/plugins/clickup/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "lib" + }, + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/marketplace/plugins/jira/lib/manifest.json b/marketplace/plugins/jira/lib/manifest.json index 4d9abdb583..210e6328f8 100644 --- a/marketplace/plugins/jira/lib/manifest.json +++ b/marketplace/plugins/jira/lib/manifest.json @@ -15,6 +15,9 @@ "options": { "url": { "type": "string" + }, + "personal_token": { + "encrypted": true } } }, @@ -57,11 +60,12 @@ "key": "personal_token", "type": "password", "description": "Enter your api token", - "hint": "You can generate a personal access token from your Jira account 'Manage account'." + "hint": "You can generate a personal access token from your Jira account 'Manage account'.", + "encrypted": true } } }, "required": [ "url" ] -} +} \ No newline at end of file diff --git a/marketplace/plugins/qdrant/lib/operations.json b/marketplace/plugins/qdrant/lib/operations.json index 8d7fc70627..d71458f821 100644 --- a/marketplace/plugins/qdrant/lib/operations.json +++ b/marketplace/plugins/qdrant/lib/operations.json @@ -146,7 +146,7 @@ "height": "36px" }, "withPayload": { - "label": "Include metadata", + "label": "Include payload", "key": "withPayload", "type": "codehinter", "description": "Whether to return payload values.", @@ -163,4 +163,4 @@ } } } -} \ No newline at end of file +} diff --git a/marketplace/plugins/supabase/lib/index.ts b/marketplace/plugins/supabase/lib/index.ts index e82952a2b1..875c7bfe9a 100644 --- a/marketplace/plugins/supabase/lib/index.ts +++ b/marketplace/plugins/supabase/lib/index.ts @@ -69,7 +69,16 @@ export default class Supabase implements QueryService { } if (error) { - throw new QueryError('Query could not be completed', error, {}); + const errorMessage = error?.message || "An unknown error occurred."; + let errorDetails: any = {}; + + const supabaseError = error as any; + const { code, hint } = supabaseError; + + errorDetails.code = code; + errorDetails.hint = hint; + + throw new QueryError('Query could not be completed', errorMessage, errorDetails); } return { diff --git a/plugins/packages/firestore/lib/index.ts b/plugins/packages/firestore/lib/index.ts index 64d91c4f58..f8c39815bd 100644 --- a/plugins/packages/firestore/lib/index.ts +++ b/plugins/packages/firestore/lib/index.ts @@ -57,7 +57,18 @@ export default class FirestoreQueryService implements QueryService { break; } } catch (error) { - throw new QueryError('Query could not be completed', error.message, {}); + const errorMessage = error.message || "An unknown error occurred."; + let errorDetails: any = {}; + + if (error && error instanceof Error) { + const firestoreError = error as any; + const { code, name } = firestoreError; + + errorDetails.code = code as string; + errorDetails.name = name; + } + + throw new QueryError('Query could not be completed', errorMessage, errorDetails); } return { diff --git a/plugins/packages/grpc/lib/index.ts b/plugins/packages/grpc/lib/index.ts index 30aa514c22..a118aadfb8 100644 --- a/plugins/packages/grpc/lib/index.ts +++ b/plugins/packages/grpc/lib/index.ts @@ -46,8 +46,17 @@ export default class GRPC implements QueryService { metadata.add(sourceOptions.grpc_apikey_key, sourceOptions.grpc_apikey_value); } + let jsonMessage = {}; + if (queryOptions.jsonMessage) { + try { + jsonMessage = JSON.parse(queryOptions.jsonMessage); + } catch (e) { + throw new QueryError('Invalid JSON message', {}, {}); + } + } + const result = await new Promise((resolve, reject) => { - clientStub[rpc]({}, metadata, (err: any, response: any) => { + clientStub[rpc](jsonMessage, metadata, (err: any, response: any) => { if (err) { reject(err); } diff --git a/plugins/packages/grpc/lib/types.ts b/plugins/packages/grpc/lib/types.ts index 5b3865c609..d38528276d 100644 --- a/plugins/packages/grpc/lib/types.ts +++ b/plugins/packages/grpc/lib/types.ts @@ -11,5 +11,6 @@ export type SourceOptions = { export type QueryOptions = { operation: string; serviceName: string; + jsonMessage: string; rpc: string; }; diff --git a/plugins/packages/mariadb/lib/manifest.json b/plugins/packages/mariadb/lib/manifest.json index db1ff5afea..3d30bbda0a 100644 --- a/plugins/packages/mariadb/lib/manifest.json +++ b/plugins/packages/mariadb/lib/manifest.json @@ -19,7 +19,8 @@ "type": "string" }, "password": { - "type": "string" + "type": "string", + "encrypted": true }, "connectionLimit": { "type": "string" @@ -83,7 +84,8 @@ "label": "Password", "key": "password", "type": "password", - "description": "Enter password" + "description": "Enter password", + "encrypted": true }, "connectionLimit": { "label": "Connection Limit", diff --git a/plugins/packages/stripe/lib/operations.json b/plugins/packages/stripe/lib/operations.json index 8ec4bed410..4532254d99 100644 --- a/plugins/packages/stripe/lib/operations.json +++ b/plugins/packages/stripe/lib/operations.json @@ -1,16 +1,16 @@ { - "$schema": "https://raw.githubusercontent.com/ToolJet/ToolJet/develop/plugins/schemas/operations.schema.json", - "title": "Stripe datasource", - "description": "A schema defining stripe datasource", - "type": "api", - "defaults": {}, - "properties": { - "operation": { - "label": "", - "key": "stripe_operation", - "type": "react-component-api-endpoint", - "description": "Single select dropdown for operation", - "specUrl": "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json" - } + "$schema": "https://raw.githubusercontent.com/ToolJet/ToolJet/develop/plugins/schemas/operations.schema.json", + "title": "Stripe datasource", + "description": "A schema defining stripe datasource", + "type": "api", + "defaults": {}, + "properties": { + "operation": { + "label": "", + "key": "stripe_operation", + "type": "react-component-api-endpoint", + "description": "Single select dropdown for operation", + "spec_url": "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json" } } +} \ No newline at end of file diff --git a/plugins/packages/woocommerce/.gitignore b/plugins/packages/woocommerce/.gitignore index cf1107688f..4c5f09d7c4 100644 --- a/plugins/packages/woocommerce/.gitignore +++ b/plugins/packages/woocommerce/.gitignore @@ -1,5 +1,4 @@ node_modules lib/*.d.* lib/*.js -lib/*.js.map -lib/operations.json \ No newline at end of file +lib/*.js.map \ No newline at end of file diff --git a/plugins/packages/woocommerce/lib/operations.json b/plugins/packages/woocommerce/lib/operations.json new file mode 100644 index 0000000000..e6041e3ee5 --- /dev/null +++ b/plugins/packages/woocommerce/lib/operations.json @@ -0,0 +1,105 @@ +{ + "title": "Woocommerce datasource", + "description": "A schema defining Woocommerce datasource", + "type": "api", + "defaults": {}, + "properties": { + "resource": { + "label": "Resource", + "key": "resource", + "className": "col-md-4", + "type": "dropdown-component-flip", + "description": "Resource select", + "list": [ + { "value": "product", "name": "Product" }, + { "value": "customer", "name": "Customer" }, + { "value": "order", "name": "Order" }, + { "value": "coupon", "name": "Coupon" } + ] + }, + "customer": { + "operation": { + "label": "Operation", + "key": "operation", + "type": "dropdown-component-flip", + "description": "Single select dropdown for operation", + "list": [ + { "value": "list_customer", "name": "List all customers" }, + { "value": "update_customer", "name": "Update a customer" }, + { "value": "delete_customer", "name": "Delete a customer" }, + { "value": "batch_update_customer", "name": "Batch update customers" }, + { "value": "create_customer", "name": "Create a customer" }, + { "value": "retrieve_customer", "name": "Retrieve a customer" } + ] + }, + "list_customer": { + "page": { + "label": "Page", + "key": "page", + "type": "codehinter", + "description": "Enter page", + "width": "320px", + "height": "36px", + "className": "codehinter-plugins", + "placeholder": "", + "lineNumbers": false + }, + "context": { + "label": "Context", + "key": "context", + "type": "codehinter", + "description": "Enter context", + "width": "320px", + "height": "36px", + "className": "codehinter-plugins", + "placeholder": "", + "lineNumbers": false + } + } + }, + "product": { + "operation": { + "label": "Operation", + "key": "operation", + "type": "dropdown-component-flip", + "description": "Single select dropdown for operation", + "list": [ + { "value": "list_product", "name": "List all products" }, + { "value": "update_product", "name": "Update a product" }, + { "value": "delete_product", "name": "Delete a product" }, + { "value": "batch_update_product", "name": "Batch update products" }, + { "value": "create_product", "name": "Create a product" }, + { "value": "retrieve_product", "name": "Retrieve a product" } + ] + } + }, + "order": { + "operation": { + "label": "Operation", + "key": "operation", + "type": "dropdown-component-flip", + "description": "Single select dropdown for operation", + "list": [ + { "value": "list_order", "name": "List all orders" }, + { "value": "update_order", "name": "Update an order" }, + { "value": "delete_order", "name": "Delete an order" }, + { "value": "batch_update_order", "name": "Batch update orders" }, + { "value": "create_order", "name": "Create an order" }, + { "value": "retrieve_order", "name": "Retrieve an order" } + ] + } + }, + "coupon": { + "operation": { + "label": "Operation", + "key": "operation", + "type": "dropdown-component-flip", + "description": "Single select dropdown for operation", + "list": [ + { "value": "list_coupon", "name": "List all coupons" }, + { "value": "create_coupon", "name": "Create a coupon" } + ] + } + } + } +} diff --git a/server/.version b/server/.version index 4eba2a62eb..f982feb41b 100644 --- a/server/.version +++ b/server/.version @@ -1 +1 @@ -3.13.0 +3.14.0 diff --git a/server/migrations/1746520805456-AddResourceDataAudit.ts b/server/migrations/1746520805456-AddResourceDataAudit.ts new file mode 100644 index 0000000000..1950038c12 --- /dev/null +++ b/server/migrations/1746520805456-AddResourceDataAudit.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddResourceDataAudit1746520805456 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'audit_logs', + new TableColumn({ + name: 'resource_data', + type: 'json', + isNullable: true, + }) + ); + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/server/migrations/1747133448781-AddPluginIdUniqueConstraint.ts b/server/migrations/1747133448781-AddPluginIdUniqueConstraint.ts new file mode 100644 index 0000000000..01b0008006 --- /dev/null +++ b/server/migrations/1747133448781-AddPluginIdUniqueConstraint.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner, TableUnique } from "typeorm"; + +export class AddPluginIdUniqueConstraint1747133448781 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createUniqueConstraint( + "plugins", + new TableUnique({ + name: "UQ_plugin_pluginId", + columnNames: ["plugin_id"], + }) + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropUniqueConstraint( + "plugins", + "UQ_plugin_pluginId" + ); + } + +} diff --git a/server/scripts/preview.sh b/server/scripts/preview.sh index 6664902874..d45e6ccb60 100644 --- a/server/scripts/preview.sh +++ b/server/scripts/preview.sh @@ -1,6 +1,8 @@ #!/bin/bash set -e +redis-server /etc/redis/redis.conf & + # Fix ownership and permissions chown -R postgres:postgres /var/lib/postgresql /var/run/postgresql chmod 0700 /var/lib/postgresql/13/main diff --git a/server/src/assets/marketplace/plugins.json b/server/src/assets/marketplace/plugins.json index fe24e4a6a7..b16d3727e9 100644 --- a/server/src/assets/marketplace/plugins.json +++ b/server/src/assets/marketplace/plugins.json @@ -221,5 +221,13 @@ "id": "azurerepos", "author": "Tooljet", "timestamp": "Mon, 23 Dec 2024 11:57:30 GMT" + }, + { + "name": "ClickUp", + "description": "ClickUp plugin for task, list, and doc management", + "version": "1.0.0", + "id": "clickup", + "author": "Tooljet", + "timestamp": "Wed, 16 Apr 2025 15:31:38 GMT" } ] \ No newline at end of file diff --git a/server/src/entities/audit_log.entity.ts b/server/src/entities/audit_log.entity.ts index 8a2b8a0806..49157d1ceb 100644 --- a/server/src/entities/audit_log.entity.ts +++ b/server/src/entities/audit_log.entity.ts @@ -23,6 +23,9 @@ export class AuditLog extends BaseEntity { @Column({ name: 'resource_type', type: 'enum', enum: MODULES }) resourceType: MODULES; + @Column('simple-json', { name: 'resource_data' }) + resourceData; + @Column({ name: 'action_type' }) actionType: string; diff --git a/server/src/entities/plugin.entity.ts b/server/src/entities/plugin.entity.ts index e69924f276..3047571f87 100644 --- a/server/src/entities/plugin.entity.ts +++ b/server/src/entities/plugin.entity.ts @@ -6,9 +6,11 @@ import { OneToOne, PrimaryGeneratedColumn, UpdateDateColumn, + Unique } from 'typeorm'; import { File } from 'src/entities/file.entity'; +@Unique(['pluginId']) @Entity({ name: 'plugins' }) export class Plugin { @PrimaryGeneratedColumn() diff --git a/server/src/modules/audit-logs/interfaces/IService.ts b/server/src/modules/audit-logs/interfaces/IService.ts index aff26abaed..80702b8fdb 100644 --- a/server/src/modules/audit-logs/interfaces/IService.ts +++ b/server/src/modules/audit-logs/interfaces/IService.ts @@ -7,6 +7,6 @@ export interface IAuditLogService { perform( { userId, organizationId, resourceId, resourceType, actionType, resourceName, metadata }: AuditLogFields, manager?: EntityManager - ): Promise; + ): Promise; findPerPage(user: User, query: AuditLogsQuery): Promise; } diff --git a/server/src/modules/audit-logs/types/index.ts b/server/src/modules/audit-logs/types/index.ts index 1bde3d3ec9..6d234fd18f 100644 --- a/server/src/modules/audit-logs/types/index.ts +++ b/server/src/modules/audit-logs/types/index.ts @@ -18,10 +18,12 @@ export interface AuditLogFields { organizationId: string; resourceId: string; resourceType: MODULES; + resourceData?: object; actionType: string; resourceName?: string; ipAddress?: string; metadata?: object; + organizationIds?: Array; } export interface Features { diff --git a/server/src/modules/auth/constants/feature.ts b/server/src/modules/auth/constants/feature.ts index b0f5bcf7c9..c21c4195ea 100644 --- a/server/src/modules/auth/constants/feature.ts +++ b/server/src/modules/auth/constants/feature.ts @@ -28,12 +28,15 @@ export const FEATURES: FeaturesConfig = { }, [FEATURE_KEY.FORGOT_PASSWORD]: { isPublic: true, + auditLogsKey: 'USER_PASSWORD_FORGOT', }, [FEATURE_KEY.RESET_PASSWORD]: { isPublic: true, + auditLogsKey: 'USER_PASSWORD_RESET', }, [FEATURE_KEY.OAUTH_SIGN_IN]: { isPublic: true, + auditLogsKey: 'USER_LOGIN', }, [FEATURE_KEY.OAUTH_OPENID_CONFIGS]: { isPublic: true, @@ -43,6 +46,7 @@ export const FEATURES: FeaturesConfig = { }, [FEATURE_KEY.OAUTH_COMMON_SIGN_IN]: { isPublic: true, + auditLogsKey: 'USER_LOGIN', }, [FEATURE_KEY.OAUTH_SAML_RESPONSE]: { isPublic: true, diff --git a/server/src/modules/auth/service.ts b/server/src/modules/auth/service.ts index 7c9fb3ad89..cfea254fe0 100644 --- a/server/src/modules/auth/service.ts +++ b/server/src/modules/auth/service.ts @@ -124,6 +124,9 @@ export class AuthService implements IAuthService { organizationId: organization.id, resourceId: user.id, resourceName: user.email, + resourceData: { + auth_method: 'password', + }, }); } @@ -184,6 +187,13 @@ export class AuthService implements IAuthService { forgotPasswordToken: null, passwordRetryCount: 0, }); + const auditLogEntry = { + userId: user.id, + organizationId: user.defaultOrganizationId, + resourceId: user.id, + resourceName: user.email, + }; + RequestContext.setLocals(AUDIT_LOGS_REQUEST_CONTEXT_KEY, auditLogEntry); } } @@ -195,6 +205,13 @@ export class AuthService implements IAuthService { } const forgotPasswordToken = uuid.v4(); await this.userRepository.updateOne(user.id, { forgotPasswordToken }); + const auditLogEntry = { + userId: user.id, + organizationId: user.defaultOrganizationId, + resourceId: user.id, + resourceName: user.email, + }; + RequestContext.setLocals(AUDIT_LOGS_REQUEST_CONTEXT_KEY, auditLogEntry); this.eventEmitter.emit('emailEvent', { type: EMAIL_EVENTS.SEND_PASSWORD_RESET_EMAIL, payload: { diff --git a/server/src/modules/data-sources/ability/index.ts b/server/src/modules/data-sources/ability/index.ts index 60686e24d7..5a205ece7e 100644 --- a/server/src/modules/data-sources/ability/index.ts +++ b/server/src/modules/data-sources/ability/index.ts @@ -5,6 +5,7 @@ import { UserAllPermissions } from '@modules/app/types'; import { FEATURE_KEY } from '../constants'; import { DataSource } from '@entities/data_source.entity'; import { MODULES } from '@modules/app/constants/modules'; +import { getTooljetEdition } from '@helpers/utils.helper'; type Subjects = InferSubjects | 'all'; export type FeatureAbility = Ability<[FEATURE_KEY, Subjects]>; @@ -33,10 +34,13 @@ export class FeatureAbilityFactory extends AbilityFactory const isAllViewable = !!resourcePermissions?.isAllUsable; const dataSourceId = request?.tj_resource_id; - + const toolJetEdition = getTooljetEdition(); // Oauth end points available to all can(FEATURE_KEY.GET_OAUTH2_BASE_URL, DataSource); can(FEATURE_KEY.AUTHORIZE, DataSource); + if ((toolJetEdition == 'ee' && superAdmin) || (toolJetEdition !== 'ee' && isAdmin)) { + can(FEATURE_KEY.QUERIES_DATASOURCE_LINKED_TO_MARKETPLACE_PLUGIN, DataSource); + } if (isBuilder) { // Only builder can do scope change, Get call is there on app builder @@ -56,6 +60,7 @@ export class FeatureAbilityFactory extends AbilityFactory FEATURE_KEY.TEST_CONNECTION, FEATURE_KEY.SCOPE_CHANGE, FEATURE_KEY.GET_FOR_APP, + FEATURE_KEY.QUERIES_LINKED_TO_DATASOURCE, ], DataSource ); @@ -70,7 +75,7 @@ export class FeatureAbilityFactory extends AbilityFactory ); if (isCanDelete) { - can(FEATURE_KEY.DELETE, DataSource); + can([FEATURE_KEY.DELETE, FEATURE_KEY.QUERIES_LINKED_TO_DATASOURCE], DataSource); } if (isCanCreate) { can(FEATURE_KEY.CREATE, DataSource); diff --git a/server/src/modules/data-sources/constants/feature.ts b/server/src/modules/data-sources/constants/feature.ts index 60f6d585d6..78ded94e26 100644 --- a/server/src/modules/data-sources/constants/feature.ts +++ b/server/src/modules/data-sources/constants/feature.ts @@ -20,5 +20,7 @@ export const FEATURES: FeaturesConfig = { [FEATURE_KEY.GET_OAUTH2_BASE_URL]: {}, [FEATURE_KEY.AUTHORIZE]: {}, [FEATURE_KEY.GET_FOR_APP]: {}, + [FEATURE_KEY.QUERIES_LINKED_TO_DATASOURCE]: {}, + [FEATURE_KEY.QUERIES_DATASOURCE_LINKED_TO_MARKETPLACE_PLUGIN]: {}, }, }; diff --git a/server/src/modules/data-sources/constants/index.ts b/server/src/modules/data-sources/constants/index.ts index 7a76daf665..ac09f78c3c 100644 --- a/server/src/modules/data-sources/constants/index.ts +++ b/server/src/modules/data-sources/constants/index.ts @@ -9,6 +9,8 @@ export enum FEATURE_KEY { TEST_CONNECTION = 'TEST_CONNECTION', GET_OAUTH2_BASE_URL = 'GET_OAUTH2_BASE_URL', AUTHORIZE = 'AUTHORIZE', + QUERIES_LINKED_TO_DATASOURCE = 'QUERIES_LINKED_TO_DATASOURCE', + QUERIES_DATASOURCE_LINKED_TO_MARKETPLACE_PLUGIN = 'QUERIES_DATASOURCE_LINKED_TO_MARKETPLACE_PLUGIN', } export enum DataSourceTypes { diff --git a/server/src/modules/data-sources/controller.ts b/server/src/modules/data-sources/controller.ts index a0b1845a3d..51d1df0af6 100644 --- a/server/src/modules/data-sources/controller.ts +++ b/server/src/modules/data-sources/controller.ts @@ -111,7 +111,7 @@ export class DataSourcesController implements IDataSourcesController { @InitFeature(FEATURE_KEY.GET_OAUTH2_BASE_URL) @UseGuards(FeatureAbilityGuard) - @Get('fetch-oauth2-base-url') + @Post('fetch-oauth2-base-url') getAuthUrl(@Body() getDataSourceOauthUrlDto: GetDataSourceOauthUrlDto) { return this.dataSourcesService.getAuthUrl(getDataSourceOauthUrlDto); } @@ -129,6 +129,20 @@ export class DataSourcesController implements IDataSourcesController { return; } + @InitFeature(FEATURE_KEY.QUERIES_DATASOURCE_LINKED_TO_MARKETPLACE_PLUGIN) + @UseGuards(FeatureAbilityGuard) + @Get('dependent-queries/marketplace-plugin/:plugin_id') + async findDatasourcesAndQueriesOfMarketplacePlugin(@User() user: UserEntity, @Param('plugin_id') pluginId) { + return await this.dataSourcesService.findDatasourcesAndQueriesOfMarketplacePlugin(pluginId); + } + + @InitFeature(FEATURE_KEY.QUERIES_LINKED_TO_DATASOURCE) + @UseGuards(FeatureAbilityGuard) + @Get('dependent-queries/:datasource_id') + async findQueriesLinkedToDatasource(@User() user: UserEntity, @Param('datasource_id') datasourceId: string) { + return await this.dataSourcesService.findQueriesLinkedToDatasource(datasourceId); + } + @InitFeature(FEATURE_KEY.AUTHORIZE) @UseGuards(FeatureAbilityGuard) @Post('decrypt') diff --git a/server/src/modules/data-sources/module.ts b/server/src/modules/data-sources/module.ts index f914bbcd2e..c8d9e51b50 100644 --- a/server/src/modules/data-sources/module.ts +++ b/server/src/modules/data-sources/module.ts @@ -10,6 +10,7 @@ import { InstanceSettingsModule } from '@modules/instance-settings/module'; import { VersionRepository } from '@modules/versions/repository'; import { AppsRepository } from '@modules/apps/repository'; import { TooljetDbModule } from '@modules/tooljet-db/module'; +import { OrganizationRepository } from '@modules/organizations/repository'; import { SessionModule } from '@modules/session/module'; import { SampleDBScheduler } from './schedulers/sample-db.scheduler'; @@ -21,6 +22,7 @@ export class DataSourcesModule { const { DataSourcesUtilService } = await import(`${importPath}/data-sources/util.service`); const { PluginsServiceSelector } = await import(`${importPath}/data-sources/services/plugin-selector.service`); const { SampleDataSourceService } = await import(`${importPath}/data-sources/services/sample-ds.service`); + const { OrganizationsService } = await import(`${importPath}/organizations/service`); return { module: DataSourcesModule, @@ -42,6 +44,8 @@ export class DataSourcesModule { PluginsRepository, SampleDataSourceService, FeatureAbilityFactory, + OrganizationsService, + OrganizationRepository, SampleDBScheduler, ], controllers: [DataSourcesController], diff --git a/server/src/modules/data-sources/repository.ts b/server/src/modules/data-sources/repository.ts index e3cb9a0fb5..0bc8195d47 100644 --- a/server/src/modules/data-sources/repository.ts +++ b/server/src/modules/data-sources/repository.ts @@ -168,4 +168,26 @@ export class DataSourcesRepository extends Repository { }); }, manager || this.manager); } + + getDatasourceByPluginId(pluginId: string) { + return dbTransactionWrap((manager: EntityManager) => { + return manager.find(DataSource, { + where: { + pluginId: pluginId, + }, + relations: ['dataQueries'], + }); + }); + } + + getQueriesByDatasourceId(datasourceId) { + return dbTransactionWrap((manager: EntityManager) => { + return manager.find(DataSource, { + where: { + id: datasourceId, + }, + relations: ['dataQueries'], + }); + }); + } } diff --git a/server/src/modules/data-sources/service.ts b/server/src/modules/data-sources/service.ts index ae10f903a5..f104f228f9 100644 --- a/server/src/modules/data-sources/service.ts +++ b/server/src/modules/data-sources/service.ts @@ -20,6 +20,8 @@ import { GetQueryVariables, UpdateOptions } from './types'; import { DataSource } from '@entities/data_source.entity'; import { PluginsServiceSelector } from './services/plugin-selector.service'; import { IDataSourcesService } from './interfaces/IService'; +// import { FEATURE_KEY } from './constants'; +import { OrganizationsService } from '@modules/organizations/service'; import { RequestContext } from '@modules/request-context/service'; import { AUDIT_LOGS_REQUEST_CONTEXT_KEY } from '@modules/app/constants'; @@ -30,7 +32,8 @@ export class DataSourcesService implements IDataSourcesService { protected readonly dataSourcesUtilService: DataSourcesUtilService, protected readonly abilityService: AbilityService, protected readonly appEnvironmentsUtilService: AppEnvironmentUtilService, - protected readonly pluginsServiceSelector: PluginsServiceSelector + protected readonly pluginsServiceSelector: PluginsServiceSelector, + protected readonly organizationsService: OrganizationsService ) {} async getForApp(query: GetQueryVariables, user: User): Promise<{ data_sources: object[] }> { @@ -43,7 +46,6 @@ export class DataSourcesService implements IDataSourcesService { const dataSources = await this.dataSourcesRepository.allGlobalDS(userPermissions, user.organizationId, query ?? {}); let staticDataSources = await this.dataSourcesRepository.getAllStaticDataSources(query.appVersionId); - if (!shouldIncludeWorkflows) { // remove workflowsdefault data source from static data sources staticDataSources = staticDataSources.filter((dataSource) => dataSource.kind !== 'workflows'); @@ -176,6 +178,12 @@ export class DataSourcesService implements IDataSourcesService { if (dataSource.type === DataSourceTypes.SAMPLE) { throw new BadRequestException('Cannot delete sample data source'); } + + const result = await this.findQueriesLinkedToDatasource(dataSourceId); + if (result.dependent_queries) { + throw new BadRequestException(`Datasource can't be deleted, queries are in use`); + } + await this.dataSourcesRepository.delete(dataSourceId); // Setting data for audit logs @@ -243,4 +251,30 @@ export class DataSourcesService implements IDataSourcesService { await this.dataSourcesUtilService.authorizeOauth2(dataSource, code, user.id, environmentId, user.organizationId); return; } + + async findQueriesLinkedToDatasource(datasourceId: string) { + const dataSourceDetails = await this.dataSourcesRepository.getQueriesByDatasourceId(datasourceId); + if (dataSourceDetails.length == 0) return { datasources: 0, dependent_queries: 0 }; + + const queries = []; + dataSourceDetails.forEach((datasourceDetail) => { + const { dataQueries = [] } = datasourceDetail; + if (dataQueries.length) queries.push(...dataQueries); + }); + + return { datasources: dataSourceDetails.length, dependent_queries: queries.length }; + } + + async findDatasourcesAndQueriesOfMarketplacePlugin(pluginId: string) { + const dataSourcesByMarketplacePlugin = await this.dataSourcesRepository.getDatasourceByPluginId(pluginId); + if (!dataSourcesByMarketplacePlugin.length) return { dependent_queries: 0 }; + + const queries = []; + dataSourcesByMarketplacePlugin?.forEach((datasource) => { + if (datasource.dataQueries.length) queries.push(...datasource.dataQueries); + }); + return { + dependent_queries: queries.length, + }; + } } diff --git a/server/src/modules/data-sources/types/index.ts b/server/src/modules/data-sources/types/index.ts index 22c56c3e31..791a5b8af0 100644 --- a/server/src/modules/data-sources/types/index.ts +++ b/server/src/modules/data-sources/types/index.ts @@ -14,6 +14,8 @@ interface Features { [FEATURE_KEY.GET_OAUTH2_BASE_URL]: FeatureConfig; [FEATURE_KEY.AUTHORIZE]: FeatureConfig; [FEATURE_KEY.GET_FOR_APP]: FeatureConfig; + [FEATURE_KEY.QUERIES_LINKED_TO_DATASOURCE]: FeatureConfig; + [FEATURE_KEY.QUERIES_DATASOURCE_LINKED_TO_MARKETPLACE_PLUGIN]: FeatureConfig; } export interface FeaturesConfig { diff --git a/server/src/modules/data-sources/util.service.ts b/server/src/modules/data-sources/util.service.ts index 961f817064..03ad5ddd52 100644 --- a/server/src/modules/data-sources/util.service.ts +++ b/server/src/modules/data-sources/util.service.ts @@ -189,17 +189,16 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { /* Basic plan customer. lets update all environment options. this will help us to run the queries successfully when the user buys enterprise plan - */ - await Promise.all( - allEnvs.map(async (envToUpdate) => { - dataSource.options = ( - await this.appEnvironmentUtilService.getOptions(dataSourceId, organizationId, envToUpdate.id) - ).options; + */ - const newOptions = await this.parseOptionsForUpdate(dataSource, options, manager); - await this.appEnvironmentUtilService.updateOptions(newOptions, envToUpdate.id, dataSource.id, manager); - }) - ); + const newOptions = await this.parseOptionsForUpdate(dataSource, options, manager); + for (const env of allEnvs) { + dataSource.options = ( + await this.appEnvironmentUtilService.getOptions(dataSourceId, organizationId, env.id) + ).options; + + await this.appEnvironmentUtilService.updateOptions(newOptions, env.id, dataSource.id, manager); + } } const updatableParams = { id: dataSourceId, diff --git a/server/src/modules/onboarding/constants/feature.ts b/server/src/modules/onboarding/constants/feature.ts index dde67c3912..b43028c4db 100644 --- a/server/src/modules/onboarding/constants/feature.ts +++ b/server/src/modules/onboarding/constants/feature.ts @@ -6,6 +6,7 @@ export const FEATURES: FeaturesConfig = { [MODULES.ONBOARDING]: { [FEATURE_KEY.ACTIVATE_ACCOUNT]: { isPublic: true, + auditLogsKey: 'USER_SIGNUP', }, // Account Activation [FEATURE_KEY.SETUP_SUPER_ADMIN]: { isPublic: true, @@ -15,6 +16,7 @@ export const FEATURES: FeaturesConfig = { }, // Signup [FEATURE_KEY.ACCEPT_INVITE]: { isPublic: true, + auditLogsKey: 'USER_INVITE_REDEEM', }, // Accept Invitation [FEATURE_KEY.RESEND_INVITE]: { isPublic: true, @@ -27,6 +29,7 @@ export const FEATURES: FeaturesConfig = { }, // Verify Organization Token [FEATURE_KEY.SETUP_ACCOUNT_FROM_TOKEN]: { isPublic: true, + auditLogsKey: 'USER_SIGNUP', }, // Setup Account From Token [FEATURE_KEY.CHECK_WORKSPACE_UNIQUENESS]: { isPublic: true, diff --git a/server/src/modules/onboarding/service.ts b/server/src/modules/onboarding/service.ts index c05c9502d2..33d6f9e5a7 100644 --- a/server/src/modules/onboarding/service.ts +++ b/server/src/modules/onboarding/service.ts @@ -120,7 +120,7 @@ export class OnboardingService implements IOnboardingService { const userParams = { email, password, firstName, lastName }; // Find the default workspace - const defaultWorkspace = await this.organizationRepository. getDefaultWorkspaceOfInstance(); + const defaultWorkspace = await this.organizationRepository.getDefaultWorkspaceOfInstance(); if (existingUser) { // Handling instance and workspace level signup for existing user @@ -133,7 +133,7 @@ export class OnboardingService implements IOnboardingService { manager ); } else { - if(defaultWorkspace && !signingUpOrganization) { + if (defaultWorkspace && !signingUpOrganization) { return await this.onboardingUtilService.createUserInDefaultWorkspace( userParams, defaultWorkspace, @@ -263,7 +263,8 @@ export class OnboardingService implements IOnboardingService { throw new BadRequestException('Please enter password'); } - const activateDefaultWorkspace = (defaultWorkspace && defaultWorkspace.id === user.defaultOrganizationId) || allowPersonalWorkspace; + const activateDefaultWorkspace = + (defaultWorkspace && defaultWorkspace.id === user.defaultOrganizationId) || allowPersonalWorkspace; if (activateDefaultWorkspace) { // Getting default workspace const defaultOrganizationUser: OrganizationUser = user.organizationUsers.find( @@ -277,11 +278,11 @@ export class OnboardingService implements IOnboardingService { // Activate default workspace await this.organizationUsersUtilService.activateOrganization(defaultOrganizationUser, manager); - if(defaultWorkspace && defaultWorkspace.id === user.defaultOrganizationId){ + if (defaultWorkspace && defaultWorkspace.id === user.defaultOrganizationId) { const personalWorkspaces = await this.organizationUsersUtilService.personalWorkspaces(user.id); - for(const personalWorkspace of personalWorkspaces){ + for (const personalWorkspace of personalWorkspaces) { // if any personal workspace left. activate those - await this.organizationUsersUtilService.activateOrganization(personalWorkspace, manager); + await this.organizationUsersUtilService.activateOrganization(personalWorkspace, manager); } } @@ -362,6 +363,9 @@ export class OnboardingService implements IOnboardingService { organizationId: organization?.id, resourceId: user.id, resourceName: user.email, + resourceData: { + signup_method: 'self-signup', + }, }); await this.licenseUserService.validateUser(manager); @@ -421,6 +425,13 @@ export class OnboardingService implements IOnboardingService { } const isWorkspaceSignup = organizationUser.source === WORKSPACE_USER_SOURCE.SIGNUP; await this.licenseUserService.validateUser(manager); + const auditLogEntry = { + userId: user.id, + organizationId: organization.id, + resourceId: user.id, + resourceName: user.email, + }; + RequestContext.setLocals(AUDIT_LOGS_REQUEST_CONTEXT_KEY, auditLogEntry); return this.sessionUtilService.generateLoginResultPayload( response, user, @@ -534,6 +545,16 @@ export class OnboardingService implements IOnboardingService { Till now user doesn't have an organization. */ await this.licenseUserService.validateUser(manager); + const auditLogsData = { + userId: signupUser.id, + organizationId: signupUser.organizationUsers[0].organizationId, + resourceId: signupUser.id, + resourceName: signupUser.email, + resourceData: { + signup_method: 'invite-redemption', + }, + }; + RequestContext.setLocals(AUDIT_LOGS_REQUEST_CONTEXT_KEY, auditLogsData); return this.onboardingUtilService.processOrganizationSignup( response, signupUser, @@ -566,7 +587,6 @@ export class OnboardingService implements IOnboardingService { if (user.status !== USER_STATUS.ACTIVE) { throw new BadRequestException(getUserErrorMessages(user.status)); } - RequestContext.setLocals(AUDIT_LOGS_REQUEST_CONTEXT_KEY, { userId: user.id, organizationId: organizationUser.organizationId, diff --git a/server/src/modules/organization-users/constants/feature.ts b/server/src/modules/organization-users/constants/feature.ts index d3ee992027..27b9eb8f7e 100644 --- a/server/src/modules/organization-users/constants/feature.ts +++ b/server/src/modules/organization-users/constants/feature.ts @@ -6,12 +6,27 @@ export const FEATURES: FeaturesConfig = { [MODULES.ORGANIZATION_USER]: { [FEATURE_KEY.SUGGEST_USERS]: {}, [FEATURE_KEY.VIEW_ALL_USERS]: {}, - [FEATURE_KEY.USER_ARCHIVE_ALL]: {}, - [FEATURE_KEY.USER_ARCHIVE]: {}, - [FEATURE_KEY.USER_INVITE]: {}, + [FEATURE_KEY.USER_ARCHIVE_ALL]: { + isPublic: true, + auditLogsKey: 'USER_ARCHIVE', + }, + [FEATURE_KEY.USER_ARCHIVE]: { + isPublic: true, + auditLogsKey: 'USER_ARCHIVE', + }, + [FEATURE_KEY.USER_INVITE]: { + isPublic: true, + auditLogsKey: 'USER_INVITE', + }, [FEATURE_KEY.USER_BULK_UPLOAD]: {}, - [FEATURE_KEY.USER_UNARCHIVE]: {}, - [FEATURE_KEY.USER_UNARCHIVE_ALL]: {}, + [FEATURE_KEY.USER_UNARCHIVE]: { + isPublic: true, + auditLogsKey: 'USER_UNARCHIVE', + }, + [FEATURE_KEY.USER_UNARCHIVE_ALL]: { + isPublic: true, + auditLogsKey: 'USER_UNARCHIVE', + }, [FEATURE_KEY.USER_UPDATE]: {}, }, }; diff --git a/server/src/modules/organization-users/controller.ts b/server/src/modules/organization-users/controller.ts index 27d1d20b84..43d6c78af9 100644 --- a/server/src/modules/organization-users/controller.ts +++ b/server/src/modules/organization-users/controller.ts @@ -90,14 +90,14 @@ export class OrganizationUsersController implements IOrganizationUsersController if (user.id === userId) { throw new NotAcceptableException('Self archive not allowed'); } - await this.organizationUsersService.archiveFromAll(userId); + await this.organizationUsersService.archiveFromAll(userId, user); return; } @InitFeature(FEATURE_KEY.USER_UNARCHIVE_ALL) @Post(':userId/unarchive-all') async unarchiveAll(@User() user: UserEntity, @Param('userId') userId: string) { - await this.organizationUsersService.unarchiveUser(userId); + await this.organizationUsersService.unarchiveUser(userId, user); return; } diff --git a/server/src/modules/organization-users/interfaces/IService.ts b/server/src/modules/organization-users/interfaces/IService.ts index 57adc96882..bbed4de3c2 100644 --- a/server/src/modules/organization-users/interfaces/IService.ts +++ b/server/src/modules/organization-users/interfaces/IService.ts @@ -6,8 +6,8 @@ import { UpdateOrgUserDto } from '../dto'; export interface IOrganizationUsersService { updateOrgUser(organizationUserId: string, user: User, updateOrgUserDto: UpdateOrgUserDto): Promise; archive(id: string, organizationId: string, user?: User): Promise; - archiveFromAll(userId: string): Promise; - unarchiveUser(userId: string): Promise; + archiveFromAll(userId: string, user: User): Promise; + unarchiveUser(userId: string, user: User): Promise; unarchive(user: User, id: string, organizationId: string): Promise; inviteNewUser(currentUser: User, inviteNewUserDto: InviteNewUserDto): Promise; bulkUploadUsers(currentUser: User, fileStream: any, res: Response): Promise; diff --git a/server/src/modules/organization-users/service.ts b/server/src/modules/organization-users/service.ts index c4ed443dfa..467b5e9539 100644 --- a/server/src/modules/organization-users/service.ts +++ b/server/src/modules/organization-users/service.ts @@ -24,6 +24,9 @@ import { Response } from 'express'; import { UserCsvRow } from './interfaces'; import { IOrganizationUsersService } from './interfaces/IService'; import { UpdateOrgUserDto } from './dto'; +import { RequestContext } from '@modules/request-context/service'; +import { AUDIT_LOGS_REQUEST_CONTEXT_KEY } from '@modules/app/constants'; +import { Organization } from '@entities/organization.entity'; @Injectable() export class OrganizationUsersService implements IOrganizationUsersService { constructor( @@ -38,7 +41,6 @@ export class OrganizationUsersService implements IOrganizationUsersService { async updateOrgUser(organizationUserId: string, user: User, updateOrgUserDto: UpdateOrgUserDto) { const { firstName, lastName, addGroups, role, userMetadata } = updateOrgUserDto; - const organizationUser = await this.organizationUsersRepository.findOne({ where: { id: organizationUserId, organizationId: user.organizationId }, }); @@ -81,35 +83,84 @@ export class OrganizationUsersService implements IOrganizationUsersService { } async archive(id: string, organizationId: string, user?: User): Promise { - const organizationUser = await this.organizationUsersRepository.findOneOrFail({ - where: { id, organizationId }, - relations: ['user'], - }); + await dbTransactionWrap(async (manager: EntityManager) => { + const organizationUser = await manager.findOneOrFail(OrganizationUser, { + where: { id, organizationId }, + relations: ['user'], + }); - await this.organizationUsersUtilService.throwErrorIfUserIsLastActiveAdmin(organizationUser?.user, organizationId); - await this.organizationUsersRepository.update(id, { - status: WORKSPACE_USER_STATUS.ARCHIVED, - invitationToken: null, + await this.organizationUsersUtilService.throwErrorIfUserIsLastActiveAdmin(organizationUser?.user, organizationId); + await manager.update(OrganizationUser, id, { + status: WORKSPACE_USER_STATUS.ARCHIVED, + invitationToken: null, + }); + const organization = await manager.findOne(Organization, { + where: { id: organizationUser.organizationId }, + }); + const auditLogEntry = { + userId: user.id, + organizationId: user.defaultOrganizationId, + resourceId: user.id, + resourceName: organizationUser.user.email, + resourceData: { + archived_user: { + id: organizationUser.userId, + email: organizationUser.user.email, + first_name: organizationUser.user.firstName, + last_name: organizationUser.user.lastName, + }, + archived_user_workspace: { + workspace_name: organization.name, + workspace_id: organization.id, + }, + }, + }; + + RequestContext.setLocals(AUDIT_LOGS_REQUEST_CONTEXT_KEY, auditLogEntry); }); } - async archiveFromAll(userId: string): Promise { + async archiveFromAll(userId: string, user: User): Promise { await dbTransactionWrap(async (manager: EntityManager) => { + const archivedUserWorkspaces = await manager.find(OrganizationUser, { + where: { userId }, + relations: ['user'], + }); await manager.update( OrganizationUser, { userId }, { status: WORKSPACE_USER_STATUS.ARCHIVED, invitationToken: null } ); await this.organizationUsersUtilService.updateUserStatus(userId, USER_STATUS.ARCHIVED, manager); + const organizationIds = archivedUserWorkspaces.map((user) => user.organizationId); + const auditLogEntry = { + userId: user.id, + organizationIds: organizationIds, + resourceId: user.id, + resourceName: archivedUserWorkspaces[0].user.email, + resourceData: { + archived_user: { + id: archivedUserWorkspaces[0].userId, + email: archivedUserWorkspaces[0].user.email, + first_name: archivedUserWorkspaces[0].user.firstName, + last_name: archivedUserWorkspaces[0].user.lastName, + }, + }, + }; + RequestContext.setLocals(AUDIT_LOGS_REQUEST_CONTEXT_KEY, auditLogEntry); }); } - async unarchiveUser(userId: string): Promise { + async unarchiveUser(userId: string, user: User): Promise { await dbTransactionWrap(async (manager: EntityManager) => { const targetUser = await manager.findOneOrFail(User, { where: { id: userId }, select: ['id', 'status', 'invitationToken', 'source'], }); + const unarchivedUserWorkspaces = await manager.find(OrganizationUser, { + where: { userId }, + relations: ['user'], + }); const { status, invitationToken } = targetUser; /* Special case. what if the user is archived when the status is invited. we were changing status to active before */ const updatedStatus = @@ -117,6 +168,22 @@ export class OrganizationUsersService implements IOrganizationUsersService { await this.organizationUsersUtilService.updateUserStatus(userId, updatedStatus, manager); await this.licenseUserService.validateUser(manager); await this.licenseOrganizationService.validateOrganization(manager); + const organizationIds = unarchivedUserWorkspaces.map((user) => user.organizationId); + const auditLogEntry = { + userId: user.id, + organizationIds: organizationIds, + resourceId: user.id, + resourceName: unarchivedUserWorkspaces[0].user.email, + resourceData: { + unarchived_user: { + id: unarchivedUserWorkspaces[0].userId, + email: unarchivedUserWorkspaces[0].user.email, + first_name: unarchivedUserWorkspaces[0].user.firstName, + last_name: unarchivedUserWorkspaces[0].user.lastName, + }, + }, + }; + RequestContext.setLocals(AUDIT_LOGS_REQUEST_CONTEXT_KEY, auditLogEntry); }); } @@ -144,6 +211,29 @@ export class OrganizationUsersService implements IOrganizationUsersService { await this.licenseUserService.validateUser(manager); await this.licenseOrganizationService.validateOrganization(manager); + const organization = await manager.findOne(Organization, { + where: { id: organizationUser.organizationId }, + }); + const auditLogEntry = { + userId: user.id, + organizationId: user.defaultOrganizationId, + resourceId: user.id, + resourceName: organizationUser.user.email, + resourceData: { + unarchived_user: { + id: organizationUser.userId, + email: organizationUser.user.email, + first_name: organizationUser.user.firstName, + last_name: organizationUser.user.lastName, + }, + unarchived_user_workspace: { + workspace_name: organization.name, + workspace_id: organization.id, + }, + }, + }; + + RequestContext.setLocals(AUDIT_LOGS_REQUEST_CONTEXT_KEY, auditLogEntry); }); if (organizationUser.user.invitationToken) { @@ -160,6 +250,7 @@ export class OrganizationUsersService implements IOrganizationUsersService { sender: user.firstName, }, }); + return; } diff --git a/server/src/modules/organization-users/util.service.ts b/server/src/modules/organization-users/util.service.ts index c040e2cff7..ba6deb3d65 100644 --- a/server/src/modules/organization-users/util.service.ts +++ b/server/src/modules/organization-users/util.service.ts @@ -1,7 +1,7 @@ import { User } from '@entities/user.entity'; import { dbTransactionWrap } from '@helpers/database.helper'; import { fullName, generateNextNameAndSlug } from '@helpers/utils.helper'; -import { EntityManager } from 'typeorm'; +import { EntityManager, In } from 'typeorm'; import { getUserStatusAndSource, lifecycleEvents, @@ -31,8 +31,6 @@ import { UserDetailsService } from './services/user-details.service'; import { FetchUserResponse, InvitedUserType, RoleUpdate, UserFilterOptions } from './types'; import { GroupPermissionsRepository } from '@modules/group-permissions/repository'; import { ERROR_HANDLER, ERROR_HANDLER_TITLE } from '@modules/organizations/constants'; -import { MODULE_INFO } from '@modules/app/constants/module-info'; -import { MODULES } from '@modules/app/constants/modules'; import { INSTANCE_USER_SETTINGS } from '@modules/instance-settings/constants'; import { OrganizationRepository } from '@modules/organizations/repository'; import * as uuid from 'uuid'; @@ -512,11 +510,33 @@ export class OrganizationUsersUtilService implements IOrganizationUsersUtilServi !user || !!user.invitationToken ); + const groupsArray = []; + if (inviteNewUserDto.groups && inviteNewUserDto.groups.length > 0) { + const groupQuery = { + organizationId: currentOrganization.id, + id: In(inviteNewUserDto.groups), + }; + const orgGroupPermissions = await this.groupPermissionsRepository.find({ + where: groupQuery, + select: ['id', 'name'], + }); + groupsArray.push(...orgGroupPermissions.map((group) => group.name)); + } RequestContext.setLocals(AUDIT_LOGS_REQUEST_CONTEXT_KEY, { userId: currentUser.id, organizationId: currentOrganization.id, - resourceId: currentOrganization.id, + resourceId: updatedUser.id, resourceName: updatedUser.email, + resourceData: { + invited_user: { + id: updatedUser.id, + email: updatedUser.email, + first_name: updatedUser.firstName, + last_name: updatedUser.lastName, + role: inviteNewUserDto.role, + group: groupsArray, + }, + }, }); return organizationUser; diff --git a/server/src/modules/plugins/ability/index.ts b/server/src/modules/plugins/ability/index.ts index 1bdc0c1a96..9c31bb44e0 100644 --- a/server/src/modules/plugins/ability/index.ts +++ b/server/src/modules/plugins/ability/index.ts @@ -4,6 +4,7 @@ import { AbilityFactory } from '@modules/app/ability-factory'; import { UserAllPermissions } from '@modules/app/types'; import { FEATURE_KEY } from '../constants'; import { Plugin } from '@entities/plugin.entity'; +import { getTooljetEdition } from '@helpers/utils.helper'; type Subjects = InferSubjects | 'all'; export type FeatureAbility = Ability<[FEATURE_KEY, Subjects]>; @@ -16,17 +17,14 @@ export class FeatureAbilityFactory extends AbilityFactory protected defineAbilityFor(can: AbilityBuilder['can'], UserAllPermissions: UserAllPermissions): void { const { superAdmin, isAdmin, isBuilder } = UserAllPermissions; + const toolJetEdition = getTooljetEdition(); + if ((toolJetEdition == 'ee' && superAdmin) || (toolJetEdition !== 'ee' && isAdmin)) { + can([FEATURE_KEY.UNINSTALL_PLUGINS, FEATURE_KEY.DELETE], Plugin); + } + if (superAdmin || isAdmin || isBuilder) { - // Admin, super admin and Builder can do all operations can( - [ - FEATURE_KEY.INSTALL, - FEATURE_KEY.UPDATE, - FEATURE_KEY.DELETE, - FEATURE_KEY.INSTALL_DEPENDENT_PLUGINS, - FEATURE_KEY.UNINSTALL_PLUGINS, - FEATURE_KEY.DEPENDENT_PLUGINS, - ], + [FEATURE_KEY.INSTALL, FEATURE_KEY.UPDATE, FEATURE_KEY.INSTALL_DEPENDENT_PLUGINS, FEATURE_KEY.DEPENDENT_PLUGINS], Plugin ); } diff --git a/server/src/modules/plugins/module.ts b/server/src/modules/plugins/module.ts index ccc59f525e..a8e8b7f7ec 100644 --- a/server/src/modules/plugins/module.ts +++ b/server/src/modules/plugins/module.ts @@ -2,6 +2,7 @@ import { FilesRepository } from '@modules/files/repository'; import { getImportPath } from '@modules/app/constants'; import { DynamicModule } from '@nestjs/common'; import { FeatureAbilityFactory } from './ability'; +import { DataSourcesRepository } from '@modules/data-sources/repository'; export class PluginsModule { static async register(configs: { IS_GET_CONTEXT: boolean }): Promise { @@ -13,7 +14,7 @@ export class PluginsModule { return { module: PluginsModule, controllers: [PluginsController], - providers: [PluginsService, FilesRepository, PluginsUtilService, FeatureAbilityFactory], + providers: [PluginsService, FilesRepository, PluginsUtilService, FeatureAbilityFactory, DataSourcesRepository], exports: [PluginsUtilService], }; } diff --git a/server/src/modules/plugins/service.ts b/server/src/modules/plugins/service.ts index 48398d5ebe..ab1c7fc2bf 100644 --- a/server/src/modules/plugins/service.ts +++ b/server/src/modules/plugins/service.ts @@ -1,4 +1,4 @@ -import { InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { CreatePluginDto, UpdatePluginDto } from './dto'; import { PluginsUtilService } from './util.service'; import { dbTransactionWrap } from '@helpers/database.helper'; @@ -10,17 +10,25 @@ import { FilesRepository } from '@modules/files/repository'; import { IPluginsService } from './interfaces/IService'; import * as path from 'path'; import { Injectable } from '@nestjs/common'; +import { DataSourcesRepository } from '@modules/data-sources/repository'; const fs = require('fs'); @Injectable() export class PluginsService implements IPluginsService { constructor( protected readonly pluginsUtilService: PluginsUtilService, - protected readonly fileRepository: FilesRepository + protected readonly fileRepository: FilesRepository, + protected readonly dataSourcesRepository: DataSourcesRepository ) {} async install(body: CreatePluginDto) { - const { id, repo } = body; + const { id, repo, name } = body; + + const existingPlugin = await dbTransactionWrap((manager: EntityManager) => { + return manager.findOne(Plugin, { where: { pluginId: id } }); + }); + if (existingPlugin) throw new BadRequestException(`Plugin '${name}' is already installed.`); + const [index, operations, icon, manifest, version] = await this.pluginsUtilService.fetchPluginFiles(id, repo); let shouldCreate = false; @@ -47,7 +55,7 @@ export class PluginsService implements IPluginsService { async findOne(id: string) { return dbTransactionWrap((manager: EntityManager) => { - return manager.find(Plugin, { where: { id } }); + return manager.findOne(Plugin, { where: { id } }); }); } @@ -57,7 +65,18 @@ export class PluginsService implements IPluginsService { return await this.pluginsUtilService.upgrade(id, body, version, { index, operations, icon, manifest }); } - remove(id: string) { + async remove(id: string) { + const dataSourcesByMarketplacePlugin = await this.dataSourcesRepository.getDatasourceByPluginId(id); + if (dataSourcesByMarketplacePlugin.length) { + const queries = []; + dataSourcesByMarketplacePlugin?.forEach((datasource) => { + if (datasource.dataQueries.length) queries.push(...datasource.dataQueries); + }); + if (queries.length) { + throw new InternalServerErrorException(`Plugin can't be removed, queries of plugin are in use`); + } + } + return dbTransactionWrap((manager: EntityManager) => { return manager.delete(Plugin, id); }); diff --git a/server/src/modules/profile/constants/feature.ts b/server/src/modules/profile/constants/feature.ts index 0ef0b4f1c9..9b5ba2b08d 100644 --- a/server/src/modules/profile/constants/feature.ts +++ b/server/src/modules/profile/constants/feature.ts @@ -4,9 +4,18 @@ import { FeaturesConfig } from '../types'; export const FEATURES: FeaturesConfig = { [MODULES.PROFILE]: { - [FEATURE_KEY.UPDATE_AVATAR]: {}, + [FEATURE_KEY.UPDATE_AVATAR]: { + isPublic: true, + auditLogsKey: 'USER_PROFILE_UPDATE', + }, [FEATURE_KEY.GET]: {}, - [FEATURE_KEY.UPDATE]: {}, - [FEATURE_KEY.UPDATE_PASSWORD]: {}, + [FEATURE_KEY.UPDATE]: { + isPublic: true, + auditLogsKey: 'USER_PROFILE_UPDATE', + }, + [FEATURE_KEY.UPDATE_PASSWORD]: { + isPublic: true, + auditLogsKey: 'USER_PASSWORD_UPDATE', + }, }, }; diff --git a/server/src/modules/profile/service.ts b/server/src/modules/profile/service.ts index 9ac2979568..17ab95d476 100644 --- a/server/src/modules/profile/service.ts +++ b/server/src/modules/profile/service.ts @@ -7,6 +7,8 @@ import { ProfileUtilService } from '@modules/profile/util.service'; import { User } from '@entities/user.entity'; import { IProfileService } from '@modules/profile/interfaces/IService'; import { File } from '@entities/file.entity'; +import { RequestContext } from '@modules/request-context/service'; +import { AUDIT_LOGS_REQUEST_CONTEXT_KEY } from '@modules/app/constants'; @Injectable() export class ProfileService implements IProfileService { @@ -25,19 +27,76 @@ export class ProfileService implements IProfileService { async addAvatar(userId: string, imageBuffer: Buffer, filename: string): Promise { return dbTransactionWrap(async (manager: EntityManager) => { - return this.serviceUtils.addAvatar(userId, imageBuffer, filename, manager); + const user = await this.userRepository.getUser({ + id: userId, + }); + const avatar = await this.serviceUtils.addAvatar(userId, imageBuffer, filename, manager); + const auditLogData = { + userId: user.id, + organizationId: user.defaultOrganizationId, + resourceId: user.id, + resourceName: user.email, + resourceData: { + previous_user_details: { + avatar_id: user.avatarId, + }, + updated_user_details: { + avatar_id: avatar.id, + }, + }, + }; + RequestContext.setLocals(AUDIT_LOGS_REQUEST_CONTEXT_KEY, auditLogData); + return avatar; }); } async updateUserPassword(userId: string, password: string): Promise { - await this.userRepository.updateOne(userId, { - password, - passwordRetryCount: 0, + return dbTransactionWrap(async (manager: EntityManager) => { + const user = await manager.findOneOrFail(User, { + where: { id: userId }, + }); + await this.userRepository.updateOne( + userId, + { + password, + passwordRetryCount: 0, + }, + manager + ); + const auditLogEntry = { + userId: user.id, + organizationId: user.defaultOrganizationId, + resourceId: user.id, + resourceName: user.email, + }; + RequestContext.setLocals(AUDIT_LOGS_REQUEST_CONTEXT_KEY, auditLogEntry); }); } async updateUserName(userId: string, updateUserDto: ProfileUpdateDto): Promise { - const { first_name: firstName, last_name: lastName } = updateUserDto; - await this.userRepository.updateOne(userId, { firstName, lastName }); + return dbTransactionWrap(async (manager: EntityManager) => { + const user = await manager.findOneOrFail(User, { + where: { id: userId }, + }); + const { first_name: firstName, last_name: lastName } = updateUserDto; + await this.userRepository.updateOne(userId, { firstName, lastName }, manager); + const auditLogData = { + userId: user.id, + organizationId: user.defaultOrganizationId, + resourceId: user.id, + resourceName: user.email, + resourceData: { + previous_user_details: { + first_name: user.firstName, + last_name: user.lastName, + }, + updated_user_details: { + first_name: firstName, + last_name: lastName, + }, + }, + }; + RequestContext.setLocals(AUDIT_LOGS_REQUEST_CONTEXT_KEY, auditLogData); + }); } } diff --git a/server/src/modules/profile/util.service.ts b/server/src/modules/profile/util.service.ts index 671ef60a85..2631627e2b 100644 --- a/server/src/modules/profile/util.service.ts +++ b/server/src/modules/profile/util.service.ts @@ -8,7 +8,7 @@ import { CreateFileDto } from '@modules/files/dto/index'; @Injectable() export class ProfileUtilService implements IProfileUtilService { - constructor(protected readonly filesRepository: FilesRepository, protected userRepository: UserRepository) {} + constructor(protected readonly filesRepository: FilesRepository, protected readonly userRepository: UserRepository) {} async addAvatar(userId: string, imageBuffer: Buffer, filename: string, manager?: EntityManager): Promise { const user = await this.userRepository.getUser({ @@ -31,6 +31,7 @@ export class ProfileUtilService implements IProfileUtilService { if (currentAvatarId) { await this.filesRepository.removeOne(currentAvatarId, manager); } + return avatar; } } diff --git a/server/src/modules/session/constants/feature.ts b/server/src/modules/session/constants/feature.ts index fd482bcfd6..9c887c9355 100644 --- a/server/src/modules/session/constants/feature.ts +++ b/server/src/modules/session/constants/feature.ts @@ -4,7 +4,10 @@ import { FeaturesConfig } from '../types'; export const FEATURES: FeaturesConfig = { [MODULES.SESSION]: { - [FEATURE_KEY.LOG_OUT]: {}, + [FEATURE_KEY.LOG_OUT]: { + isPublic: true, + auditLogsKey: 'USER_LOGOUT', + }, [FEATURE_KEY.GET_INVITED_USER_SESSION]: { isPublic: true, }, diff --git a/server/src/modules/session/service.ts b/server/src/modules/session/service.ts index ba0b089aa9..3aa0aedaf4 100644 --- a/server/src/modules/session/service.ts +++ b/server/src/modules/session/service.ts @@ -19,6 +19,8 @@ import { OrganizationRepository } from '@modules/organizations/repository'; import { OrganizationUsersRepository } from '@modules/organization-users/repository'; import { fullName, generateOrgInviteURL, isSuperAdmin } from '@helpers/utils.helper'; import { decamelizeKeys } from 'humps'; +import { RequestContext } from '@modules/request-context/service'; +import { AUDIT_LOGS_REQUEST_CONTEXT_KEY } from '@modules/app/constants'; @Injectable() export class SessionService { @@ -34,6 +36,17 @@ export class SessionService { response.clearCookie('tj_auth_token'); await dbTransactionWrap(async (manager: EntityManager) => { await manager.delete(UserSessions, { id: sessionId, userId }); + const user = await manager.findOneOrFail(User, { + where: { id: userId }, + }); + + const auditLogData = { + userId: user.id, + organizationId: user.defaultOrganizationId, + resourceId: user.id, + resourceName: user.email, + }; + RequestContext.setLocals(AUDIT_LOGS_REQUEST_CONTEXT_KEY, auditLogData); }); } diff --git a/server/src/modules/session/util.service.ts b/server/src/modules/session/util.service.ts index 282a6179e5..fcfb61d9f0 100644 --- a/server/src/modules/session/util.service.ts +++ b/server/src/modules/session/util.service.ts @@ -44,6 +44,7 @@ export class SessionUtilService { protected readonly encryptionService: EncryptionService, protected readonly jwtService: JwtService ) {} + async terminateAllSessions(userId: string): Promise { await dbTransactionWrap(async (manager: EntityManager) => { await manager.delete(UserSessions, { userId }); @@ -333,7 +334,7 @@ export class SessionUtilService { }) : null; - const noWorkspaceAttachedInTheSession = await this.checkUserWorkspaceStatus(user.id) && !isSuperAdmin(user); + const noWorkspaceAttachedInTheSession = (await this.checkUserWorkspaceStatus(user.id)) && !isSuperAdmin(user); const isAllWorkspacesArchived = await this.#isAllWorkspacesArchivedBySuperAdmin(user.id); const onboardingFlags = await this.#onboardingFlags(user); const metadata = await this.metadataUtilService.fetchMetadata(); @@ -367,7 +368,7 @@ export class SessionUtilService { async #onboardingFlags(user: User) { let isFirstUserOnboardingCompleted = true; - let isOnboardingCompleted = true; + const isOnboardingCompleted = true; // const isOnboardingQuestionsEnabled = // this.configService.get('ENABLE_ONBOARDING_QUESTIONS_FOR_ALL_SIGN_UPS') === 'true'; diff --git a/server/src/modules/users/constants/features.ts b/server/src/modules/users/constants/features.ts index cc68400c25..1524e517a1 100644 --- a/server/src/modules/users/constants/features.ts +++ b/server/src/modules/users/constants/features.ts @@ -5,8 +5,21 @@ import { FeaturesConfig } from '../types'; export const FEATURES: FeaturesConfig = { [MODULES.USER]: { [FEATURE_KEY.GET_ALL_USERS]: {}, - [FEATURE_KEY.UPDATE_USER_TYPE]: {}, - [FEATURE_KEY.AUTO_UPDATE_USER_PASSWORD]: {}, - [FEATURE_KEY.CHANGE_USER_PASSWORD]: {}, + [FEATURE_KEY.UPDATE_USER_TYPE]: { + isPublic: true, + auditLogsKey: 'USER_DETAILS_UPDATE', + }, + [FEATURE_KEY.UPDATE_USER_TYPE_INSTANCE]: { + isPublic: true, + auditLogsKey: 'SET_AS_SUPERADMIN', + }, + [FEATURE_KEY.AUTO_UPDATE_USER_PASSWORD]: { + isPublic: true, + auditLogsKey: 'USER_PASSWORD_RESET', + }, + [FEATURE_KEY.CHANGE_USER_PASSWORD]: { + isPublic: true, + auditLogsKey: 'USER_PASSWORD_RESET', + }, }, }; diff --git a/server/src/modules/users/constants/index.ts b/server/src/modules/users/constants/index.ts index 0808b21fc1..9e14e275cf 100644 --- a/server/src/modules/users/constants/index.ts +++ b/server/src/modules/users/constants/index.ts @@ -3,4 +3,5 @@ export enum FEATURE_KEY { UPDATE_USER_TYPE = 'UPDATE_USER_TYPE', AUTO_UPDATE_USER_PASSWORD = 'AUTO_UPDATE_USER_PASSWORD', CHANGE_USER_PASSWORD = 'CHANGE_USER_PASSWORD', + UPDATE_USER_TYPE_INSTANCE = 'UPDATE_USER_TYPE_INSTANCE', } diff --git a/server/src/modules/users/controller.ts b/server/src/modules/users/controller.ts index 145320a22e..31d62ac5de 100644 --- a/server/src/modules/users/controller.ts +++ b/server/src/modules/users/controller.ts @@ -2,19 +2,20 @@ import { Controller, NotFoundException } from '@nestjs/common'; import { UpdateUserTypeDto } from '@modules/onboarding/dto/user.dto'; import { IUserController } from './interfaces/IController'; import { ChangePasswordDto } from './dto'; +import { User } from '@entities/user.entity'; @Controller('users') export class UsersController implements IUserController { getAllUsers(query: { page?: number; searchText?: string; status?: string }): Promise { throw new NotFoundException(); } - updateUserType(updateUserTypeDto: UpdateUserTypeDto): Promise { + updateUserType(updateUserTypeDto: UpdateUserTypeDto, user: User): Promise { throw new NotFoundException(); } - autoUpdateUserPassword(userId: string): Promise<{ newPassword: string }> { + autoUpdateUserPassword(userId: string, user: User): Promise<{ newPassword: string }> { throw new NotFoundException(); } - changeUserPassword(userId: string, changePasswordDto: ChangePasswordDto): Promise { + changeUserPassword(userId: string, changePasswordDto: ChangePasswordDto, user: User): Promise { throw new NotFoundException(); } } diff --git a/server/src/modules/users/dto/index.ts b/server/src/modules/users/dto/index.ts index d7b5b1a450..ea2a8f58f0 100644 --- a/server/src/modules/users/dto/index.ts +++ b/server/src/modules/users/dto/index.ts @@ -11,12 +11,6 @@ export class UpdateUserTypeDto { @MaxLength(100) userId: string; - @IsNotEmpty() - @IsString() - @Transform(({ value }) => sanitizeInput(value)) - @MaxLength(100) - userType: USER_TYPE; - @IsOptional() @IsString() @Transform(({ value }) => sanitizeInput(value)) @@ -27,6 +21,19 @@ export class UpdateUserTypeDto { @Transform(({ value }) => sanitizeInput(value)) lastName: string; } +export class UpdateUserTypeInstanceDto { + @IsNotEmpty() + @IsString() + @Transform(({ value }) => sanitizeInput(value)) + @MaxLength(100) + userId: string; + + @IsNotEmpty() + @IsString() + @Transform(({ value }) => sanitizeInput(value)) + @MaxLength(100) + userType: USER_TYPE; +} @Exclude() export class AllUserResponse { diff --git a/server/src/modules/users/interfaces/IController.ts b/server/src/modules/users/interfaces/IController.ts index 793b7d2738..80b9ce9119 100644 --- a/server/src/modules/users/interfaces/IController.ts +++ b/server/src/modules/users/interfaces/IController.ts @@ -1,11 +1,12 @@ +import { User } from '@entities/user.entity'; import { UpdateUserTypeDto, ChangePasswordDto } from '../dto'; export interface IUserController { getAllUsers(query: { page?: number; searchText?: string; status?: string }): Promise; - updateUserType(updateUserTypeDto: UpdateUserTypeDto): Promise; + updateUserType(updateUserTypeDto: UpdateUserTypeDto, user: User): Promise; - autoUpdateUserPassword(userId: string): Promise<{ newPassword: string }>; + autoUpdateUserPassword(userId: string, user: User): Promise<{ newPassword: string }>; - changeUserPassword(userId: string, changePasswordDto: ChangePasswordDto): Promise; + changeUserPassword(userId: string, changePasswordDto: ChangePasswordDto, user: User): Promise; } diff --git a/server/src/modules/users/interfaces/IService.ts b/server/src/modules/users/interfaces/IService.ts index fe3cd48469..9676fd049e 100644 --- a/server/src/modules/users/interfaces/IService.ts +++ b/server/src/modules/users/interfaces/IService.ts @@ -1,3 +1,4 @@ +import { User } from '@entities/user.entity'; import { AllUserResponse, UpdateUserTypeDto } from '@modules/onboarding/dto/user.dto'; export interface IUsersService { @@ -6,9 +7,9 @@ export interface IUsersService { users: AllUserResponse[]; }>; - updateUserType(updateUserTypeDto: UpdateUserTypeDto): Promise; + updateUserType(updateUserTypeDto: UpdateUserTypeDto, user: User): Promise; - updatePassword(userId: string, password: string): Promise; + updatePassword(userId: string, user: User, password: string): Promise; - autoUpdateUserPassword(userId: string): Promise; + autoUpdateUserPassword(userId: string, user: User): Promise; } diff --git a/server/src/modules/users/module.ts b/server/src/modules/users/module.ts index 963eb08fca..9ba2992f32 100644 --- a/server/src/modules/users/module.ts +++ b/server/src/modules/users/module.ts @@ -3,6 +3,7 @@ import { DynamicModule } from '@nestjs/common'; import { UserRepository } from './repository'; import { SessionModule } from '@modules/session/module'; import { FeatureAbilityFactory } from './ability'; +import { OrganizationUsersRepository } from '@modules/organization-users/repository'; export class UsersModule { static async register(configs?: { IS_GET_CONTEXT: boolean }): Promise { @@ -15,7 +16,7 @@ export class UsersModule { module: UsersModule, imports: [await SessionModule.register(configs)], controllers: [UsersController], - providers: [UsersService, UserRepository, UsersUtilService, FeatureAbilityFactory], + providers: [UsersService, UserRepository, UsersUtilService, FeatureAbilityFactory, OrganizationUsersRepository], exports: [UsersUtilService], }; } diff --git a/server/src/modules/users/service.ts b/server/src/modules/users/service.ts index c1870f1f4d..a422f99a68 100644 --- a/server/src/modules/users/service.ts +++ b/server/src/modules/users/service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { AllUserResponse, UpdateUserTypeDto } from '@modules/onboarding/dto/user.dto'; import { IUsersService } from '@modules/users/interfaces/IService'; +import { User } from '@entities/user.entity'; @Injectable() export class UsersService implements IUsersService { @@ -9,13 +10,13 @@ export class UsersService implements IUsersService { ): Promise<{ meta: { total_pages: number; total_count: number; current_page: number }; users: AllUserResponse[] }> { throw new Error('Method not implemented.'); } - updateUserType(updateUserTypeDto: UpdateUserTypeDto): Promise { + updateUserType(updateUserTypeDto: UpdateUserTypeDto, user: User): Promise { throw new Error('Method not implemented.'); } - updatePassword(userId: string, password: string): Promise { + updatePassword(userId: string, user: User, password: string): Promise { throw new Error('Method not implemented.'); } - autoUpdateUserPassword(userId: string): Promise { + autoUpdateUserPassword(userId: string, user: User): Promise { throw new Error('Method not implemented.'); } } diff --git a/server/src/modules/users/types.ts b/server/src/modules/users/types.ts index c05429936a..37319f7b43 100644 --- a/server/src/modules/users/types.ts +++ b/server/src/modules/users/types.ts @@ -7,6 +7,7 @@ interface Features { [FEATURE_KEY.UPDATE_USER_TYPE]: FeatureConfig; [FEATURE_KEY.AUTO_UPDATE_USER_PASSWORD]: FeatureConfig; [FEATURE_KEY.CHANGE_USER_PASSWORD]: FeatureConfig; + [FEATURE_KEY.UPDATE_USER_TYPE_INSTANCE]: FeatureConfig; } export interface FeaturesConfig {