diff --git a/.eslintignore b/.eslintignore index 54824be5c7..579bd55947 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,4 +7,5 @@ packages/server/client packages/builder/.routify packages/builder/cypress/support/queryLevelTransformerFunction.js packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js -packages/builder/cypress/reports \ No newline at end of file +packages/builder/cypress/reports +packages/sdk/sdk \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/epic.md b/.github/ISSUE_TEMPLATE/epic.md new file mode 100644 index 0000000000..b8cf652125 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/epic.md @@ -0,0 +1,24 @@ +--- +name: Epic +about: Plan a new project +title: '' +labels: epic +assignees: '' + +--- + +## Description +Brief summary of what this Epic is, whether it's a larger project, goal, or user story. Describe the job to be done, which persona this Epic is mainly for, or if more multiple, break it down by user and job story. + +## Spec +Link to confluence spec + +## Teams and Stakeholders +Describe who needs to be kept up-to-date about this Epic, included in discussions, or updated along the way. Stakeholders can be both in Product/Engineering, as well as other teams like Customer Success who might want to keep customers updated on the Epic project. + + +## Workflow +- [ ] Spec Created and pasted above +- [ ] Product Review +- [ ] Designs created +- [ ] Individual Tasks created and assigned to Epic diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index e940e6fa10..475bd4f66a 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -23,6 +23,15 @@ jobs: build: runs-on: ubuntu-latest + services: + couchdb: + image: ibmcom/couchdb3 + env: + COUCHDB_PASSWORD: budibase + COUCHDB_USER: budibase + ports: + - 4567:5984 + strategy: matrix: node-version: [14.x] @@ -53,9 +62,8 @@ jobs: name: codecov-umbrella verbose: true - # TODO: parallelise this - - name: Cypress run - uses: cypress-io/github-action@v2 - with: - install: false - command: yarn test:e2e:ci + - name: QA Core Integration Tests + run: | + cd qa-core + yarn + yarn api:test:ci \ No newline at end of file diff --git a/.github/workflows/deploy-single-image.yml b/.github/workflows/deploy-single-image.yml index 8bf8f232c5..cd16574eea 100644 --- a/.github/workflows/deploy-single-image.yml +++ b/.github/workflows/deploy-single-image.yml @@ -4,8 +4,6 @@ on: workflow_dispatch: env: - BASE_BRANCH: ${{ github.event.pull_request.base.ref}} - BRANCH: ${{ github.event.pull_request.head.ref }} CI: true PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }} REGISTRY_URL: registry.hub.docker.com @@ -17,6 +15,11 @@ jobs: matrix: node-version: [14.x] steps: + - name: Fail if branch is not master + if: github.ref != 'refs/heads/master' + run: | + echo "Ref is not master, you must run this job from master." + exit 1 - name: "Checkout" uses: actions/checkout@v2 - name: Use Node.js ${{ matrix.node-version }} @@ -28,8 +31,6 @@ jobs: - name: Setup Docker Buildx id: buildx uses: docker/setup-buildx-action@v1 - - name: Install Pro - run: yarn install:pro $BRANCH $BASE_BRANCH - name: Run Yarn run: yarn - name: Run Yarn Bootstrap diff --git a/.github/workflows/release-develop.yml b/.github/workflows/release-develop.yml index 57e65c734e..21c74851e1 100644 --- a/.github/workflows/release-develop.yml +++ b/.github/workflows/release-develop.yml @@ -46,7 +46,8 @@ jobs: - run: yarn - run: yarn bootstrap - run: yarn lint - - run: yarn build + - run: yarn build + - run: yarn build:sdk - run: yarn test - name: Configure AWS Credentials diff --git a/.github/workflows/release-selfhost.yml b/.github/workflows/release-selfhost.yml index da064f3e32..d78180fdc7 100644 --- a/.github/workflows/release-selfhost.yml +++ b/.github/workflows/release-selfhost.yml @@ -3,10 +3,6 @@ name: Budibase Release Selfhost on: workflow_dispatch: -env: - BRANCH: ${{ github.event.pull_request.head.ref }} - BASE_BRANCH: ${{ github.event.pull_request.base.ref}} - jobs: release: runs-on: ubuntu-latest @@ -54,9 +50,6 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }} SELFHOST_TAG: latest - - name: Install Pro - run: yarn install:pro $BRANCH $BASE_BRANCH - - name: Bootstrap and build (CLI) run: | yarn diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 961082e1ef..de288dd7db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,7 @@ jobs: - run: yarn bootstrap - run: yarn lint - run: yarn build + - run: yarn build:sdk - run: yarn test - name: Configure AWS Credentials diff --git a/.gitignore b/.gitignore index 32c6faf980..e1d3e6db0e 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,7 @@ typings/ # dotenv environment variables file .env +!qa-core/.env !hosting/.env hosting/.generated-nginx.dev.conf hosting/proxy/.generated-nginx.prod.conf diff --git a/.prettierignore b/.prettierignore index bbeff65da7..3a381d255e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,3 +9,4 @@ packages/server/src/definitions/openapi.ts packages/builder/.routify packages/builder/cypress/support/queryLevelTransformerFunction.js packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js +packages/sdk/sdk \ No newline at end of file diff --git a/README.md b/README.md index 1dec1737da..bd38610566 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Budibase is open-source - licensed as GPL v3. This should fill you with confiden

### Load data or start from scratch -Budibase pulls in data from multiple sources, including MongoDB, CouchDB, PostgreSQL, MySQL, Airtable, S3, DynamoDB, or a REST API. And unlike other platforms, with Budibase you can start from scratch and create business apps with no data sources. [Request new data sources](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas). +Budibase pulls in data from multiple sources, including MongoDB, CouchDB, PostgreSQL, MySQL, Airtable, S3, DynamoDB, or a REST API. And unlike other platforms, with Budibase you can start from scratch and create business apps with no datasources. [Request new datasources](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).

Budibase data diff --git a/charts/budibase/templates/app-service-deployment.yaml b/charts/budibase/templates/app-service-deployment.yaml index 6517133a58..f72d1aef03 100644 --- a/charts/budibase/templates/app-service-deployment.yaml +++ b/charts/budibase/templates/app-service-deployment.yaml @@ -78,6 +78,8 @@ spec: key: objectStoreSecret - name: MINIO_URL value: {{ .Values.services.objectStore.url }} + - name: PLUGIN_BUCKET_NAME + value: {{ .Values.services.objectStore.pluginBucketName | default "plugins" | quote }} - name: PORT value: {{ .Values.services.apps.port | quote }} {{ if .Values.services.worker.publicApiRateLimitPerSecond }} diff --git a/charts/budibase/templates/worker-service-deployment.yaml b/charts/budibase/templates/worker-service-deployment.yaml index 902e9ac03d..b1c6110d95 100644 --- a/charts/budibase/templates/worker-service-deployment.yaml +++ b/charts/budibase/templates/worker-service-deployment.yaml @@ -77,6 +77,8 @@ spec: key: objectStoreSecret - name: MINIO_URL value: {{ .Values.services.objectStore.url }} + - name: PLUGIN_BUCKET_NAME + value: {{ .Values.services.objectStore.pluginBucketName | default "plugins" | quote }} - name: PORT value: {{ .Values.services.worker.port | quote }} - name: MULTI_TENANCY diff --git a/charts/budibase/values.yaml b/charts/budibase/values.yaml index a15504d58c..5c4004cb57 100644 --- a/charts/budibase/values.yaml +++ b/charts/budibase/values.yaml @@ -76,6 +76,7 @@ affinity: {} globals: appVersion: "latest" budibaseEnv: PRODUCTION + tenantFeatureFlags: "*:LICENSING,*:USER_GROUPS" enableAnalytics: "1" sentryDSN: "" posthogToken: "phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU" diff --git a/docs/DEV-SETUP-DEBIAN.md b/docs/DEV-SETUP-DEBIAN.md index 88a124708c..9edd8286cb 100644 --- a/docs/DEV-SETUP-DEBIAN.md +++ b/docs/DEV-SETUP-DEBIAN.md @@ -1,12 +1,15 @@ ## Dev Environment on Debian 11 -### Install Node +### Install NVM & Node 14 +NVM documentation: https://github.com/nvm-sh/nvm#installing-and-updating -Budibase requires a recent version of node (14+): +Install NVM ``` -curl -sL https://deb.nodesource.com/setup_16.x | sudo bash - -apt -y install nodejs -node -v +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash +``` +Install Node 14 +``` +nvm install 14 ``` ### Install npm requirements @@ -31,7 +34,7 @@ This setup process was tested on Debian 11 (bullseye) with version numbers show - Docker: 20.10.5 - Docker-Compose: 1.29.2 -- Node: v16.15.1 +- Node: v14.20.1 - Yarn: 1.22.19 - Lerna: 5.1.4 diff --git a/docs/DEV-SETUP-MACOSX.md b/docs/DEV-SETUP-MACOSX.md index c5990e58da..d9e2dcad6a 100644 --- a/docs/DEV-SETUP-MACOSX.md +++ b/docs/DEV-SETUP-MACOSX.md @@ -11,7 +11,7 @@ through brew. ### Install Node -Budibase requires a recent version of node (14+): +Budibase requires a recent version of node 14: ``` brew install node npm node -v @@ -38,7 +38,7 @@ This setup process was tested on Mac OSX 12 (Monterey) with version numbers show - Docker: 20.10.14 - Docker-Compose: 2.6.0 -- Node: 18.3.0 +- Node: 14.20.1 - Yarn: 1.22.19 - Lerna: 5.1.4 @@ -59,4 +59,7 @@ The dev version will be available on port 10000 i.e. http://127.0.0.1:10000/builder/admin | **NOTE**: If you are working on a M1 Apple Silicon, you will need to uncomment `# platform: linux/amd64` line in -[hosting/docker-compose-dev.yaml](../hosting/docker-compose.dev.yaml) \ No newline at end of file +[hosting/docker-compose-dev.yaml](../hosting/docker-compose.dev.yaml) + +### Troubleshooting +If there are errors with the `yarn setup` command, you can try installing nvm and node 14. This is the same as the instructions for Debian 11. diff --git a/docs/DEV-SETUP-WINDOWS.md b/docs/DEV-SETUP-WINDOWS.md new file mode 100644 index 0000000000..c5608b7567 --- /dev/null +++ b/docs/DEV-SETUP-WINDOWS.md @@ -0,0 +1,81 @@ +## Dev Environment on Windows 10/11 (WSL2) + + +### Install WSL with Ubuntu LTS + +Enable WSL 2 on Windows 10/11 for docker support. +``` +wsl --set-default-version 2 +``` +Install Ubuntu LTS. +``` +wsl --install Ubuntu +``` + +Or follow the instruction here: +https://learn.microsoft.com/en-us/windows/wsl/install + +### Install Docker in windows +Download the installer from docker and install it. + +Check this url for more detailed instructions: +https://docs.docker.com/desktop/install/windows-install/ + +You should follow the next steps from within the Ubuntu terminal. + +### Install NVM & Node 14 +NVM documentation: https://github.com/nvm-sh/nvm#installing-and-updating + +Install NVM +``` +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash +``` +Install Node 14 +``` +nvm install 14 +``` + + +### Install npm requirements + +``` +npm install -g yarn jest lerna +``` + +### Clone the repo +``` +git clone https://github.com/Budibase/budibase.git +``` + +### Check Versions + +This setup process was tested on Windows 11 with version numbers show below. Your mileage may vary using anything else. + +- Docker: 20.10.7 +- Docker-Compose: 2.10.2 +- Node: v14.20.1 +- Yarn: 1.22.19 +- Lerna: 5.5.4 + +### Build + +``` +cd budibase +yarn setup +``` +The yarn setup command runs several build steps i.e. +``` +node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev +``` +So this command will actually run the application in dev mode. It creates .env files under `./packages/server` and `./packages/worker` and runs docker containers for each service via docker-compose. + +The dev version will be available on port 10000 i.e. + +http://127.0.0.1:10000/builder/admin + +### Working with the code +Here are the instructions to work on the application from within Visual Studio Code (in Windows) through the WSL. All the commands and files are within the Ubuntu system and it should run as if you were working on a Linux machine. + +https://code.visualstudio.com/docs/remote/wsl + +Note you will be able to run the application from within the WSL terminal and you will be able to access the application from the a browser in Windows. \ No newline at end of file diff --git a/examples/nextjs-api-sales/definitions/openapi.ts b/examples/nextjs-api-sales/definitions/openapi.ts index 4f4ad45fc6..7f7f6befec 100644 --- a/examples/nextjs-api-sales/definitions/openapi.ts +++ b/examples/nextjs-api-sales/definitions/openapi.ts @@ -348,7 +348,7 @@ export interface paths { } } responses: { - /** Returns the created table, including the ID which has been generated for it. This can be internal or external data sources. */ + /** Returns the created table, including the ID which has been generated for it. This can be internal or external datasources. */ 200: { content: { "application/json": components["schemas"]["tableOutput"] @@ -959,7 +959,7 @@ export interface components { query: { /** @description The ID of the query. */ _id: string - /** @description The ID of the data source the query belongs to. */ + /** @description The ID of the datasource the query belongs to. */ datasourceId?: string /** @description The bindings which are required to perform this query. */ parameters?: string[] @@ -983,7 +983,7 @@ export interface components { data: { /** @description The ID of the query. */ _id: string - /** @description The ID of the data source the query belongs to. */ + /** @description The ID of the datasource the query belongs to. */ datasourceId?: string /** @description The bindings which are required to perform this query. */ parameters?: string[] diff --git a/hosting/scripts/build-target-paths.sh b/hosting/scripts/build-target-paths.sh index fce768e2ee..67e1765ca8 100644 --- a/hosting/scripts/build-target-paths.sh +++ b/hosting/scripts/build-target-paths.sh @@ -4,12 +4,17 @@ echo ${TARGETBUILD} > /buildtarget.txt if [[ "${TARGETBUILD}" = "aas" ]]; then # Azure AppService uses /home for persisent data & SSH on port 2222 DATA_DIR=/home + WEBSITES_ENABLE_APP_SERVICE_STORAGE=true mkdir -p $DATA_DIR/{search,minio,couch} mkdir -p $DATA_DIR/couch/{dbs,views} chown -R couchdb:couchdb $DATA_DIR/couch/ apt update apt-get install -y openssh-server - sed -i "s/#Port 22/Port 2222/" /etc/ssh/sshd_config + echo "root:Docker!" | chpasswd + mkdir -p /tmp + chmod +x /tmp/ssh_setup.sh \ + && (sleep 1;/tmp/ssh_setup.sh 2>&1 > /dev/null) + cp /etc/sshd_config /etc/ssh/sshd_config /etc/init.d/ssh restart sed -i "s#DATA_DIR#/home#g" /opt/clouseau/clouseau.ini sed -i "s#DATA_DIR#/home#g" /opt/couchdb/etc/local.ini diff --git a/hosting/single/Dockerfile b/hosting/single/Dockerfile index 476a6e5e94..58796f0362 100644 --- a/hosting/single/Dockerfile +++ b/hosting/single/Dockerfile @@ -19,8 +19,8 @@ ADD packages/worker . RUN node /pinVersions.js && yarn && yarn build && /cleanup.sh FROM couchdb:3.2.1 -# TARGETARCH can be amd64 or arm e.g. docker build --build-arg TARGETARCH=amd64 -ARG TARGETARCH=amd64 +ARG TARGETARCH +ENV TARGETARCH $TARGETARCH #TARGETBUILD can be set to single (for single docker image) or aas (for azure app service) # e.g. docker build --build-arg TARGETBUILD=aas .... ARG TARGETBUILD=single @@ -29,23 +29,8 @@ ENV TARGETBUILD $TARGETBUILD COPY --from=build /app /app COPY --from=build /worker /worker -ENV \ - APP_PORT=4001 \ - ARCHITECTURE=amd \ - BUDIBASE_ENVIRONMENT=PRODUCTION \ - CLUSTER_PORT=80 \ - # CUSTOM_DOMAIN=budi001.custom.com \ - DATA_DIR=/data \ - DEPLOYMENT_ENVIRONMENT=docker \ - MINIO_URL=http://localhost:9000 \ - POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU \ - REDIS_URL=localhost:6379 \ - SELF_HOSTED=1 \ - TARGETBUILD=$TARGETBUILD \ - WORKER_PORT=4002 \ - WORKER_URL=http://localhost:4002 \ - APPS_URL=http://localhost:4001 - +# ENV CUSTOM_DOMAIN=budi001.custom.com \ +# See runner.sh for Env Vars # These secret env variables are generated by the runner at startup # their values can be overriden by the user, they will be written # to the .env file in the /data directory for use later on @@ -117,6 +102,8 @@ RUN chmod +x ./build-target-paths.sh # Script below sets the path for storing data based on $DATA_DIR # For Azure App Service install SSH & point data locations to /home +ADD hosting/single/ssh/sshd_config /etc/ +ADD hosting/single/ssh/ssh_setup.sh /tmp RUN /build-target-paths.sh # cleanup cache @@ -124,6 +111,8 @@ RUN yarn cache clean -f EXPOSE 80 EXPOSE 443 +# Expose port 2222 for SSH on Azure App Service build +EXPOSE 2222 VOLUME /data # setup letsencrypt certificate diff --git a/hosting/single/runner.sh b/hosting/single/runner.sh index 77015d75ee..6770d27ee0 100644 --- a/hosting/single/runner.sh +++ b/hosting/single/runner.sh @@ -1,18 +1,37 @@ #!/bin/bash declare -a ENV_VARS=("COUCHDB_USER" "COUCHDB_PASSWORD" "DATA_DIR" "MINIO_ACCESS_KEY" "MINIO_SECRET_KEY" "INTERNAL_API_KEY" "JWT_SECRET" "REDIS_PASSWORD") - +declare -a DOCKER_VARS=("APP_PORT" "APPS_URL" "ARCHITECTURE" "BUDIBASE_ENVIRONMENT" "CLUSTER_PORT" "DEPLOYMENT_ENVIRONMENT" "MINIO_URL" "NODE_ENV" "POSTHOG_TOKEN" "REDIS_URL" "SELF_HOSTED" "WORKER_PORT" "WORKER_URL" "TENANT_FEATURE_FLAGS" "ACCOUNT_PORTAL_URL") +# Check the env vars set in Dockerfile have come through, AAS seems to drop them +[[ -z "${APP_PORT}" ]] && export APP_PORT=4001 +[[ -z "${ARCHITECTURE}" ]] && export ARCHITECTURE=amd +[[ -z "${BUDIBASE_ENVIRONMENT}" ]] && export BUDIBASE_ENVIRONMENT=PRODUCTION +[[ -z "${CLUSTER_PORT}" ]] && export CLUSTER_PORT=80 +[[ -z "${DEPLOYMENT_ENVIRONMENT}" ]] && export DEPLOYMENT_ENVIRONMENT=docker +[[ -z "${MINIO_URL}" ]] && export MINIO_URL=http://localhost:9000 +[[ -z "${NODE_ENV}" ]] && export NODE_ENV=production +[[ -z "${POSTHOG_TOKEN}" ]] && export POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU +[[ -z "${TENANT_FEATURE_FLAGS}" ]] && export TENANT_FEATURE_FLAGS="*:LICENSING,*:USER_GROUPS" +[[ -z "${ACCOUNT_PORTAL_URL}" ]] && export ACCOUNT_PORTAL_URL=https://account.budibase.app +[[ -z "${REDIS_URL}" ]] && export REDIS_URL=localhost:6379 +[[ -z "${SELF_HOSTED}" ]] && export SELF_HOSTED=1 +[[ -z "${WORKER_PORT}" ]] && export WORKER_PORT=4002 +[[ -z "${WORKER_URL}" ]] && export WORKER_URL=http://localhost:4002 +[[ -z "${APPS_URL}" ]] && export APPS_URL=http://localhost:4001 +# export CUSTOM_DOMAIN=budi001.custom.com # Azure App Service customisations if [[ "${TARGETBUILD}" = "aas" ]]; then DATA_DIR=/home + WEBSITES_ENABLE_APP_SERVICE_STORAGE=true /etc/init.d/ssh start else DATA_DIR=${DATA_DIR:-/data} fi if [ -f "${DATA_DIR}/.env" ]; then - export $(cat ${DATA_DIR}/.env | xargs) + # Read in the .env file and export the variables + for LINE in $(cat ${DATA_DIR}/.env); do export $LINE; done fi -# first randomise any unset environment variables +# randomise any unset environment variables for ENV_VAR in "${ENV_VARS[@]}" do temp=$(eval "echo \$$ENV_VAR") @@ -30,11 +49,18 @@ if [ ! -f "${DATA_DIR}/.env" ]; then temp=$(eval "echo \$$ENV_VAR") echo "$ENV_VAR=$temp" >> ${DATA_DIR}/.env done + for ENV_VAR in "${DOCKER_VARS[@]}" + do + temp=$(eval "echo \$$ENV_VAR") + echo "$ENV_VAR=$temp" >> ${DATA_DIR}/.env + done echo "COUCH_DB_URL=${COUCH_DB_URL}" >> ${DATA_DIR}/.env fi -export COUCH_DB_URL=http://$COUCHDB_USER:$COUCHDB_PASSWORD@localhost:5984 - +# Read in the .env file and export the variables +for LINE in $(cat ${DATA_DIR}/.env); do export $LINE; done +ln -s ${DATA_DIR}/.env /app/.env +ln -s ${DATA_DIR}/.env /worker/.env # make these directories in runner, incase of mount mkdir -p ${DATA_DIR}/couch/{dbs,views} mkdir -p ${DATA_DIR}/minio diff --git a/hosting/single/ssh/ssh_setup.sh b/hosting/single/ssh/ssh_setup.sh new file mode 100644 index 0000000000..0af0b6d7ad --- /dev/null +++ b/hosting/single/ssh/ssh_setup.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +ssh-keygen -A + +#prepare run dir +if [ ! -d "/var/run/sshd" ]; then + mkdir -p /var/run/sshd +fi \ No newline at end of file diff --git a/hosting/single/ssh/sshd_config b/hosting/single/ssh/sshd_config new file mode 100644 index 0000000000..7eb5df953a --- /dev/null +++ b/hosting/single/ssh/sshd_config @@ -0,0 +1,12 @@ +Port 2222 +ListenAddress 0.0.0.0 +LoginGraceTime 180 +X11Forwarding yes +Ciphers aes128-cbc,3des-cbc,aes256-cbc,aes128-ctr,aes192-ctr,aes256-ctr +MACs hmac-sha1,hmac-sha1-96 +StrictModes yes +SyslogFacility DAEMON +PasswordAuthentication yes +PermitEmptyPasswords no +PermitRootLogin yes +Subsystem sftp internal-sftp diff --git a/lerna.json b/lerna.json index 1089acf87a..a8276de8cc 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.3.15-alpha.3", + "version": "2.0.24-alpha.0", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/package.json b/package.json index 4c24e0025b..579e86802e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "js-yaml": "^4.1.0", "kill-port": "^1.6.1", "lerna": "3.14.1", + "madge": "^5.0.1", "prettier": "^2.3.1", "prettier-plugin-svelte": "^2.3.0", "rimraf": "^3.0.2", @@ -25,6 +26,8 @@ "bootstrap": "lerna bootstrap && lerna link && ./scripts/link-dependencies.sh", "build": "lerna run build", "build:dev": "lerna run prebuild && tsc --build --watch --preserveWatchOutput", + "build:sdk": "lerna run build:sdk", + "deps:circular": "madge packages/server/dist/index.js packages/worker/src/index.ts packages/backend-core/dist/src/index.js packages/cli/src/index.js --circular", "release": "lerna publish ${RELEASE_VERSION_TYPE:-patch} --yes --force-publish && yarn release:pro", "release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop --exact && yarn release:pro:develop", "release:pro": "bash scripts/pro/release.sh", @@ -45,8 +48,8 @@ "lint:eslint": "eslint packages", "lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\"", "lint": "yarn run lint:eslint && yarn run lint:prettier", - "lint:fix:eslint": "eslint --fix packages", - "lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\"", + "lint:fix:eslint": "eslint --fix packages qa-core", + "lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --write \"qa-core/**/*.{js,ts,svelte}\"", "lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint", "test:e2e": "lerna run cy:test --stream", "test:e2e:ci": "lerna run cy:ci --stream", diff --git a/packages/backend-core/context.js b/packages/backend-core/context.js index aaa0f56f92..c6fa87a337 100644 --- a/packages/backend-core/context.js +++ b/packages/backend-core/context.js @@ -6,6 +6,7 @@ const { updateAppId, doInAppContext, doInTenant, + doInContext, } = require("./src/context") const identity = require("./src/context/identity") @@ -19,4 +20,5 @@ module.exports = { doInAppContext, doInTenant, identity, + doInContext, } diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index 9a770d3887..90b645ff2c 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/backend-core", - "version": "1.3.15-alpha.3", + "version": "2.0.24-alpha.0", "description": "Budibase backend core libraries used in server and worker", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -20,11 +20,12 @@ "test:watch": "jest --watchAll" }, "dependencies": { - "@budibase/types": "1.3.15-alpha.3", + "@budibase/types": "2.0.24-alpha.0", "@shopify/jest-koa-mocks": "5.0.1", "@techpass/passport-openidconnect": "0.3.2", "aws-sdk": "2.1030.0", "bcrypt": "5.0.1", + "bcryptjs": "2.4.3", "dotenv": "16.0.1", "emitter-listener": "1.1.2", "ioredis": "4.28.0", diff --git a/packages/backend-core/src/constants.js b/packages/backend-core/src/constants.js index 172e66e603..44c271a4f8 100644 --- a/packages/backend-core/src/constants.js +++ b/packages/backend-core/src/constants.js @@ -7,6 +7,7 @@ exports.Cookies = { CurrentApp: "budibase:currentapp", Auth: "budibase:auth", Init: "budibase:init", + ACCOUNT_RETURN_URL: "budibase:account:returnurl", DatasourceAuth: "budibase:datasourceauth", OIDC_CONFIG: "budibase:oidc:config", } diff --git a/packages/backend-core/src/context/index.ts b/packages/backend-core/src/context/index.ts index 78ce764d55..35eeee608b 100644 --- a/packages/backend-core/src/context/index.ts +++ b/packages/backend-core/src/context/index.ts @@ -2,7 +2,7 @@ import env from "../environment" import { SEPARATOR, DocumentType } from "../db/constants" import cls from "./FunctionContext" import { dangerousGetDB, closeDB } from "../db" -import { baseGlobalDBName } from "../tenancy/utils" +import { baseGlobalDBName } from "../db/tenancy" import { IdentityContext } from "@budibase/types" import { DEFAULT_TENANT_ID as _DEFAULT_TENANT_ID } from "../constants" import { ContextKey } from "./constants" @@ -65,7 +65,16 @@ export const getTenantIDFromAppID = (appId: string) => { } } -// used for automations, API endpoints should always be in context already +export const doInContext = async (appId: string, task: any) => { + // gets the tenant ID from the app ID + const tenantId = getTenantIDFromAppID(appId) + return doInTenant(tenantId, async () => { + return doInAppContext(appId, async () => { + return task() + }) + }) +} + export const doInTenant = (tenantId: string | null, task: any) => { // make sure default always selected in single tenancy if (!env.MULTI_TENANCY) { @@ -226,6 +235,10 @@ export const getAppId = () => { } } +export const isTenancyEnabled = () => { + return env.MULTI_TENANCY +} + /** * Opens the app database based on whatever the request * contained, dev or prod. diff --git a/packages/backend-core/src/db/constants.ts b/packages/backend-core/src/db/constants.ts index fd464ba5fb..a61e8a2af2 100644 --- a/packages/backend-core/src/db/constants.ts +++ b/packages/backend-core/src/db/constants.ts @@ -19,6 +19,8 @@ export enum ViewName { ROUTING = "screen_routes", AUTOMATION_LOGS = "automation_logs", ACCOUNT_BY_EMAIL = "account_by_email", + PLATFORM_USERS_LOWERCASE = "platform_users_lowercase", + USER_BY_GROUP = "by_group_user", } export const DeprecatedViews = { @@ -43,6 +45,10 @@ export enum DocumentType { DEV_INFO = "devinfo", AUTOMATION_LOG = "log_au", ACCOUNT_METADATA = "acc_metadata", + PLUGIN = "plg", + TABLE = "ta", + DATASOURCE = "datasource", + DATASOURCE_PLUS = "datasource_plus", } export const StaticDatabases = { diff --git a/packages/backend-core/src/db/conversions.js b/packages/backend-core/src/db/conversions.js index 90c04e9251..5b1a785ecc 100644 --- a/packages/backend-core/src/db/conversions.js +++ b/packages/backend-core/src/db/conversions.js @@ -36,6 +36,7 @@ exports.getDevelopmentAppID = appId => { const rest = split.join(APP_PREFIX) return `${APP_DEV_PREFIX}${rest}` } +exports.getDevAppID = exports.getDevelopmentAppID /** * Convert a development app ID to a deployed app ID. diff --git a/packages/backend-core/src/db/tenancy.ts b/packages/backend-core/src/db/tenancy.ts new file mode 100644 index 0000000000..d920f7cd41 --- /dev/null +++ b/packages/backend-core/src/db/tenancy.ts @@ -0,0 +1,22 @@ +import { DEFAULT_TENANT_ID } from "../constants" +import { StaticDatabases, SEPARATOR } from "./constants" +import { getTenantId } from "../context" + +export const getGlobalDBName = (tenantId?: string) => { + // tenant ID can be set externally, for example user API where + // new tenants are being created, this may be the case + if (!tenantId) { + tenantId = getTenantId() + } + return baseGlobalDBName(tenantId) +} + +export const baseGlobalDBName = (tenantId: string | undefined | null) => { + let dbName + if (!tenantId || tenantId === DEFAULT_TENANT_ID) { + dbName = StaticDatabases.GLOBAL.name + } else { + dbName = `${tenantId}${SEPARATOR}${StaticDatabases.GLOBAL.name}` + } + return dbName +} diff --git a/packages/backend-core/src/db/utils.ts b/packages/backend-core/src/db/utils.ts index 4926a60150..1c4be7e366 100644 --- a/packages/backend-core/src/db/utils.ts +++ b/packages/backend-core/src/db/utils.ts @@ -2,7 +2,8 @@ import { newid } from "../hashing" import { DEFAULT_TENANT_ID, Configs } from "../constants" import env from "../environment" import { SEPARATOR, DocumentType, UNICODE_MAX, ViewName } from "./constants" -import { getTenantId, getGlobalDBName, getGlobalDB } from "../tenancy" +import { getTenantId, getGlobalDB } from "../context" +import { getGlobalDBName } from "./tenancy" import fetch from "node-fetch" import { doWithDB, allDbs } from "./index" import { getCouchInfo } from "./pouch" @@ -15,6 +16,7 @@ import * as events from "../events" export * from "./constants" export * from "./conversions" export { default as Replication } from "./Replication" +export * from "./tenancy" /** * Generates a new app ID. @@ -62,6 +64,28 @@ export function getQueryIndex(viewName: ViewName) { return `database/${viewName}` } +/** + * Check if a given ID is that of a table. + * @returns {boolean} + */ +export const isTableId = (id: string) => { + // this includes datasource plus tables + return ( + id && + (id.startsWith(`${DocumentType.TABLE}${SEPARATOR}`) || + id.startsWith(`${DocumentType.DATASOURCE_PLUS}${SEPARATOR}`)) + ) +} + +/** + * Check if a given ID is that of a datasource or datasource plus. + * @returns {boolean} + */ +export const isDatasourceId = (id: string) => { + // this covers both datasources and datasource plus + return id && id.startsWith(`${DocumentType.DATASOURCE}${SEPARATOR}`) +} + /** * Generates a new workspace ID. * @returns {string} The new workspace ID which the workspace doc can be stored under. @@ -366,6 +390,21 @@ export const generateDevInfoID = (userId: any) => { return `${DocumentType.DEV_INFO}${SEPARATOR}${userId}` } +/** + * Generates a new plugin ID - to be used in the global DB. + * @returns {string} The new plugin ID which a plugin metadata document can be stored under. + */ +export const generatePluginID = (name: string) => { + return `${DocumentType.PLUGIN}${SEPARATOR}${name}` +} + +/** + * Gets parameters for retrieving automations, this is a utility function for the getDocParams function. + */ +export const getPluginParams = (pluginId?: string | null, otherProps = {}) => { + return getDocParams(DocumentType.PLUGIN, pluginId, otherProps) +} + /** * Returns the most granular configuration document from the DB based on the type, workspace and userID passed. * @param {Object} db - db instance to query diff --git a/packages/backend-core/src/db/views.js b/packages/backend-core/src/db/views.js deleted file mode 100644 index b2562bdc71..0000000000 --- a/packages/backend-core/src/db/views.js +++ /dev/null @@ -1,203 +0,0 @@ -const { - DocumentType, - ViewName, - DeprecatedViews, - SEPARATOR, -} = require("./utils") -const { getGlobalDB } = require("../tenancy") -const { StaticDatabases } = require("./constants") -const { doWithDB } = require("./") - -const DESIGN_DB = "_design/database" - -function DesignDoc() { - return { - _id: DESIGN_DB, - // view collation information, read before writing any complex views: - // https://docs.couchdb.org/en/master/ddocs/views/collation.html#collation-specification - views: {}, - } -} - -async function removeDeprecated(db, viewName) { - if (!DeprecatedViews[viewName]) { - return - } - try { - const designDoc = await db.get(DESIGN_DB) - for (let deprecatedNames of DeprecatedViews[viewName]) { - delete designDoc.views[deprecatedNames] - } - await db.put(designDoc) - } catch (err) { - // doesn't exist, ignore - } -} - -exports.createNewUserEmailView = async () => { - const db = getGlobalDB() - let designDoc - try { - designDoc = await db.get(DESIGN_DB) - } catch (err) { - // no design doc, make one - designDoc = DesignDoc() - } - const view = { - // if using variables in a map function need to inject them before use - map: `function(doc) { - if (doc._id.startsWith("${DocumentType.USER}${SEPARATOR}")) { - emit(doc.email.toLowerCase(), doc._id) - } - }`, - } - designDoc.views = { - ...designDoc.views, - [ViewName.USER_BY_EMAIL]: view, - } - await db.put(designDoc) -} - -exports.createAccountEmailView = async () => { - await doWithDB(StaticDatabases.PLATFORM_INFO.name, async db => { - let designDoc - try { - designDoc = await db.get(DESIGN_DB) - } catch (err) { - // no design doc, make one - designDoc = DesignDoc() - } - const view = { - // if using variables in a map function need to inject them before use - map: `function(doc) { - if (doc._id.startsWith("${DocumentType.ACCOUNT_METADATA}${SEPARATOR}")) { - emit(doc.email.toLowerCase(), doc._id) - } - }`, - } - designDoc.views = { - ...designDoc.views, - [ViewName.ACCOUNT_BY_EMAIL]: view, - } - await db.put(designDoc) - }) -} - -exports.createUserAppView = async () => { - const db = getGlobalDB() - let designDoc - try { - designDoc = await db.get("_design/database") - } catch (err) { - // no design doc, make one - designDoc = DesignDoc() - } - const view = { - // if using variables in a map function need to inject them before use - map: `function(doc) { - if (doc._id.startsWith("${DocumentType.USER}${SEPARATOR}") && doc.roles) { - for (let prodAppId of Object.keys(doc.roles)) { - let emitted = prodAppId + "${SEPARATOR}" + doc._id - emit(emitted, null) - } - } - }`, - } - designDoc.views = { - ...designDoc.views, - [ViewName.USER_BY_APP]: view, - } - await db.put(designDoc) -} - -exports.createApiKeyView = async () => { - const db = getGlobalDB() - let designDoc - try { - designDoc = await db.get("_design/database") - } catch (err) { - designDoc = DesignDoc() - } - const view = { - map: `function(doc) { - if (doc._id.startsWith("${DocumentType.DEV_INFO}") && doc.apiKey) { - emit(doc.apiKey, doc.userId) - } - }`, - } - designDoc.views = { - ...designDoc.views, - [ViewName.BY_API_KEY]: view, - } - await db.put(designDoc) -} - -exports.createUserBuildersView = async () => { - const db = getGlobalDB() - let designDoc - try { - designDoc = await db.get("_design/database") - } catch (err) { - // no design doc, make one - designDoc = DesignDoc() - } - const view = { - map: `function(doc) { - if (doc.builder && doc.builder.global === true) { - emit(doc._id, doc._id) - } - }`, - } - designDoc.views = { - ...designDoc.views, - [ViewName.USER_BY_BUILDERS]: view, - } - await db.put(designDoc) -} - -exports.queryView = async (viewName, params, db, CreateFuncByName) => { - try { - let response = (await db.query(`database/${viewName}`, params)).rows - response = response.map(resp => - params.include_docs ? resp.doc : resp.value - ) - if (params.arrayResponse) { - return response - } else { - return response.length <= 1 ? response[0] : response - } - } catch (err) { - if (err != null && err.name === "not_found") { - const createFunc = CreateFuncByName[viewName] - await removeDeprecated(db, viewName) - await createFunc() - return exports.queryView(viewName, params, db, CreateFuncByName) - } else { - throw err - } - } -} - -exports.queryPlatformView = async (viewName, params) => { - const CreateFuncByName = { - [ViewName.ACCOUNT_BY_EMAIL]: exports.createAccountEmailView, - } - - return doWithDB(StaticDatabases.PLATFORM_INFO.name, async db => { - return exports.queryView(viewName, params, db, CreateFuncByName) - }) -} - -exports.queryGlobalView = async (viewName, params, db = null) => { - const CreateFuncByName = { - [ViewName.USER_BY_EMAIL]: exports.createNewUserEmailView, - [ViewName.BY_API_KEY]: exports.createApiKeyView, - [ViewName.USER_BY_BUILDERS]: exports.createUserBuildersView, - [ViewName.USER_BY_APP]: exports.createUserAppView, - } - // can pass DB in if working with something specific - if (!db) { - db = getGlobalDB() - } - return exports.queryView(viewName, params, db, CreateFuncByName) -} diff --git a/packages/backend-core/src/db/views.ts b/packages/backend-core/src/db/views.ts new file mode 100644 index 0000000000..f0fff918fc --- /dev/null +++ b/packages/backend-core/src/db/views.ts @@ -0,0 +1,199 @@ +import { DocumentType, ViewName, DeprecatedViews, SEPARATOR } from "./utils" +import { getGlobalDB } from "../context" +import PouchDB from "pouchdb" +import { StaticDatabases } from "./constants" +import { doWithDB } from "./" + +const DESIGN_DB = "_design/database" + +function DesignDoc() { + return { + _id: DESIGN_DB, + // view collation information, read before writing any complex views: + // https://docs.couchdb.org/en/master/ddocs/views/collation.html#collation-specification + views: {}, + } +} + +interface DesignDocument { + views: any +} + +async function removeDeprecated(db: PouchDB.Database, viewName: ViewName) { + // @ts-ignore + if (!DeprecatedViews[viewName]) { + return + } + try { + const designDoc = await db.get(DESIGN_DB) + // @ts-ignore + for (let deprecatedNames of DeprecatedViews[viewName]) { + delete designDoc.views[deprecatedNames] + } + await db.put(designDoc) + } catch (err) { + // doesn't exist, ignore + } +} + +export async function createView(db: any, viewJs: string, viewName: string) { + let designDoc + try { + designDoc = (await db.get(DESIGN_DB)) as DesignDocument + } catch (err) { + // no design doc, make one + designDoc = DesignDoc() + } + const view = { + map: viewJs, + } + designDoc.views = { + ...designDoc.views, + [viewName]: view, + } + await db.put(designDoc) +} + +export const createNewUserEmailView = async () => { + const db = getGlobalDB() + const viewJs = `function(doc) { + if (doc._id.startsWith("${DocumentType.USER}${SEPARATOR}")) { + emit(doc.email.toLowerCase(), doc._id) + } + }` + await createView(db, viewJs, ViewName.USER_BY_EMAIL) +} + +export const createAccountEmailView = async () => { + const viewJs = `function(doc) { + if (doc._id.startsWith("${DocumentType.ACCOUNT_METADATA}${SEPARATOR}")) { + emit(doc.email.toLowerCase(), doc._id) + } + }` + await doWithDB( + StaticDatabases.PLATFORM_INFO.name, + async (db: PouchDB.Database) => { + await createView(db, viewJs, ViewName.ACCOUNT_BY_EMAIL) + } + ) +} + +export const createUserAppView = async () => { + const db = getGlobalDB() as PouchDB.Database + const viewJs = `function(doc) { + if (doc._id.startsWith("${DocumentType.USER}${SEPARATOR}") && doc.roles) { + for (let prodAppId of Object.keys(doc.roles)) { + let emitted = prodAppId + "${SEPARATOR}" + doc._id + emit(emitted, null) + } + } + }` + await createView(db, viewJs, ViewName.USER_BY_APP) +} + +export const createApiKeyView = async () => { + const db = getGlobalDB() + const viewJs = `function(doc) { + if (doc._id.startsWith("${DocumentType.DEV_INFO}") && doc.apiKey) { + emit(doc.apiKey, doc.userId) + } + }` + await createView(db, viewJs, ViewName.BY_API_KEY) +} + +export const createUserBuildersView = async () => { + const db = getGlobalDB() + const viewJs = `function(doc) { + if (doc.builder && doc.builder.global === true) { + emit(doc._id, doc._id) + } + }` + await createView(db, viewJs, ViewName.USER_BY_BUILDERS) +} + +export const createPlatformUserView = async () => { + const viewJs = `function(doc) { + if (doc.tenantId) { + emit(doc._id.toLowerCase(), doc._id) + } + }` + await doWithDB( + StaticDatabases.PLATFORM_INFO.name, + async (db: PouchDB.Database) => { + await createView(db, viewJs, ViewName.PLATFORM_USERS_LOWERCASE) + } + ) +} + +export interface QueryViewOptions { + arrayResponse?: boolean +} + +export const queryView = async ( + viewName: ViewName, + params: PouchDB.Query.Options, + db: PouchDB.Database, + createFunc: any, + opts?: QueryViewOptions +): Promise => { + try { + let response = await db.query(`database/${viewName}`, params) + const rows = response.rows + const docs = rows.map(row => (params.include_docs ? row.doc : row.value)) + + // if arrayResponse has been requested, always return array regardless of length + if (opts?.arrayResponse) { + return docs + } else { + // return the single document if there is only one + return docs.length <= 1 ? docs[0] : docs + } + } catch (err: any) { + if (err != null && err.name === "not_found") { + await removeDeprecated(db, viewName) + await createFunc() + return queryView(viewName, params, db, createFunc, opts) + } else { + throw err + } + } +} + +export const queryPlatformView = async ( + viewName: ViewName, + params: PouchDB.Query.Options, + opts?: QueryViewOptions +): Promise => { + const CreateFuncByName: any = { + [ViewName.ACCOUNT_BY_EMAIL]: createAccountEmailView, + [ViewName.PLATFORM_USERS_LOWERCASE]: createPlatformUserView, + } + + return doWithDB( + StaticDatabases.PLATFORM_INFO.name, + async (db: PouchDB.Database) => { + const createFn = CreateFuncByName[viewName] + return queryView(viewName, params, db, createFn, opts) + } + ) +} + +export const queryGlobalView = async ( + viewName: ViewName, + params: PouchDB.Query.Options, + db?: PouchDB.Database, + opts?: QueryViewOptions +): Promise => { + const CreateFuncByName: any = { + [ViewName.USER_BY_EMAIL]: createNewUserEmailView, + [ViewName.BY_API_KEY]: createApiKeyView, + [ViewName.USER_BY_BUILDERS]: createUserBuildersView, + [ViewName.USER_BY_APP]: createUserAppView, + } + // can pass DB in if working with something specific + if (!db) { + db = getGlobalDB() as PouchDB.Database + } + const createFn = CreateFuncByName[viewName] + return queryView(viewName, params, db, createFn, opts) +} diff --git a/packages/backend-core/src/environment.ts b/packages/backend-core/src/environment.ts index 04d09d2eb7..6e2ac94be9 100644 --- a/packages/backend-core/src/environment.ts +++ b/packages/backend-core/src/environment.ts @@ -16,9 +16,19 @@ if (!LOADED && isDev() && !isTest()) { LOADED = true } +const DefaultBucketName = { + BACKUPS: "backups", + APPS: "prod-budi-app-assets", + TEMPLATES: "templates", + GLOBAL: "global", + CLOUD: "prod-budi-tenant-uploads", + PLUGINS: "plugins", +} + const env = { isTest, isDev, + JS_BCRYPT: process.env.JS_BCRYPT, JWT_SECRET: process.env.JWT_SECRET, COUCH_DB_URL: process.env.COUCH_DB_URL || "http://localhost:4005", COUCH_DB_USERNAME: process.env.COUCH_DB_USER, @@ -36,7 +46,7 @@ const env = { MULTI_TENANCY: process.env.MULTI_TENANCY, ACCOUNT_PORTAL_URL: process.env.ACCOUNT_PORTAL_URL || "https://account.budibase.app", - ACCOUNT_PORTAL_API_KEY: process.env.ACCOUNT_PORTAL_API_KEY, + ACCOUNT_PORTAL_API_KEY: process.env.ACCOUNT_PORTAL_API_KEY || "", DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL, SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED || ""), COOKIE_DOMAIN: process.env.COOKIE_DOMAIN, @@ -44,13 +54,17 @@ const env = { POSTHOG_TOKEN: process.env.POSTHOG_TOKEN, ENABLE_ANALYTICS: process.env.ENABLE_ANALYTICS, TENANT_FEATURE_FLAGS: process.env.TENANT_FEATURE_FLAGS, - BACKUPS_BUCKET_NAME: process.env.BACKUPS_BUCKET_NAME || "backups", - APPS_BUCKET_NAME: process.env.APPS_BUCKET_NAME || "prod-budi-app-assets", - TEMPLATES_BUCKET_NAME: process.env.TEMPLATES_BUCKET_NAME || "templates", - GLOBAL_BUCKET_NAME: process.env.GLOBAL_BUCKET_NAME || "global", + BACKUPS_BUCKET_NAME: + process.env.BACKUPS_BUCKET_NAME || DefaultBucketName.BACKUPS, + APPS_BUCKET_NAME: process.env.APPS_BUCKET_NAME || DefaultBucketName.APPS, + TEMPLATES_BUCKET_NAME: + process.env.TEMPLATES_BUCKET_NAME || DefaultBucketName.TEMPLATES, + GLOBAL_BUCKET_NAME: + process.env.GLOBAL_BUCKET_NAME || DefaultBucketName.GLOBAL, GLOBAL_CLOUD_BUCKET_NAME: - process.env.GLOBAL_CLOUD_BUCKET_NAME || "prod-budi-tenant-uploads", - PLUGIN_BUCKET_NAME: process.env.PLUGIN_BUCKET_NAME || "plugins", + process.env.GLOBAL_CLOUD_BUCKET_NAME || DefaultBucketName.CLOUD, + PLUGIN_BUCKET_NAME: + process.env.PLUGIN_BUCKET_NAME || DefaultBucketName.PLUGINS, USE_COUCH: process.env.USE_COUCH || true, DISABLE_DEVELOPER_LICENSE: process.env.DISABLE_DEVELOPER_LICENSE, DEFAULT_LICENSE: process.env.DEFAULT_LICENSE, diff --git a/packages/backend-core/src/errors/base.js b/packages/backend-core/src/errors/base.js deleted file mode 100644 index 7cb0c0fc23..0000000000 --- a/packages/backend-core/src/errors/base.js +++ /dev/null @@ -1,11 +0,0 @@ -class BudibaseError extends Error { - constructor(message, code, type) { - super(message) - this.code = code - this.type = type - } -} - -module.exports = { - BudibaseError, -} diff --git a/packages/backend-core/src/errors/base.ts b/packages/backend-core/src/errors/base.ts new file mode 100644 index 0000000000..801dcf168d --- /dev/null +++ b/packages/backend-core/src/errors/base.ts @@ -0,0 +1,10 @@ +export class BudibaseError extends Error { + code: string + type: string + + constructor(message: string, code: string, type: string) { + super(message) + this.code = code + this.type = type + } +} diff --git a/packages/backend-core/src/errors/generic.js b/packages/backend-core/src/errors/generic.js deleted file mode 100644 index 5c7661f035..0000000000 --- a/packages/backend-core/src/errors/generic.js +++ /dev/null @@ -1,11 +0,0 @@ -const { BudibaseError } = require("./base") - -class GenericError extends BudibaseError { - constructor(message, code, type) { - super(message, code, type ? type : "generic") - } -} - -module.exports = { - GenericError, -} diff --git a/packages/backend-core/src/errors/generic.ts b/packages/backend-core/src/errors/generic.ts new file mode 100644 index 0000000000..71b3352438 --- /dev/null +++ b/packages/backend-core/src/errors/generic.ts @@ -0,0 +1,7 @@ +import { BudibaseError } from "./base" + +export class GenericError extends BudibaseError { + constructor(message: string, code: string, type: string) { + super(message, code, type ? type : "generic") + } +} diff --git a/packages/backend-core/src/errors/http.js b/packages/backend-core/src/errors/http.js deleted file mode 100644 index 8e7cab4638..0000000000 --- a/packages/backend-core/src/errors/http.js +++ /dev/null @@ -1,12 +0,0 @@ -const { GenericError } = require("./generic") - -class HTTPError extends GenericError { - constructor(message, httpStatus, code = "http", type = "generic") { - super(message, code, type) - this.status = httpStatus - } -} - -module.exports = { - HTTPError, -} diff --git a/packages/backend-core/src/errors/http.ts b/packages/backend-core/src/errors/http.ts new file mode 100644 index 0000000000..182e009f58 --- /dev/null +++ b/packages/backend-core/src/errors/http.ts @@ -0,0 +1,15 @@ +import { GenericError } from "./generic" + +export class HTTPError extends GenericError { + status: number + + constructor( + message: string, + httpStatus: number, + code = "http", + type = "generic" + ) { + super(message, code, type) + this.status = httpStatus + } +} diff --git a/packages/backend-core/src/errors/index.js b/packages/backend-core/src/errors/index.ts similarity index 65% rename from packages/backend-core/src/errors/index.js rename to packages/backend-core/src/errors/index.ts index 31ffd739a0..be6657093d 100644 --- a/packages/backend-core/src/errors/index.js +++ b/packages/backend-core/src/errors/index.ts @@ -1,5 +1,6 @@ -const http = require("./http") -const licensing = require("./licensing") +import { HTTPError } from "./http" +import { UsageLimitError, FeatureDisabledError } from "./licensing" +import * as licensing from "./licensing" const codes = { ...licensing.codes, @@ -11,7 +12,7 @@ const context = { ...licensing.context, } -const getPublicError = err => { +const getPublicError = (err: any) => { let error if (err.code || err.type) { // add generic error information @@ -32,13 +33,15 @@ const getPublicError = err => { return error } -module.exports = { +const pkg = { codes, types, errors: { - UsageLimitError: licensing.UsageLimitError, - FeatureDisabledError: licensing.FeatureDisabledError, - HTTPError: http.HTTPError, + UsageLimitError, + FeatureDisabledError, + HTTPError, }, getPublicError, } + +export = pkg diff --git a/packages/backend-core/src/errors/licensing.js b/packages/backend-core/src/errors/licensing.js deleted file mode 100644 index 85d207ac35..0000000000 --- a/packages/backend-core/src/errors/licensing.js +++ /dev/null @@ -1,43 +0,0 @@ -const { HTTPError } = require("./http") - -const type = "license_error" - -const codes = { - USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded", - FEATURE_DISABLED: "feature_disabled", -} - -const context = { - [codes.USAGE_LIMIT_EXCEEDED]: err => { - return { - limitName: err.limitName, - } - }, - [codes.FEATURE_DISABLED]: err => { - return { - featureName: err.featureName, - } - }, -} - -class UsageLimitError extends HTTPError { - constructor(message, limitName) { - super(message, 400, codes.USAGE_LIMIT_EXCEEDED, type) - this.limitName = limitName - } -} - -class FeatureDisabledError extends HTTPError { - constructor(message, featureName) { - super(message, 400, codes.FEATURE_DISABLED, type) - this.featureName = featureName - } -} - -module.exports = { - type, - codes, - context, - UsageLimitError, - FeatureDisabledError, -} diff --git a/packages/backend-core/src/errors/licensing.ts b/packages/backend-core/src/errors/licensing.ts new file mode 100644 index 0000000000..7ffcefa167 --- /dev/null +++ b/packages/backend-core/src/errors/licensing.ts @@ -0,0 +1,39 @@ +import { HTTPError } from "./http" + +export const type = "license_error" + +export const codes = { + USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded", + FEATURE_DISABLED: "feature_disabled", +} + +export const context = { + [codes.USAGE_LIMIT_EXCEEDED]: (err: any) => { + return { + limitName: err.limitName, + } + }, + [codes.FEATURE_DISABLED]: (err: any) => { + return { + featureName: err.featureName, + } + }, +} + +export class UsageLimitError extends HTTPError { + limitName: string + + constructor(message: string, limitName: string) { + super(message, 400, codes.USAGE_LIMIT_EXCEEDED, type) + this.limitName = limitName + } +} + +export class FeatureDisabledError extends HTTPError { + featureName: string + + constructor(message: string, featureName: string) { + super(message, 400, codes.FEATURE_DISABLED, type) + this.featureName = featureName + } +} diff --git a/packages/backend-core/src/events/processors/LoggingProcessor.ts b/packages/backend-core/src/events/processors/LoggingProcessor.ts index a517fba09c..d41a82fbb4 100644 --- a/packages/backend-core/src/events/processors/LoggingProcessor.ts +++ b/packages/backend-core/src/events/processors/LoggingProcessor.ts @@ -23,9 +23,11 @@ export default class LoggingProcessor implements EventProcessor { return } let timestampString = getTimestampString(timestamp) - console.log( - `[audit] [tenant=${identity.tenantId}] [identityType=${identity.type}] [identity=${identity.id}] ${timestampString} ${event} ` - ) + let message = `[audit] [tenant=${identity.tenantId}] [identityType=${identity.type}] [identity=${identity.id}] ${timestampString} ${event} ` + if (env.isDev()) { + message = message + `[debug: [properties=${JSON.stringify(properties)}] ]` + } + console.log(message) } async identify(identity: Identity, timestamp?: string | number) { diff --git a/packages/backend-core/src/events/publishers/datasource.ts b/packages/backend-core/src/events/publishers/datasource.ts index 3cd68033fc..d3ea7402f9 100644 --- a/packages/backend-core/src/events/publishers/datasource.ts +++ b/packages/backend-core/src/events/publishers/datasource.ts @@ -5,8 +5,15 @@ import { DatasourceCreatedEvent, DatasourceUpdatedEvent, DatasourceDeletedEvent, + SourceName, } from "@budibase/types" +function isCustom(datasource: Datasource) { + const sources = Object.values(SourceName) + // if not in the base source list, then it must be custom + return !sources.includes(datasource.source) +} + export async function created( datasource: Datasource, timestamp?: string | number @@ -14,6 +21,7 @@ export async function created( const properties: DatasourceCreatedEvent = { datasourceId: datasource._id as string, source: datasource.source, + custom: isCustom(datasource), } await publishEvent(Event.DATASOURCE_CREATED, properties, timestamp) } @@ -22,6 +30,7 @@ export async function updated(datasource: Datasource) { const properties: DatasourceUpdatedEvent = { datasourceId: datasource._id as string, source: datasource.source, + custom: isCustom(datasource), } await publishEvent(Event.DATASOURCE_UPDATED, properties) } @@ -30,6 +39,7 @@ export async function deleted(datasource: Datasource) { const properties: DatasourceDeletedEvent = { datasourceId: datasource._id as string, source: datasource.source, + custom: isCustom(datasource), } await publishEvent(Event.DATASOURCE_DELETED, properties) } diff --git a/packages/backend-core/src/events/publishers/group.ts b/packages/backend-core/src/events/publishers/group.ts index d300873725..b4fd0d1469 100644 --- a/packages/backend-core/src/events/publishers/group.ts +++ b/packages/backend-core/src/events/publishers/group.ts @@ -40,9 +40,9 @@ export async function usersAdded(count: number, group: UserGroup) { await publishEvent(Event.USER_GROUP_USERS_ADDED, properties) } -export async function usersDeleted(emails: string[], group: UserGroup) { +export async function usersDeleted(count: number, group: UserGroup) { const properties: GroupUsersDeletedEvent = { - count: emails.length, + count, groupId: group._id as string, } await publishEvent(Event.USER_GROUP_USERS_REMOVED, properties) diff --git a/packages/backend-core/src/events/publishers/index.ts b/packages/backend-core/src/events/publishers/index.ts index 57fd0bf8e2..6fe42c4bda 100644 --- a/packages/backend-core/src/events/publishers/index.ts +++ b/packages/backend-core/src/events/publishers/index.ts @@ -18,3 +18,4 @@ export * as view from "./view" export * as installation from "./installation" export * as backfill from "./backfill" export * as group from "./group" +export * as plugin from "./plugin" diff --git a/packages/backend-core/src/events/publishers/license.ts b/packages/backend-core/src/events/publishers/license.ts index 1adc71652e..84472e408f 100644 --- a/packages/backend-core/src/events/publishers/license.ts +++ b/packages/backend-core/src/events/publishers/license.ts @@ -1,27 +1,78 @@ import { publishEvent } from "../events" import { Event, - License, LicenseActivatedEvent, - LicenseDowngradedEvent, - LicenseUpdatedEvent, - LicenseUpgradedEvent, + LicensePlanChangedEvent, + LicenseTierChangedEvent, + PlanType, + Account, + LicensePortalOpenedEvent, + LicenseCheckoutSuccessEvent, + LicenseCheckoutOpenedEvent, + LicensePaymentFailedEvent, + LicensePaymentRecoveredEvent, } from "@budibase/types" -// TODO -export async function updgraded(license: License) { - const properties: LicenseUpgradedEvent = {} - await publishEvent(Event.LICENSE_UPGRADED, properties) +export async function tierChanged(account: Account, from: number, to: number) { + const properties: LicenseTierChangedEvent = { + accountId: account.accountId, + to, + from, + } + await publishEvent(Event.LICENSE_TIER_CHANGED, properties) } -// TODO -export async function downgraded(license: License) { - const properties: LicenseDowngradedEvent = {} - await publishEvent(Event.LICENSE_DOWNGRADED, properties) +export async function planChanged( + account: Account, + from: PlanType, + to: PlanType +) { + const properties: LicensePlanChangedEvent = { + accountId: account.accountId, + to, + from, + } + await publishEvent(Event.LICENSE_PLAN_CHANGED, properties) } -// TODO -export async function activated(license: License) { - const properties: LicenseActivatedEvent = {} +export async function activated(account: Account) { + const properties: LicenseActivatedEvent = { + accountId: account.accountId, + } await publishEvent(Event.LICENSE_ACTIVATED, properties) } + +export async function checkoutOpened(account: Account) { + const properties: LicenseCheckoutOpenedEvent = { + accountId: account.accountId, + } + await publishEvent(Event.LICENSE_CHECKOUT_OPENED, properties) +} + +export async function checkoutSuccess(account: Account) { + const properties: LicenseCheckoutSuccessEvent = { + accountId: account.accountId, + } + await publishEvent(Event.LICENSE_CHECKOUT_SUCCESS, properties) +} + +export async function portalOpened(account: Account) { + const properties: LicensePortalOpenedEvent = { + accountId: account.accountId, + } + await publishEvent(Event.LICENSE_PORTAL_OPENED, properties) +} + +export async function paymentFailed(account: Account) { + const properties: LicensePaymentFailedEvent = { + accountId: account.accountId, + } + await publishEvent(Event.LICENSE_PAYMENT_FAILED, properties) +} + +export async function paymentRecovered(account: Account) { + const properties: LicensePaymentRecoveredEvent = { + accountId: account.accountId, + } + await publishEvent(Event.LICENSE_PAYMENT_RECOVERED, properties) +} diff --git a/packages/backend-core/src/events/publishers/plugin.ts b/packages/backend-core/src/events/publishers/plugin.ts new file mode 100644 index 0000000000..4e4d87cf56 --- /dev/null +++ b/packages/backend-core/src/events/publishers/plugin.ts @@ -0,0 +1,41 @@ +import { publishEvent } from "../events" +import { + Event, + Plugin, + PluginDeletedEvent, + PluginImportedEvent, + PluginInitEvent, +} from "@budibase/types" + +export async function init(plugin: Plugin) { + const properties: PluginInitEvent = { + type: plugin.schema.type, + name: plugin.name, + description: plugin.description, + version: plugin.version, + } + await publishEvent(Event.PLUGIN_INIT, properties) +} + +export async function imported(plugin: Plugin) { + const properties: PluginImportedEvent = { + pluginId: plugin._id as string, + type: plugin.schema.type, + source: plugin.source, + name: plugin.name, + description: plugin.description, + version: plugin.version, + } + await publishEvent(Event.PLUGIN_IMPORTED, properties) +} + +export async function deleted(plugin: Plugin) { + const properties: PluginDeletedEvent = { + pluginId: plugin._id as string, + type: plugin.schema.type, + name: plugin.name, + description: plugin.description, + version: plugin.version, + } + await publishEvent(Event.PLUGIN_DELETED, properties) +} diff --git a/packages/backend-core/src/featureFlags/index.js b/packages/backend-core/src/featureFlags/index.js index 103ac4df59..8a8162d0ba 100644 --- a/packages/backend-core/src/featureFlags/index.js +++ b/packages/backend-core/src/featureFlags/index.js @@ -31,23 +31,29 @@ const TENANT_FEATURE_FLAGS = getFeatureFlags() exports.isEnabled = featureFlag => { const tenantId = tenancy.getTenantId() - - return ( - TENANT_FEATURE_FLAGS && - TENANT_FEATURE_FLAGS[tenantId] && - TENANT_FEATURE_FLAGS[tenantId].includes(featureFlag) - ) + const flags = exports.getTenantFeatureFlags(tenantId) + return flags.includes(featureFlag) } exports.getTenantFeatureFlags = tenantId => { - if (TENANT_FEATURE_FLAGS && TENANT_FEATURE_FLAGS[tenantId]) { - return TENANT_FEATURE_FLAGS[tenantId] + const flags = [] + + if (TENANT_FEATURE_FLAGS) { + const globalFlags = TENANT_FEATURE_FLAGS["*"] + const tenantFlags = TENANT_FEATURE_FLAGS[tenantId] + + if (globalFlags) { + flags.push(...globalFlags) + } + if (tenantFlags) { + flags.push(...tenantFlags) + } } - return [] + return flags } -exports.FeatureFlag = { +exports.TenantFeatureFlag = { LICENSING: "LICENSING", GOOGLE_SHEETS: "GOOGLE_SHEETS", USER_GROUPS: "USER_GROUPS", diff --git a/packages/backend-core/src/hashing.js b/packages/backend-core/src/hashing.js index 45abe2f9bd..7524e66043 100644 --- a/packages/backend-core/src/hashing.js +++ b/packages/backend-core/src/hashing.js @@ -1,5 +1,5 @@ -const bcrypt = require("bcrypt") const env = require("./environment") +const bcrypt = env.JS_BCRYPT ? require("bcryptjs") : require("bcrypt") const { v4 } = require("uuid") const SALT_ROUNDS = env.SALT_ROUNDS || 10 diff --git a/packages/backend-core/src/index.ts b/packages/backend-core/src/index.ts index d9dbe58264..83b23b479d 100644 --- a/packages/backend-core/src/index.ts +++ b/packages/backend-core/src/index.ts @@ -1,5 +1,4 @@ import errors from "./errors" - const errorClasses = errors.errors import * as events from "./events" import * as migrations from "./migrations" @@ -15,10 +14,11 @@ import deprovisioning from "./context/deprovision" import auth from "./auth" import constants from "./constants" import * as dbConstants from "./db/constants" -import logging from "./logging" +import * as logging from "./logging" import pino from "./pino" import * as middleware from "./middleware" import plugins from "./plugin" +import encryption from "./security/encryption" // mimic the outer package exports import * as db from "./pkg/db" @@ -61,6 +61,7 @@ const core = { ...pino, ...errorClasses, middleware, + encryption, } export = core diff --git a/packages/backend-core/src/middleware/authenticated.ts b/packages/backend-core/src/middleware/authenticated.ts index 062070785d..a3c6b67cde 100644 --- a/packages/backend-core/src/middleware/authenticated.ts +++ b/packages/backend-core/src/middleware/authenticated.ts @@ -106,6 +106,7 @@ export = ( user = await getUser(userId, session.tenantId) } user.csrfToken = session.csrfToken + if (session?.lastAccessedAt < timeMinusOneMinute()) { // make sure we denote that the session is still in use await updateSessionTTL(session) diff --git a/packages/backend-core/src/migrations/definitions.ts b/packages/backend-core/src/migrations/definitions.ts index 34ec0f0cad..6eba56ab43 100644 --- a/packages/backend-core/src/migrations/definitions.ts +++ b/packages/backend-core/src/migrations/definitions.ts @@ -11,20 +11,12 @@ export const DEFINITIONS: MigrationDefinition[] = [ }, { type: MigrationType.GLOBAL, - name: MigrationName.QUOTAS_1, + name: MigrationName.SYNC_QUOTAS, }, { type: MigrationType.APP, name: MigrationName.APP_URLS, }, - { - type: MigrationType.GLOBAL, - name: MigrationName.DEVELOPER_QUOTA, - }, - { - type: MigrationType.GLOBAL, - name: MigrationName.PUBLISHED_APP_QUOTA, - }, { type: MigrationType.APP, name: MigrationName.EVENT_APP_BACKFILL, diff --git a/packages/backend-core/src/migrations/migrations.ts b/packages/backend-core/src/migrations/migrations.ts index ca238ff80e..90a12acec2 100644 --- a/packages/backend-core/src/migrations/migrations.ts +++ b/packages/backend-core/src/migrations/migrations.ts @@ -3,12 +3,8 @@ import { doWithDB } from "../db" import { DocumentType, StaticDatabases } from "../db/constants" import { getAllApps } from "../db/utils" import environment from "../environment" -import { - doInTenant, - getTenantIds, - getGlobalDBName, - getTenantId, -} from "../tenancy" +import { doInTenant, getTenantIds, getTenantId } from "../tenancy" +import { getGlobalDBName } from "../db/tenancy" import * as context from "../context" import { DEFINITIONS } from "." import { diff --git a/packages/backend-core/src/objectStore/index.ts b/packages/backend-core/src/objectStore/index.ts index a97aa8f65d..17e002cc49 100644 --- a/packages/backend-core/src/objectStore/index.ts +++ b/packages/backend-core/src/objectStore/index.ts @@ -182,6 +182,11 @@ export const streamUpload = async ( ...extra, ContentType: "application/javascript", } + } else if (filename?.endsWith(".svg")) { + extra = { + ...extra, + ContentType: "image", + } } const params = { diff --git a/packages/backend-core/src/objectStore/utils.js b/packages/backend-core/src/objectStore/utils.js index acc1b9904e..9cf4f5f70e 100644 --- a/packages/backend-core/src/objectStore/utils.js +++ b/packages/backend-core/src/objectStore/utils.js @@ -2,6 +2,11 @@ const { join } = require("path") const { tmpdir } = require("os") const env = require("../environment") +/**************************************************** + * NOTE: When adding a new bucket - name * + * sure that S3 usages (like budibase-infra) * + * have been updated to have a unique bucket name. * + ****************************************************/ exports.ObjectStoreBuckets = { BACKUPS: env.BACKUPS_BUCKET_NAME, APPS: env.APPS_BUCKET_NAME, diff --git a/packages/backend-core/src/pkg/context.ts b/packages/backend-core/src/pkg/context.ts index 5caa82ab0c..4915cc6e41 100644 --- a/packages/backend-core/src/pkg/context.ts +++ b/packages/backend-core/src/pkg/context.ts @@ -8,6 +8,7 @@ import { updateAppId, doInAppContext, doInTenant, + doInContext, } from "../context" import * as identity from "../context/identity" @@ -20,5 +21,6 @@ export = { updateAppId, doInAppContext, doInTenant, + doInContext, identity, } diff --git a/packages/backend-core/src/plugin/utils.js b/packages/backend-core/src/plugin/utils.js index 020fb4484d..60a40f3a76 100644 --- a/packages/backend-core/src/plugin/utils.js +++ b/packages/backend-core/src/plugin/utils.js @@ -67,14 +67,19 @@ function validateDatasource(schema) { description: joi.string().required(), datasource: joi.object().pattern(joi.string(), fieldValidator).required(), query: joi - .object({ - create: queryValidator, - read: queryValidator, - update: queryValidator, - delete: queryValidator, - }) + .object() + .pattern(joi.string(), queryValidator) .unknown(true) .required(), + extra: joi.object().pattern( + joi.string(), + joi.object({ + type: joi.string().required(), + displayName: joi.string().required(), + required: joi.boolean(), + data: joi.object(), + }) + ), }), }) runJoi(validator, schema) diff --git a/packages/backend-core/src/security/roles.js b/packages/backend-core/src/security/roles.js index 983aebf676..33c9123b63 100644 --- a/packages/backend-core/src/security/roles.js +++ b/packages/backend-core/src/security/roles.js @@ -78,7 +78,7 @@ function isBuiltin(role) { */ exports.builtinRoleToNumber = id => { const builtins = exports.getBuiltinRoles() - const MAX = Object.values(BUILTIN_IDS).length + 1 + const MAX = Object.values(builtins).length + 1 if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) { return MAX } @@ -94,6 +94,22 @@ exports.builtinRoleToNumber = id => { return count } +/** + * Converts any role to a number, but has to be async to get the roles from db. + */ +exports.roleToNumber = async id => { + if (exports.isBuiltin(id)) { + return exports.builtinRoleToNumber(id) + } + const hierarchy = await exports.getUserRoleHierarchy(id) + for (let role of hierarchy) { + if (isBuiltin(role.inherits)) { + return exports.builtinRoleToNumber(role.inherits) + 1 + } + } + return 0 +} + /** * Returns whichever builtin roleID is lower. */ @@ -172,7 +188,7 @@ async function getAllUserRoles(userRoleId) { * to determine if a user can access something that requires a specific role. * @param {string} userRoleId The user's role ID, this can be found in their access token. * @param {object} opts Various options, such as whether to only retrieve the IDs (default true). - * @returns {Promise} returns an ordered array of the roles, with the first being their + * @returns {Promise} returns an ordered array of the roles, with the first being their * highest level of access and the last being the lowest level. */ exports.getUserRoleHierarchy = async (userRoleId, opts = { idOnly: true }) => { diff --git a/packages/backend-core/src/security/sessions.ts b/packages/backend-core/src/security/sessions.ts index f621b99dc2..33230afc60 100644 --- a/packages/backend-core/src/security/sessions.ts +++ b/packages/backend-core/src/security/sessions.ts @@ -2,28 +2,12 @@ const redis = require("../redis/init") const { v4: uuidv4 } = require("uuid") const { logWarn } = require("../logging") const env = require("../environment") - -interface CreateSession { - sessionId: string - tenantId: string - csrfToken?: string -} - -interface Session extends CreateSession { - userId: string - lastAccessedAt: string - createdAt: string - // make optional attributes required - csrfToken: string -} - -interface SessionKey { - key: string -} - -interface ScannedSession { - value: Session -} +import { + Session, + ScannedSession, + SessionKey, + CreateSession, +} from "@budibase/types" // a week in seconds const EXPIRY_SECONDS = 86400 * 7 diff --git a/packages/backend-core/src/tenancy/tenancy.ts b/packages/backend-core/src/tenancy/tenancy.ts index 041f694d34..ad5c6b5287 100644 --- a/packages/backend-core/src/tenancy/tenancy.ts +++ b/packages/backend-core/src/tenancy/tenancy.ts @@ -1,6 +1,7 @@ import { doWithDB } from "../db" -import { StaticDatabases } from "../db/constants" -import { baseGlobalDBName } from "./utils" +import { queryPlatformView } from "../db/views" +import { StaticDatabases, ViewName } from "../db/constants" +import { getGlobalDBName } from "../db/tenancy" import { getTenantId, DEFAULT_TENANT_ID, @@ -8,6 +9,7 @@ import { getTenantIDFromAppID, } from "../context" import env from "../environment" +import { PlatformUser } from "@budibase/types" const TENANT_DOC = StaticDatabases.PLATFORM_INFO.docs.tenants const PLATFORM_INFO_DB = StaticDatabases.PLATFORM_INFO.name @@ -87,15 +89,6 @@ export const tryAddTenant = async ( }) } -export const getGlobalDBName = (tenantId?: string) => { - // tenant ID can be set externally, for example user API where - // new tenants are being created, this may be the case - if (!tenantId) { - tenantId = getTenantId() - } - return baseGlobalDBName(tenantId) -} - export const doWithGlobalDB = (tenantId: string, cb: any) => { return doWithDB(getGlobalDBName(tenantId), cb) } @@ -116,17 +109,19 @@ export const lookupTenantId = async (userId: string) => { } // lookup, could be email or userId, either will return a doc -export const getTenantUser = async (identifier: string) => { - return doWithDB(PLATFORM_INFO_DB, async (db: any) => { - try { - return await db.get(identifier) - } catch (err) { - return null - } - }) +export const getTenantUser = async ( + identifier: string +): Promise => { + // use the view here and allow to find anyone regardless of casing + // Use lowercase to ensure email login is case insensitive + const response = queryPlatformView(ViewName.PLATFORM_USERS_LOWERCASE, { + keys: [identifier.toLowerCase()], + include_docs: true, + }) as Promise + return response } -export const isUserInAppTenant = (appId: string, user: any) => { +export const isUserInAppTenant = (appId: string, user?: any) => { let userTenantId if (user) { userTenantId = user.tenantId || DEFAULT_TENANT_ID diff --git a/packages/backend-core/src/tenancy/utils.js b/packages/backend-core/src/tenancy/utils.js deleted file mode 100644 index 70a965ddb7..0000000000 --- a/packages/backend-core/src/tenancy/utils.js +++ /dev/null @@ -1,12 +0,0 @@ -const { DEFAULT_TENANT_ID } = require("../constants") -const { StaticDatabases, SEPARATOR } = require("../db/constants") - -exports.baseGlobalDBName = tenantId => { - let dbName - if (!tenantId || tenantId === DEFAULT_TENANT_ID) { - dbName = StaticDatabases.GLOBAL.name - } else { - dbName = `${tenantId}${SEPARATOR}${StaticDatabases.GLOBAL.name}` - } - return dbName -} diff --git a/packages/backend-core/src/users.js b/packages/backend-core/src/users.js deleted file mode 100644 index 81bf28bb46..0000000000 --- a/packages/backend-core/src/users.js +++ /dev/null @@ -1,67 +0,0 @@ -const { - ViewName, - getUsersByAppParams, - getProdAppID, - generateAppUserID, -} = require("./db/utils") -const { queryGlobalView } = require("./db/views") -const { UNICODE_MAX } = require("./db/constants") - -/** - * Given an email address this will use a view to search through - * all the users to find one with this email address. - * @param {string} email the email to lookup the user by. - */ -exports.getGlobalUserByEmail = async email => { - if (email == null) { - throw "Must supply an email address to view" - } - - return await queryGlobalView(ViewName.USER_BY_EMAIL, { - key: email.toLowerCase(), - include_docs: true, - }) -} - -exports.searchGlobalUsersByApp = async (appId, opts) => { - if (typeof appId !== "string") { - throw new Error("Must provide a string based app ID") - } - const params = getUsersByAppParams(appId, { - include_docs: true, - }) - params.startkey = opts && opts.startkey ? opts.startkey : params.startkey - let response = await queryGlobalView(ViewName.USER_BY_APP, params) - if (!response) { - response = [] - } - return Array.isArray(response) ? response : [response] -} - -exports.getGlobalUserByAppPage = (appId, user) => { - if (!user) { - return - } - return generateAppUserID(getProdAppID(appId), user._id) -} - -/** - * Performs a starts with search on the global email view. - */ -exports.searchGlobalUsersByEmail = async (email, opts) => { - if (typeof email !== "string") { - throw new Error("Must provide a string to search by") - } - const lcEmail = email.toLowerCase() - // handle if passing up startkey for pagination - const startkey = opts && opts.startkey ? opts.startkey : lcEmail - let response = await queryGlobalView(ViewName.USER_BY_EMAIL, { - ...opts, - startkey, - endkey: `${lcEmail}${UNICODE_MAX}`, - }) - if (!response) { - response = [] - } - return Array.isArray(response) ? response : [response] -} diff --git a/packages/backend-core/src/users.ts b/packages/backend-core/src/users.ts new file mode 100644 index 0000000000..44f04749c9 --- /dev/null +++ b/packages/backend-core/src/users.ts @@ -0,0 +1,94 @@ +import { + ViewName, + getUsersByAppParams, + getProdAppID, + generateAppUserID, +} from "./db/utils" +import { queryGlobalView } from "./db/views" +import { UNICODE_MAX } from "./db/constants" +import { BulkDocsResponse, User } from "@budibase/types" +import { getGlobalDB } from "./context" +import PouchDB from "pouchdb" + +export const bulkGetGlobalUsersById = async (userIds: string[]) => { + const db = getGlobalDB() as PouchDB.Database + return ( + await db.allDocs({ + keys: userIds, + include_docs: true, + }) + ).rows.map(row => row.doc) as User[] +} + +export const bulkUpdateGlobalUsers = async (users: User[]) => { + const db = getGlobalDB() as PouchDB.Database + return (await db.bulkDocs(users)) as BulkDocsResponse +} + +/** + * Given an email address this will use a view to search through + * all the users to find one with this email address. + * @param {string} email the email to lookup the user by. + */ +export const getGlobalUserByEmail = async ( + email: String +): Promise => { + if (email == null) { + throw "Must supply an email address to view" + } + + const response = await queryGlobalView(ViewName.USER_BY_EMAIL, { + key: email.toLowerCase(), + include_docs: true, + }) + + if (Array.isArray(response)) { + // shouldn't be able to happen, but need to handle just in case + throw new Error(`Multiple users found with email address: ${email}`) + } + + return response +} + +export const searchGlobalUsersByApp = async (appId: any, opts: any) => { + if (typeof appId !== "string") { + throw new Error("Must provide a string based app ID") + } + const params = getUsersByAppParams(appId, { + include_docs: true, + }) + params.startkey = opts && opts.startkey ? opts.startkey : params.startkey + let response = await queryGlobalView(ViewName.USER_BY_APP, params) + if (!response) { + response = [] + } + return Array.isArray(response) ? response : [response] +} + +export const getGlobalUserByAppPage = (appId: string, user: User) => { + if (!user) { + return + } + return generateAppUserID(getProdAppID(appId), user._id!) +} + +/** + * Performs a starts with search on the global email view. + */ +export const searchGlobalUsersByEmail = async (email: string, opts: any) => { + if (typeof email !== "string") { + throw new Error("Must provide a string to search by") + } + const lcEmail = email.toLowerCase() + // handle if passing up startkey for pagination + const startkey = opts && opts.startkey ? opts.startkey : lcEmail + let response = await queryGlobalView(ViewName.USER_BY_EMAIL, { + ...opts, + startkey, + endkey: `${lcEmail}${UNICODE_MAX}`, + }) + if (!response) { + response = [] + } + return Array.isArray(response) ? response : [response] +} diff --git a/packages/backend-core/src/utils.js b/packages/backend-core/src/utils.js index 0587267e9a..6b59c7cb72 100644 --- a/packages/backend-core/src/utils.js +++ b/packages/backend-core/src/utils.js @@ -42,6 +42,18 @@ async function resolveAppUrl(ctx) { return app && app.appId ? app.appId : undefined } +exports.isServingApp = ctx => { + // dev app + if (ctx.path.startsWith(`/${APP_PREFIX}`)) { + return true + } + // prod app + if (ctx.path.startsWith(PROD_APP_PREFIX)) { + return true + } + return false +} + /** * Given a request tries to find the appId, which can be located in various places * @param {object} ctx The main request body to look through. diff --git a/packages/backend-core/yarn.lock b/packages/backend-core/yarn.lock index 22c17a9444..2e62aea734 100644 --- a/packages/backend-core/yarn.lock +++ b/packages/backend-core/yarn.lock @@ -1377,6 +1377,11 @@ bcrypt@5.0.1: "@mapbox/node-pre-gyp" "^1.0.0" node-addon-api "^3.1.0" +bcryptjs@2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb" + integrity sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ== + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" diff --git a/packages/bbui/package.json b/packages/bbui/package.json index 41ca994b87..803396fb35 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -1,7 +1,7 @@ { "name": "@budibase/bbui", "description": "A UI solution used in the different Budibase projects.", - "version": "1.3.15-alpha.3", + "version": "2.0.24-alpha.0", "license": "MPL-2.0", "svelte": "src/index.js", "module": "dist/bbui.es.js", @@ -38,7 +38,7 @@ ], "dependencies": { "@adobe/spectrum-css-workflow-icons": "^1.2.1", - "@budibase/string-templates": "1.3.15-alpha.3", + "@budibase/string-templates": "2.0.24-alpha.0", "@spectrum-css/actionbutton": "^1.0.1", "@spectrum-css/actiongroup": "^1.0.1", "@spectrum-css/avatar": "^3.0.2", diff --git a/packages/bbui/src/Banner/BannerDisplay.svelte b/packages/bbui/src/Banner/BannerDisplay.svelte index aad742b1bd..9ea2eaf2ec 100644 --- a/packages/bbui/src/Banner/BannerDisplay.svelte +++ b/packages/bbui/src/Banner/BannerDisplay.svelte @@ -4,22 +4,32 @@ import { banner } from "../Stores/banner" import Banner from "./Banner.svelte" import { fly } from "svelte/transition" + import TooltipWrapper from "../Tooltip/TooltipWrapper.svelte"

diff --git a/packages/bbui/src/Drawer/Drawer.svelte b/packages/bbui/src/Drawer/Drawer.svelte index e1880d0ed4..43729cd794 100644 --- a/packages/bbui/src/Drawer/Drawer.svelte +++ b/packages/bbui/src/Drawer/Drawer.svelte @@ -78,7 +78,7 @@ bottom: 0; background: var(--background); border-top: var(--border-light); - z-index: 2; + z-index: 3; } .fillWidth { diff --git a/packages/bbui/src/Form/Core/Dropzone.svelte b/packages/bbui/src/Form/Core/Dropzone.svelte index 3102972d1e..51f6eef6f9 100644 --- a/packages/bbui/src/Form/Core/Dropzone.svelte +++ b/packages/bbui/src/Form/Core/Dropzone.svelte @@ -65,6 +65,9 @@ } } + $: showDropzone = + (!maximum || (maximum && value?.length < maximum)) && !disabled + async function processFileList(fileList) { if ( handleFileTooLarge && @@ -211,7 +214,7 @@ {/each} {/if} {/if} - {#if !maximum || (maximum && value?.length < maximum)} + {#if showDropzone}
false + export let isOptionEnabled = () => true export let onSelectOption = () => {} export let getOptionLabel = option => option export let getOptionValue = option => option @@ -164,6 +165,7 @@ aria-selected="true" tabindex="0" on:click={() => onSelectOption(getOptionValue(option, idx))} + class:is-disabled={!isOptionEnabled(option)} > {#if getOptionIcon(option, idx)} @@ -256,4 +258,7 @@ .spectrum-Popover :global(.spectrum-Search .spectrum-Textfield-icon) { top: 9px; } + .spectrum-Menu-item.is-disabled { + pointer-events: none; + } diff --git a/packages/bbui/src/Form/Core/PickerDropdown.svelte b/packages/bbui/src/Form/Core/PickerDropdown.svelte index 28cb2b2a4e..1607876b46 100644 --- a/packages/bbui/src/Form/Core/PickerDropdown.svelte +++ b/packages/bbui/src/Form/Core/PickerDropdown.svelte @@ -9,13 +9,13 @@ import StatusLight from "../../StatusLight/StatusLight.svelte" import Detail from "../../Typography/Detail.svelte" import Search from "./Search.svelte" + import IconAvatar from "../../Icon/IconAvatar.svelte" export let primaryLabel = "" export let primaryValue = null export let id = null export let placeholder = "Choose an option or type" export let disabled = false - export let updateOnChange = true export let error = null export let secondaryOptions = [] export let primaryOptions = [] @@ -204,19 +204,11 @@ })} > {#if primaryOptions[title].getIcon(option)} -
-
- -
-
+ {:else if getPrimaryOptionColour(option, idx)} {/if} - {primaryOptions[title].getLabel(option)} - +
+ +
+ + diff --git a/packages/bbui/src/InlineAlert/InlineAlert.svelte b/packages/bbui/src/InlineAlert/InlineAlert.svelte index 25e14b7caf..94ac6b2c2a 100644 --- a/packages/bbui/src/InlineAlert/InlineAlert.svelte +++ b/packages/bbui/src/InlineAlert/InlineAlert.svelte @@ -6,6 +6,7 @@ export let header = "" export let message = "" export let onConfirm = undefined + export let buttonText = "" $: icon = selectIcon(type) // if newlines used, convert them to different elements @@ -39,13 +40,16 @@
{splitMsg}
{/each} {#if onConfirm} - diff --git a/packages/bbui/src/Modal/ModalContent.svelte b/packages/bbui/src/Modal/ModalContent.svelte index 25fac63ec8..7d3a8d7c40 100644 --- a/packages/bbui/src/Modal/ModalContent.svelte +++ b/packages/bbui/src/Modal/ModalContent.svelte @@ -79,7 +79,7 @@ {/if} {#if showDivider} - + {/if} {/if} diff --git a/packages/bbui/src/Popover/Popover.svelte b/packages/bbui/src/Popover/Popover.svelte index 91581724d5..ded0ed6cfd 100644 --- a/packages/bbui/src/Popover/Popover.svelte +++ b/packages/bbui/src/Popover/Popover.svelte @@ -65,6 +65,7 @@ diff --git a/packages/bbui/src/SideNavigation/Item.svelte b/packages/bbui/src/SideNavigation/Item.svelte index 30da1fa172..aa86fc02a8 100644 --- a/packages/bbui/src/SideNavigation/Item.svelte +++ b/packages/bbui/src/SideNavigation/Item.svelte @@ -1,6 +1,7 @@
  • {/if} + {#if badge} +
    + {badge} +
    + {/if} + {#if multilevel && $$slots.subnav}
    {/if}
  • + + diff --git a/packages/bbui/src/Stores/banner.js b/packages/bbui/src/Stores/banner.js index 81a9ee2204..ba6d187d97 100644 --- a/packages/bbui/src/Stores/banner.js +++ b/packages/bbui/src/Stores/banner.js @@ -1,7 +1,14 @@ import { writable } from "svelte/store" +export const BANNER_TYPES = { + INFO: "info", + NEGATIVE: "negative", +} + export function createBannerStore() { - const DEFAULT_CONFIG = {} + const DEFAULT_CONFIG = { + messages: [], + } const banner = writable(DEFAULT_CONFIG) @@ -20,17 +27,38 @@ export function createBannerStore() { const showStatus = async () => { const config = { message: "Some systems are experiencing issues", - type: "negative", + type: BANNER_TYPES.NEGATIVE, extraButtonText: "View Status", extraButtonAction: () => window.open("https://status.budibase.com/"), } - await show(config) + await queue([config]) + } + + const queue = async entries => { + const priority = { + [BANNER_TYPES.NEGATIVE]: 0, + [BANNER_TYPES.INFO]: 1, + } + banner.update(store => { + const sorted = [...store.messages, ...entries].sort((a, b) => { + if (priority[a.type] == priority[b.type]) { + return 0 + } + return priority[a.type] < priority[b.type] ? -1 : 1 + }) + return { + ...store, + messages: sorted, + } + }) } return { subscribe: banner.subscribe, showStatus, + show, + queue, } } diff --git a/packages/bbui/src/Table/AttachmentRenderer.svelte b/packages/bbui/src/Table/AttachmentRenderer.svelte index 3017aac9b7..b4de8672ae 100644 --- a/packages/bbui/src/Table/AttachmentRenderer.svelte +++ b/packages/bbui/src/Table/AttachmentRenderer.svelte @@ -20,6 +20,9 @@ target="_blank" download={attachment.name} href={attachment.url} + on:click={e => { + e.stopPropagation() + }} >
    {attachment.extension} @@ -32,6 +35,9 @@ target="_blank" download={attachment.name} href={attachment.url} + on:click={e => { + e.stopPropagation() + }} > {attachment.extension} diff --git a/packages/bbui/src/Tooltip/TooltipWrapper.svelte b/packages/bbui/src/Tooltip/TooltipWrapper.svelte index 92f5c6f474..09998d2c52 100644 --- a/packages/bbui/src/Tooltip/TooltipWrapper.svelte +++ b/packages/bbui/src/Tooltip/TooltipWrapper.svelte @@ -4,6 +4,7 @@ export let tooltip = "" export let size = "M" + export let disabled = true let showTooltip = false @@ -19,7 +20,7 @@ on:mouseleave={() => (showTooltip = false)} on:focus > - +
    {#if showTooltip}
    @@ -47,14 +48,13 @@ display: flex; justify-content: center; top: 15px; - z-index: 100; + z-index: 200; width: 160px; } .icon { transform: scale(0.75); } .icon-small { - margin-top: -2px; - margin-bottom: -5px; + margin-bottom: -2px; } diff --git a/packages/bbui/src/index.js b/packages/bbui/src/index.js index b45f3e9ed6..538a62188f 100644 --- a/packages/bbui/src/index.js +++ b/packages/bbui/src/index.js @@ -20,6 +20,7 @@ export { default as Button } from "./Button/Button.svelte" export { default as ButtonGroup } from "./ButtonGroup/ButtonGroup.svelte" export { default as ClearButton } from "./ClearButton/ClearButton.svelte" export { default as Icon, directions } from "./Icon/Icon.svelte" +export { default as IconAvatar } from "./Icon/IconAvatar.svelte" export { default as Toggle } from "./Form/Toggle.svelte" export { default as RadioGroup } from "./Form/RadioGroup.svelte" export { default as Checkbox } from "./Form/Checkbox.svelte" @@ -34,6 +35,7 @@ export { default as Layout } from "./Layout/Layout.svelte" export { default as Page } from "./Layout/Page.svelte" export { default as Link } from "./Link/Link.svelte" export { default as Tooltip } from "./Tooltip/Tooltip.svelte" +export { default as TooltipWrapper } from "./Tooltip/TooltipWrapper.svelte" export { default as Menu } from "./Menu/Menu.svelte" export { default as MenuSection } from "./Menu/Section.svelte" export { default as MenuSeparator } from "./Menu/Separator.svelte" @@ -94,7 +96,7 @@ export { default as clickOutside } from "./Actions/click_outside" // Stores export { notifications, createNotificationStore } from "./Stores/notifications" -export { banner } from "./Stores/banner" +export { banner, BANNER_TYPES } from "./Stores/banner" // Helpers export * as Helpers from "./helpers" diff --git a/packages/builder/cypress/integration/adminAndManagement/userManagement.spec.js b/packages/builder/cypress/integration/adminAndManagement/userManagement.spec.js index 000ca7cb54..4844a0c670 100644 --- a/packages/builder/cypress/integration/adminAndManagement/userManagement.spec.js +++ b/packages/builder/cypress/integration/adminAndManagement/userManagement.spec.js @@ -20,7 +20,9 @@ filterTests(["smoke", "all"], () => { cy.get(".spectrum-Form-itemField").eq(3).should('contain', 'App User') // User should not have app access - cy.get(interact.LIST_ITEMS, { timeout: 500 }).should("contain", "No apps") + cy.get(".spectrum-Heading").contains("Apps").parent().within(() => { + cy.get(interact.LIST_ITEMS, { timeout: 500 }).should("contain", "This user has access to no apps") + }) }) if (Cypress.env("TEST_ENV")) { diff --git a/packages/builder/cypress/integration/autoScreensUI.spec.js b/packages/builder/cypress/integration/autoScreensUI.spec.js index 7a5dbef5a5..a22dbb0a1e 100644 --- a/packages/builder/cypress/integration/autoScreensUI.spec.js +++ b/packages/builder/cypress/integration/autoScreensUI.spec.js @@ -54,6 +54,7 @@ filterTests(['smoke', 'all'], () => { cy.createDatasourceScreen([initialTable, secondTable]) // Confirm screens have been auto generated // Previously generated tables are suffixed with numbers - as expected + cy.wait(1000) cy.get(interact.BODY).should('contain', 'cypress-tests-2') .and('contain', 'cypress-tests-2/:id') .and('contain', 'cypress-tests-2/new/row') @@ -82,10 +83,10 @@ filterTests(['smoke', 'all'], () => { }) if (Cypress.env("TEST_ENV")) { - it("should generate data source screens", () => { - // Using MySQL data source for testing this + it("should generate datasource screens", () => { + // Using MySQL datasource for testing this const datasource = "MySQL" - // Select & configure MySQL data source + // Select & configure MySQL datasource cy.selectExternalDatasource(datasource) cy.addDatasourceConfig(datasource) // Create Autogenerated screens from a MySQL table - MySQL contains books table diff --git a/packages/builder/cypress/integration/createComponents.spec.js b/packages/builder/cypress/integration/createComponents.spec.js index e39ce4a4a8..7f29466258 100644 --- a/packages/builder/cypress/integration/createComponents.spec.js +++ b/packages/builder/cypress/integration/createComponents.spec.js @@ -2,7 +2,7 @@ import filterTests from "../support/filterTests" const interact = require("../support/interact") filterTests(["all"], () => { - context("Create Components", () => { + xcontext("Create Components", () => { let headlineId before(() => { diff --git a/packages/builder/cypress/integration/datasources/mySql.spec.js b/packages/builder/cypress/integration/datasources/mySql.spec.js index 654705a24e..33aa72f0bb 100644 --- a/packages/builder/cypress/integration/datasources/mySql.spec.js +++ b/packages/builder/cypress/integration/datasources/mySql.spec.js @@ -11,8 +11,8 @@ filterTests(["all"], () => { const queryName = "Cypress Test Query" const queryRename = "CT Query Rename" - it("Should add MySQL data source without configuration", () => { - // Select MySQL data source + it("Should add MySQL datasource without configuration", () => { + // Select MySQL datasource cy.selectExternalDatasource(datasource) // Attempt to fetch tables without applying configuration cy.intercept("**/datasources").as("datasource") @@ -35,8 +35,8 @@ filterTests(["all"], () => { cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true }) }) - it("should add MySQL data source and fetch tables", () => { - // Add & configure MySQL data source + it("should add MySQL datasource and fetch tables", () => { + // Add & configure MySQL datasource cy.selectExternalDatasource(datasource) cy.intercept("**/datasources").as("datasource") cy.addDatasourceConfig(datasource) @@ -52,7 +52,7 @@ filterTests(["all"], () => { }) it("should check table fetching error", () => { - // MySQL test data source contains tables without primary keys + // MySQL test datasource contains tables without primary keys cy.get(".spectrum-InLineAlert") .should("contain", "Error fetching tables") .and("contain", "No primary key constraint found") diff --git a/packages/builder/cypress/integration/datasources/oracle.spec.js b/packages/builder/cypress/integration/datasources/oracle.spec.js index 5d92d6b217..ae1ca5cd75 100644 --- a/packages/builder/cypress/integration/datasources/oracle.spec.js +++ b/packages/builder/cypress/integration/datasources/oracle.spec.js @@ -11,8 +11,8 @@ filterTests(["all"], () => { const queryName = "Cypress Test Query" const queryRename = "CT Query Rename" - it("Should add Oracle data source and skip table fetch", () => { - // Select Oracle data source + it("Should add Oracle datasource and skip table fetch", () => { + // Select Oracle datasource cy.selectExternalDatasource(datasource) // Skip table fetch - no config added cy.get(".spectrum-Button") @@ -23,7 +23,7 @@ filterTests(["all"], () => { cy.get(".spectrum-Textfield-input", { timeout: 500 }) .eq(1) .should("have.value", "localhost") - // Add another Oracle data source, configure & skip table fetch + // Add another Oracle datasource, configure & skip table fetch cy.selectExternalDatasource(datasource) cy.addDatasourceConfig(datasource, true) // Confirm config and no tables @@ -33,8 +33,8 @@ filterTests(["all"], () => { cy.get(".spectrum-Body").eq(2).should("contain", "No tables found.") }) - it("Should add Oracle data source and fetch tables without configuration", () => { - // Select Oracle data source + it("Should add Oracle datasource and fetch tables without configuration", () => { + // Select Oracle datasource cy.selectExternalDatasource(datasource) // Attempt to fetch tables without applying configuration cy.intercept("**/datasources").as("datasource") @@ -49,8 +49,8 @@ filterTests(["all"], () => { cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true }) }) - xit("should add Oracle data source and fetch tables", () => { - // Add & configure Oracle data source + xit("should add Oracle datasource and fetch tables", () => { + // Add & configure Oracle datasource cy.selectExternalDatasource(datasource) cy.intercept("**/datasources").as("datasource") cy.addDatasourceConfig(datasource) diff --git a/packages/builder/cypress/integration/datasources/postgreSql.spec.js b/packages/builder/cypress/integration/datasources/postgreSql.spec.js index 622c3ade73..8ef574566e 100644 --- a/packages/builder/cypress/integration/datasources/postgreSql.spec.js +++ b/packages/builder/cypress/integration/datasources/postgreSql.spec.js @@ -11,8 +11,8 @@ filterTests(["all"], () => { const queryName = "Cypress Test Query" const queryRename = "CT Query Rename" - xit("Should add PostgreSQL data source without configuration", () => { - // Select PostgreSQL data source + xit("Should add PostgreSQL datasource without configuration", () => { + // Select PostgreSQL datasource cy.selectExternalDatasource(datasource) // Attempt to fetch tables without applying configuration cy.intercept("**/datasources").as("datasource") @@ -27,8 +27,8 @@ filterTests(["all"], () => { cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true }) }) - it("should add PostgreSQL data source and fetch tables", () => { - // Add & configure PostgreSQL data source + it("should add PostgreSQL datasource and fetch tables", () => { + // Add & configure PostgreSQL datasource cy.selectExternalDatasource(datasource) cy.intercept("**/datasources").as("datasource") cy.addDatasourceConfig(datasource) diff --git a/packages/builder/cypress/integration/datasources/rest.spec.js b/packages/builder/cypress/integration/datasources/rest.spec.js index 7a145049e2..ec9864a47d 100644 --- a/packages/builder/cypress/integration/datasources/rest.spec.js +++ b/packages/builder/cypress/integration/datasources/rest.spec.js @@ -10,8 +10,8 @@ filterTests(["smoke", "all"], () => { const datasource = "REST" const restUrl = "https://api.openbrewerydb.org/breweries" - it("Should add REST data source with incorrect API", () => { - // Select REST data source + it("Should add REST datasource with incorrect API", () => { + // Select REST datasource cy.selectExternalDatasource(datasource) // Enter incorrect api & attempt to send query cy.get(".query-buttons", { timeout: 1000 }).contains("Add query").click({ force: true }) diff --git a/packages/builder/cypress/setup.js b/packages/builder/cypress/setup.js index d858801990..0e2f25b028 100644 --- a/packages/builder/cypress/setup.js +++ b/packages/builder/cypress/setup.js @@ -1,16 +1,14 @@ const cypressConfig = require("../cypress.json") -const path = require("path") - -const tmpdir = path.join(require("os").tmpdir(), ".budibase") // normal development system const SERVER_PORT = cypressConfig.env.PORT const WORKER_PORT = cypressConfig.env.WORKER_PORT -process.env.NODE_ENV = "cypress" +if (!process.env.NODE_ENV) { + process.env.NODE_ENV = "cypress" +} process.env.ENABLE_ANALYTICS = "0" process.env.JWT_SECRET = cypressConfig.env.JWT_SECRET -process.env.COUCH_URL = `leveldb://${tmpdir}/.data/` process.env.SELF_HOSTED = 1 process.env.WORKER_URL = `http://localhost:${WORKER_PORT}/` process.env.APPS_URL = `http://localhost:${SERVER_PORT}/` diff --git a/packages/builder/cypress/support/commands.js b/packages/builder/cypress/support/commands.js index a07a22188f..b1fa629023 100644 --- a/packages/builder/cypress/support/commands.js +++ b/packages/builder/cypress/support/commands.js @@ -402,8 +402,8 @@ Cypress.Commands.add("searchForApplication", appName => { // Searches for the app cy.get(".filter").then(() => { cy.get(".spectrum-Textfield").within(() => { - cy.get("input").eq(0).clear() - cy.get("input").eq(0).type(appName) + cy.get("input").eq(0).clear({ force: true }) + cy.get("input").eq(0).type(appName, { force: true }) }) }) } @@ -763,7 +763,7 @@ Cypress.Commands.add("navigateToDataSection", () => { }) Cypress.Commands.add("navigateToAutogeneratedModal", () => { - // Screen name must already exist within data source + // Screen name must already exist within datasource cy.contains("Design").click() cy.get(".spectrum-Button").contains("Add screen").click({ force: true }) cy.get(".spectrum-Modal").within(() => { @@ -779,7 +779,7 @@ Cypress.Commands.add("navigateToAutogeneratedModal", () => { Cypress.Commands.add("selectExternalDatasource", datasourceName => { // Navigates to Data Section cy.navigateToDataSection() - // Open Data Source modal + // Open Datasource modal cy.get(".nav").within(() => { cy.get(".add-button").click() }) diff --git a/packages/builder/package.json b/packages/builder/package.json index dd918132d7..687b571b66 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/builder", - "version": "1.3.15-alpha.3", + "version": "2.0.24-alpha.0", "license": "GPL-3.0", "private": true, "scripts": { @@ -9,6 +9,7 @@ "dev:builder": "routify -c dev:vite", "dev:vite": "vite --host 0.0.0.0", "rollup": "rollup -c -w", + "test": "jest", "cy:setup": "ts-node ./cypress/ts/setup.ts", "cy:setup:ci": "node ./cypress/setup.js", "cy:open": "cypress open", @@ -36,7 +37,8 @@ "components(.*)$": "/src/components$1", "builderStore(.*)$": "/src/builderStore$1", "stores(.*)$": "/src/stores$1", - "analytics(.*)$": "/src/analytics$1" + "analytics(.*)$": "/src/analytics$1", + "constants/backend": "/src/constants/backend/index.js" }, "moduleFileExtensions": [ "js", @@ -69,10 +71,10 @@ } }, "dependencies": { - "@budibase/bbui": "1.3.15-alpha.3", - "@budibase/client": "1.3.15-alpha.3", - "@budibase/frontend-core": "1.3.15-alpha.3", - "@budibase/string-templates": "1.3.15-alpha.3", + "@budibase/bbui": "2.0.24-alpha.0", + "@budibase/client": "2.0.24-alpha.0", + "@budibase/frontend-core": "2.0.24-alpha.0", + "@budibase/string-templates": "2.0.24-alpha.0", "@sentry/browser": "5.19.1", "@spectrum-css/page": "^3.0.1", "@spectrum-css/vars": "^3.0.1", diff --git a/packages/builder/src/App.svelte b/packages/builder/src/App.svelte index 0fb0fe59d5..4d193df104 100644 --- a/packages/builder/src/App.svelte +++ b/packages/builder/src/App.svelte @@ -4,6 +4,7 @@ import { NotificationDisplay, BannerDisplay } from "@budibase/bbui" import { parse, stringify } from "qs" import HelpIcon from "components/common/HelpIcon.svelte" + import LicensingOverlays from "components/portal/licensing/LicensingOverlays.svelte" const queryHandler = { parse, stringify } @@ -12,6 +13,9 @@ + + + diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index 21059b32dd..3fd38bddeb 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -314,7 +314,7 @@ const relatedTable = $tables.list.find( tbl => tbl._id === fieldInfo.tableId ) - if (inUse(relatedTable, fieldInfo.fieldName)) { + if (inUse(relatedTable, fieldInfo.fieldName) && !originalName) { newError.relatedName = `Column name already in use in table ${relatedTable.name}` } } diff --git a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte index 6235e52916..a3531513fb 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte @@ -13,7 +13,7 @@ customQueryIconColor, customQueryText, } from "helpers/data/utils" - import { getIcon } from "./icons" + import IntegrationIcon from "./IntegrationIcon.svelte" import { notifications } from "@budibase/bbui" let openDataSources = [] @@ -32,8 +32,8 @@ : [] $: openDataSource = enrichedDataSources.find(x => x.open) $: { - // Ensure the open data source is always included in the list of open - // data sources + // Ensure the open datasource is always included in the list of open + // datasources if (openDataSource) { openNode(openDataSource) } @@ -79,7 +79,7 @@ }) const containsActiveEntity = datasource => { - // If we're view a query then the data source ID is in the URL + // If we're view a query then the datasource ID is in the URL if ($params.selectedDatasource === datasource._id) { return true } @@ -123,10 +123,10 @@ on:iconClick={() => toggleNode(datasource)} >
    -
    {#if datasource._id !== BUDIBASE_INTERNAL_DB} diff --git a/packages/builder/src/components/backend/DatasourceNavigator/IntegrationIcon.svelte b/packages/builder/src/components/backend/DatasourceNavigator/IntegrationIcon.svelte new file mode 100644 index 0000000000..e6cfbf7db8 --- /dev/null +++ b/packages/builder/src/components/backend/DatasourceNavigator/IntegrationIcon.svelte @@ -0,0 +1,32 @@ + + +{#if iconInfo.icon} + +{:else if iconInfo.url} + {#await getSvgFromUrl(iconInfo) then retrievedSvg} + + {/await} +{/if} diff --git a/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte b/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte index b81e818d5f..cef49d81a1 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte @@ -173,7 +173,7 @@ - +
    Tables
    @@ -209,7 +209,7 @@ {:else} No tables found. {/if} - +
    Relationships
    -
    -
    - +
    +
    - -
    + {/if}
    {#if filteredPlugins?.length} diff --git a/packages/builder/src/pages/builder/portal/manage/users/[userId].svelte b/packages/builder/src/pages/builder/portal/manage/users/[userId].svelte index 8f7b24f1b6..f818595539 100644 --- a/packages/builder/src/pages/builder/portal/manage/users/[userId].svelte +++ b/packages/builder/src/pages/builder/portal/manage/users/[userId].svelte @@ -19,17 +19,17 @@ Modal, notifications, Divider, + Banner, StatusLight, } from "@budibase/bbui" import { onMount } from "svelte" - import { fetchData } from "helpers" - import { users, auth, groups, apps } from "stores/portal" + import { users, auth, groups, apps, licensing } from "stores/portal" import { roles } from "stores/backend" - import { Constants } from "@budibase/frontend-core" import ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte" - import { RoleUtils } from "@budibase/frontend-core" import UserGroupPicker from "components/settings/UserGroupPicker.svelte" import DeleteUserModal from "./_components/DeleteUserModal.svelte" + import GroupIcon from "../groups/_components/GroupIcon.svelte" + import { Constants, RoleUtils } from "@budibase/frontend-core" export let userId @@ -38,59 +38,57 @@ let popoverAnchor let searchTerm = "" let popover - let selectedGroups = [] - let allAppList = [] let user let loaded = false - $: fetchUser(userId) - $: fullName = $userFetch?.data?.firstName - ? $userFetch?.data?.firstName + " " + $userFetch?.data?.lastName - : "" - $: nameLabel = getNameLabel($userFetch) + $: fullName = user?.firstName ? user?.firstName + " " + user?.lastName : "" + $: privileged = user?.admin?.global || user?.builder?.global + $: nameLabel = getNameLabel(user) $: initials = getInitials(nameLabel) - $: allAppList = $apps - .filter(x => { - if ($userFetch.data?.roles) { - return Object.keys($userFetch.data.roles).find(y => { - return x.appId === apps.extractAppId(y) - }) - } - }) - .map(app => { - let roles = Object.fromEntries( - Object.entries($userFetch.data.roles).filter(([key]) => { - return apps.extractAppId(key) === app.appId - }) - ) - return { - name: app.name, - devId: app.devId, - icon: app.icon, - roles, - } - }) - // Used for searching through groups in the add group popover - $: filteredGroups = $groups.filter( - group => - selectedGroups && - group?.name?.toLowerCase().includes(searchTerm.toLowerCase()) - ) + $: filteredGroups = getFilteredGroups($groups, searchTerm) + $: availableApps = getAvailableApps($apps, privileged, user?.roles) $: userGroups = $groups.filter(x => { return x.users?.find(y => { return y._id === userId }) }) - $: globalRole = $userFetch?.data?.admin?.global + $: globalRole = user?.admin?.global ? "admin" - : $userFetch?.data?.builder?.global + : user?.builder?.global ? "developer" : "appUser" - const userFetch = fetchData(`/api/global/users/${userId}`) + const getAvailableApps = (appList, privileged, roles) => { + let availableApps = appList.slice() + if (!privileged) { + availableApps = availableApps.filter(x => { + return Object.keys(roles || {}).find(y => { + return x.appId === apps.extractAppId(y) + }) + }) + } + return availableApps.map(app => { + const prodAppId = apps.getProdAppID(app.appId) + console.log(prodAppId) + return { + name: app.name, + devId: app.devId, + icon: app.icon, + role: privileged ? Constants.Roles.ADMIN : roles[prodAppId], + } + }) + } - const getNameLabel = userFetch => { - const { firstName, lastName, email } = userFetch?.data || {} + const getFilteredGroups = (groups, search) => { + if (!search) { + return groups + } + search = search.toLowerCase() + return groups.filter(group => group.name?.toLowerCase().includes(search)) + } + + const getNameLabel = user => { + const { firstName, lastName, email } = user || {} if (!firstName && !lastName) { return email || "" } @@ -122,38 +120,19 @@ return role?.name || "Custom role" } - function getHighestRole(roles) { - let highestRole - let highestRoleNumber = 0 - Object.keys(roles).forEach(role => { - let roleNumber = RoleUtils.getRolePriority(roles[role]) - if (roleNumber > highestRoleNumber) { - highestRoleNumber = roleNumber - highestRole = roles[role] - } - }) - return highestRole - } async function updateUserFirstName(evt) { try { - await users.save({ ...$userFetch?.data, firstName: evt.target.value }) - await userFetch.refresh() + await users.save({ ...user, firstName: evt.target.value }) + await fetchUser() } catch (error) { notifications.error("Error updating user") } } - async function removeGroup(id) { - let updatedGroup = $groups.find(x => x._id === id) - let newUsers = updatedGroup.users.filter(user => user._id !== userId) - updatedGroup.users = newUsers - groups.actions.save(updatedGroup) - } - async function updateUserLastName(evt) { try { - await users.save({ ...$userFetch?.data, lastName: evt.target.value }) - await userFetch.refresh() + await users.save({ ...user, lastName: evt.target.value }) + await fetchUser() } catch (error) { notifications.error("Error updating user") } @@ -169,40 +148,40 @@ } } - async function addGroup(groupId) { - let selectedGroup = selectedGroups.includes(groupId) - let group = $groups.find(group => group._id === groupId) - - if (selectedGroup) { - selectedGroups = selectedGroups.filter(id => id === selectedGroup) - let newUsers = group.users.filter(groupUser => user._id !== groupUser._id) - group.users = newUsers - } else { - selectedGroups = [...selectedGroups, groupId] - group.users.push(user) + async function fetchUser() { + user = await users.get(userId) + if (!user?._id) { + $goto("./") } - - await groups.actions.save(group) - } - - async function fetchUser(userId) { - let userPromise = users.get(userId) - user = await userPromise } async function toggleFlags(detail) { try { - await users.save({ ...$userFetch?.data, ...detail }) - await userFetch.refresh() + await users.save({ ...user, ...detail }) + await fetchUser() } catch (error) { notifications.error("Error updating user") } } - function addAll() {} + const addGroup = async groupId => { + await groups.actions.addUser(groupId, userId) + await fetchUser() + } + + const removeGroup = async groupId => { + await groups.actions.removeUser(groupId, userId) + await fetchUser() + } + onMount(async () => { try { - await Promise.all([groups.actions.init(), apps.load(), roles.fetch()]) + await Promise.all([ + fetchUser(), + groups.actions.init(), + apps.load(), + roles.fetch(), + ]) loaded = true } catch (error) { notifications.error("Error getting user groups") @@ -225,13 +204,13 @@
    {nameLabel} - {#if nameLabel !== $userFetch?.data?.email} - {$userFetch?.data?.email} + {#if nameLabel !== user?.email} + {user?.email} {/if}
    - {#if userId !== $auth.user._id} + {#if userId !== $auth.user?._id}
    @@ -247,27 +226,21 @@
    {/if} - + Details
    - +
    - +
    - +
    {#if userId !== $auth.user._id} @@ -284,7 +257,7 @@ - {#if $auth.groupsEnabled} + {#if $licensing.groupsEnabled}
    @@ -301,13 +274,14 @@
    addGroup(e.detail)} + on:deselect={e => removeGroup(e.detail)} + iconComponent={GroupIcon} + extractIconProps={item => ({ group: item, size: "S" })} />
    @@ -322,7 +296,10 @@ on:click={() => $goto(`../groups/${group._id}`)} > { + removeGroup(group._id) + e.stopPropagation() + }} hoverable size="S" name="Close" @@ -330,7 +307,7 @@ {/each} {:else} - + {/if}
    @@ -339,27 +316,28 @@ Apps - {#if allAppList.length} - {#each allAppList as app} + {#if privileged} + + This user's role grants admin access to all apps + + {:else if availableApps.length} + {#each availableApps as app} $goto(`../../overview/${app.devId}`)} >
    - - {getRoleLabel(getHighestRole(app.roles))} + + {getRoleLabel(app.role)}
    {/each} {:else} - + {/if}
    @@ -367,13 +345,10 @@ {/if} - + - + diff --git a/packages/builder/src/pages/builder/portal/settings/usage.svelte b/packages/builder/src/pages/builder/portal/settings/usage.svelte index 069c37b555..7609a4742e 100644 --- a/packages/builder/src/pages/builder/portal/settings/usage.svelte +++ b/packages/builder/src/pages/builder/portal/settings/usage.svelte @@ -5,49 +5,74 @@ Heading, Layout, notifications, + Detail, Link, + TooltipWrapper, } from "@budibase/bbui" import { onMount } from "svelte" - import { admin, auth, licensing } from "stores/portal" - import Usage from "components/usage/Usage.svelte" + import { admin, auth, licensing } from "../../../../stores/portal" + import { Constants } from "@budibase/frontend-core" + import { DashCard, Usage } from "../../../../components/usage" let staticUsage = [] let monthlyUsage = [] + let cancelAt let loaded = false + let textRows = [] + let daysRemainingInMonth + let primaryActionText + + const upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade` + const manageUrl = `${$admin.accountPortalUrl}/portal/billing` + + const WARN_USAGE = ["Queries", "Automations", "Rows", "Day Passes"] + const EXCLUDE_QUOTAS = ["Queries"] $: quotaUsage = $licensing.quotaUsage $: license = $auth.user?.license - - const upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade` + $: accountPortalAccess = $auth?.user?.accountPortalAccess + $: quotaReset = quotaUsage?.quotaReset const setMonthlyUsage = () => { monthlyUsage = [] if (quotaUsage.monthly) { for (let [key, value] of Object.entries(license.quotas.usage.monthly)) { + if (EXCLUDE_QUOTAS.includes(value.name)) { + continue + } const used = quotaUsage.monthly.current[key] - if (used !== undefined) { + if (value.value !== 0) { monthlyUsage.push({ name: value.name, - used: used, + used: used ? used : 0, total: value.value, }) } } } + monthlyUsage = monthlyUsage.sort((a, b) => a.name.localeCompare(b.name)) } const setStaticUsage = () => { staticUsage = [] for (let [key, value] of Object.entries(license.quotas.usage.static)) { + if (EXCLUDE_QUOTAS.includes(value.name)) { + continue + } const used = quotaUsage.usageQuota[key] - if (used !== undefined) { + if (value.value !== 0) { staticUsage.push({ name: value.name, - used: used, + used: used ? used : 0, total: value.value, }) } } + staticUsage = staticUsage.sort((a, b) => a.name.localeCompare(b.name)) + } + + const setCancelAt = () => { + cancelAt = license?.billing?.subscription?.cancelAt } const capitalise = string => { @@ -56,9 +81,74 @@ } } + const planTitle = () => { + return capitalise(license?.plan.type) + } + + const getDaysRemaining = timestamp => { + if (!timestamp) { + return + } + const now = new Date() + now.setHours(0) + now.setMinutes(0) + + const thenDate = new Date(timestamp) + thenDate.setHours(0) + thenDate.setMinutes(0) + + const difference = thenDate.getTime() - now + // return the difference in days + return (difference / (1000 * 3600 * 24)).toFixed(0) + } + + const setTextRows = () => { + textRows = [] + + if (cancelAt) { + textRows.push({ message: "Subscription has been cancelled" }) + textRows.push({ + message: `${getDaysRemaining(cancelAt * 1000)} days remaining`, + tooltip: new Date(cancelAt * 1000), + }) + } + } + + const setDaysRemainingInMonth = () => { + const resetDate = new Date(quotaReset) + + const now = new Date() + const difference = resetDate.getTime() - now.getTime() + + // return the difference in days + daysRemainingInMonth = (difference / (1000 * 3600 * 24)).toFixed(0) + } + + const goToAccountPortal = () => { + if (license?.plan.type === Constants.PlanType.FREE) { + window.location.href = upgradeUrl + } else { + window.location.href = manageUrl + } + } + + const setPrimaryActionText = () => { + if (license?.plan.type === Constants.PlanType.FREE) { + primaryActionText = "Upgrade" + return + } + + if (cancelAt) { + primaryActionText = "Renew" + } else { + primaryActionText = "Manage" + } + } + const init = async () => { try { - await licensing.getQuotaUsage() + // always load latest + await licensing.init() } catch (e) { console.error(e) notifications.error(e) @@ -71,69 +161,98 @@ }) $: { - if (license && quotaUsage) { - setMonthlyUsage() - setStaticUsage() + if (license) { + setPrimaryActionText() + setCancelAt() + setTextRows() + setDaysRemainingInMonth() + + if (quotaUsage) { + setMonthlyUsage() + setStaticUsage() + } } } {#if loaded} - - Usage - Get information about your current usage within Budibase. - {#if $admin.cloud} - {#if $auth.user?.accountPortalAccess} + + + Usage + + Get information about your current usage within Budibase. + {#if accountPortalAccess} To upgrade your plan and usage limits visit your Account. + on:click={goToAccountPortal} + size="L">Account {:else} - Contact your account holder to upgrade your usage limits. + To upgrade your plan and usage limits contact your account holder. {/if} - {/if} - - - - - - - - YOUR PLAN - {capitalise(license?.plan.type)} + - - USAGE -
    - {#each staticUsage as usage} -
    - + + + + +
    + + {#each staticUsage as usage} +
    + +
    + {/each} +
    - {/each} -
    - - {#if monthlyUsage.length} - - MONTHLY -
    - {#each monthlyUsage as usage} -
    - -
    - {/each} -
    +
    + {#if monthlyUsage.length} +
    + + Monthly +
    + + Resets in {daysRemainingInMonth} days + +
    +
    + + {#each monthlyUsage as usage} +
    + +
    + {/each} +
    +
    +
    +
    + {/if} -
    - {/if} + {/if} diff --git a/packages/builder/src/stores/backend/queries.js b/packages/builder/src/stores/backend/queries.js index bb456ce405..2046d71d9d 100644 --- a/packages/builder/src/stores/backend/queries.js +++ b/packages/builder/src/stores/backend/queries.js @@ -1,7 +1,7 @@ import { writable, get } from "svelte/store" import { datasources, integrations, tables, views } from "./" import { API } from "api" -import { duplicateName } from "../../helpers/duplicate" +import { duplicateName } from "helpers/duplicate" const sortQueries = queryList => { queryList.sort((q1, q2) => { diff --git a/packages/builder/src/stores/backend/roles.js b/packages/builder/src/stores/backend/roles.js index 4d9ab303c9..ac395aa232 100644 --- a/packages/builder/src/stores/backend/roles.js +++ b/packages/builder/src/stores/backend/roles.js @@ -5,16 +5,24 @@ import { RoleUtils } from "@budibase/frontend-core" export function createRolesStore() { const { subscribe, update, set } = writable([]) + function setRoles(roles) { + set( + roles.sort((a, b) => { + const priorityA = RoleUtils.getRolePriority(a._id) + const priorityB = RoleUtils.getRolePriority(b._id) + return priorityA > priorityB ? -1 : 1 + }) + ) + } + const actions = { fetch: async () => { const roles = await API.getRoles() - set( - roles.sort((a, b) => { - const priorityA = RoleUtils.getRolePriority(a._id) - const priorityB = RoleUtils.getRolePriority(b._id) - return priorityA > priorityB ? -1 : 1 - }) - ) + setRoles(roles) + }, + fetchByAppId: async appId => { + const { roles } = await API.getRolesForApp(appId) + setRoles(roles) }, delete: async role => { await API.deleteRole({ diff --git a/packages/builder/src/stores/backend/tables.js b/packages/builder/src/stores/backend/tables.js index c6362aa3b2..cbbe0bd496 100644 --- a/packages/builder/src/stores/backend/tables.js +++ b/packages/builder/src/stores/backend/tables.js @@ -2,7 +2,7 @@ import { get, writable } from "svelte/store" import { datasources, queries, views } from "./" import { cloneDeep } from "lodash/fp" import { API } from "api" -import { SWITCHABLE_TYPES } from "../../constants/backend" +import { SWITCHABLE_TYPES } from "constants/backend" export function createTablesStore() { const store = writable({}) diff --git a/packages/builder/src/stores/backend/tests/datasources.spec.js b/packages/builder/src/stores/backend/tests/datasources.spec.js.disabled similarity index 61% rename from packages/builder/src/stores/backend/tests/datasources.spec.js rename to packages/builder/src/stores/backend/tests/datasources.spec.js.disabled index 46e9568b50..772aaf36a2 100644 --- a/packages/builder/src/stores/backend/tests/datasources.spec.js +++ b/packages/builder/src/stores/backend/tests/datasources.spec.js.disabled @@ -1,9 +1,9 @@ -import { get } from 'svelte/store' -import api from 'builderStore/api' +import { get } from "svelte/store" +import { API } from "api" -jest.mock('builderStore/api'); +jest.mock("api") -import { SOME_DATASOURCE, SAVE_DATASOURCE} from './fixtures/datasources' +import { SOME_DATASOURCE, SAVE_DATASOURCE } from "./fixtures/datasources" import { createDatasourcesStore } from "../datasources" import { queries } from '../queries' @@ -12,39 +12,39 @@ describe("Datasources Store", () => { let store = createDatasourcesStore() beforeEach(async () => { - api.get.mockReturnValue({ json: () => [SOME_DATASOURCE]}) + API.getDatasources.mockReturnValue({ json: () => [SOME_DATASOURCE]}) await store.init() }) it("Initialises correctly", async () => { - api.get.mockReturnValue({ json: () => [SOME_DATASOURCE]}) - + API.getDatasources.mockReturnValue({ json: () => [SOME_DATASOURCE]}) + await store.init() expect(get(store)).toEqual({ list: [SOME_DATASOURCE], selected: null}) }) it("fetches all the datasources and updates the store", async () => { - api.get.mockReturnValue({ json: () => [SOME_DATASOURCE] }) + API.getDatasources.mockReturnValue({ json: () => [SOME_DATASOURCE] }) await store.fetch() - expect(get(store)).toEqual({ list: [SOME_DATASOURCE], selected: null }) + expect(get(store)).toEqual({ list: [SOME_DATASOURCE], selected: null }) }) it("selects a datasource", async () => { store.select(SOME_DATASOURCE._id) - - expect(get(store).select).toEqual(SOME_DATASOURCE._id) + + expect(get(store).select).toEqual(SOME_DATASOURCE._id) }) it("resets the queries store when new datasource is selected", async () => { - + await store.select(SOME_DATASOURCE._id) const queriesValue = get(queries) - expect(queriesValue.selected).toEqual(null) + expect(queriesValue.selected).toEqual(null) }) it("saves the datasource, updates the store and returns status message", async () => { - api.post.mockReturnValue({ status: 200, json: () => SAVE_DATASOURCE}) + API.createDatasource.mockReturnValue({ status: 200, json: () => SAVE_DATASOURCE}) await store.save({ name: 'CoolDB', @@ -56,13 +56,13 @@ describe("Datasources Store", () => { expect(get(store).list).toEqual(expect.arrayContaining([SAVE_DATASOURCE.datasource])) }) it("deletes a datasource, updates the store and returns status message", async () => { - api.get.mockReturnValue({ json: () => SOME_DATASOURCE}) + API.getDatasources.mockReturnValue({ json: () => SOME_DATASOURCE}) await store.fetch() - api.delete.mockReturnValue({status: 200, message: 'Datasource deleted.'}) + API.deleteDatasource.mockReturnValue({status: 200, message: 'Datasource deleted.'}) await store.delete(SOME_DATASOURCE[0]) - expect(get(store)).toEqual({ list: [], selected: null}) + expect(get(store)).toEqual({ list: [], selected: null}) }) }) \ No newline at end of file diff --git a/packages/builder/src/stores/backend/tests/permissions.spec.js b/packages/builder/src/stores/backend/tests/permissions.spec.js.disabled similarity index 69% rename from packages/builder/src/stores/backend/tests/permissions.spec.js rename to packages/builder/src/stores/backend/tests/permissions.spec.js.disabled index ab5aebb284..d3c19964f2 100644 --- a/packages/builder/src/stores/backend/tests/permissions.spec.js +++ b/packages/builder/src/stores/backend/tests/permissions.spec.js.disabled @@ -1,6 +1,6 @@ -import api from 'builderStore/api' +import { API } from "api" -jest.mock('builderStore/api'); +jest.mock("api") const PERMISSIONS_FOR_RESOURCE = { "write": "BASIC", @@ -13,13 +13,12 @@ describe("Permissions Store", () => { const store = createPermissionStore() it("fetches permissions for specific resource", async () => { - api.get.mockReturnValueOnce({ json: () => PERMISSIONS_FOR_RESOURCE}) + API.getPermissionForResource.mockReturnValueOnce({ json: () => PERMISSIONS_FOR_RESOURCE}) const resourceId = "ta_013657543b4043b89dbb17e9d3a4723a" const permissions = await store.forResource(resourceId) - expect(api.get).toBeCalledWith(`/api/permission/${resourceId}`) expect(permissions).toEqual(PERMISSIONS_FOR_RESOURCE) }) }) \ No newline at end of file diff --git a/packages/builder/src/stores/backend/tests/queries.spec.js b/packages/builder/src/stores/backend/tests/queries.spec.js.disabled similarity index 58% rename from packages/builder/src/stores/backend/tests/queries.spec.js rename to packages/builder/src/stores/backend/tests/queries.spec.js.disabled index b4c1805c66..20db8e4a95 100644 --- a/packages/builder/src/stores/backend/tests/queries.spec.js +++ b/packages/builder/src/stores/backend/tests/queries.spec.js.disabled @@ -1,9 +1,9 @@ -import { get } from 'svelte/store' -import api from 'builderStore/api' +import { get } from "svelte/store" +import { API } from "api" -jest.mock('builderStore/api'); +jest.mock("api") -import { SOME_QUERY, SAVE_QUERY_RESPONSE } from './fixtures/queries' +import { SOME_QUERY, SAVE_QUERY_RESPONSE } from "./fixtures/queries" import { createQueriesStore } from "../queries" @@ -11,36 +11,36 @@ describe("Queries Store", () => { let store = createQueriesStore() beforeEach(async () => { - api.get.mockReturnValue({ json: () => [SOME_QUERY]}) + API.getQueries.mockReturnValue({ json: () => [SOME_QUERY]}) await store.init() }) it("Initialises correctly", async () => { - api.get.mockReturnValue({ json: () => [SOME_QUERY]}) - + API.getQueries.mockReturnValue({ json: () => [SOME_QUERY]}) + await store.init() expect(get(store)).toEqual({ list: [SOME_QUERY], selected: null}) }) it("fetches all the queries", async () => { - api.get.mockReturnValue({ json: () => [SOME_QUERY]}) + API.getQueries.mockReturnValue({ json: () => [SOME_QUERY]}) await store.fetch() - expect(get(store)).toEqual({ list: [SOME_QUERY], selected: null}) + expect(get(store)).toEqual({ list: [SOME_QUERY], selected: null}) }) it("saves the query, updates the store and returns status message", async () => { - api.post.mockReturnValue({ json: () => SAVE_QUERY_RESPONSE}) + API.saveQuery.mockReturnValue({ json: () => SAVE_QUERY_RESPONSE}) await store.select(SOME_QUERY.datasourceId, SOME_QUERY) expect(get(store).list).toEqual(expect.arrayContaining([SOME_QUERY])) }) it("deletes a query, updates the store and returns status message", async () => { - - api.delete.mockReturnValue({status: 200, message: `Query deleted.`}) - + + API.deleteQuery.mockReturnValue({status: 200, message: `Query deleted.`}) + await store.delete(SOME_QUERY) - expect(get(store)).toEqual({ list: [], selected: null}) + expect(get(store)).toEqual({ list: [], selected: null}) }) }) \ No newline at end of file diff --git a/packages/builder/src/stores/backend/tests/roles.spec.js b/packages/builder/src/stores/backend/tests/roles.spec.js.disabled similarity index 56% rename from packages/builder/src/stores/backend/tests/roles.spec.js rename to packages/builder/src/stores/backend/tests/roles.spec.js.disabled index 13861f6359..b729c27ce6 100644 --- a/packages/builder/src/stores/backend/tests/roles.spec.js +++ b/packages/builder/src/stores/backend/tests/roles.spec.js.disabled @@ -1,10 +1,10 @@ -import { get } from 'svelte/store' -import api from 'builderStore/api' +import { get } from "svelte/store" +import { API } from "api" -jest.mock('builderStore/api'); +jest.mock("api") import { createRolesStore } from "../roles" -import { ROLES } from './fixtures/roles' +import { ROLES } from "./fixtures/roles" describe("Roles Store", () => { let store = createRolesStore() @@ -14,19 +14,18 @@ describe("Roles Store", () => { }) it("fetches roles from backend", async () => { - api.get.mockReturnValue({ json: () => ROLES}) + API.getRoles.mockReturnValue({ json: () => ROLES}) await store.fetch() - expect(api.get).toBeCalledWith("/api/roles") expect(get(store)).toEqual(ROLES) }) it("deletes a role", async () => { - api.get.mockReturnValueOnce({ json: () => ROLES}) + API.getRoles.mockReturnValueOnce({ json: () => ROLES}) await store.fetch() - - api.delete.mockReturnValue({status: 200, message: `Role deleted.`}) - + + API.deleteRole.mockReturnValue({status: 200, message: `Role deleted.`}) + const updatedRoles = [...ROLES.slice(1)] await store.delete(ROLES[0]) diff --git a/packages/builder/src/stores/backend/tests/tables.spec.js b/packages/builder/src/stores/backend/tests/tables.spec.js.disabled similarity index 56% rename from packages/builder/src/stores/backend/tests/tables.spec.js rename to packages/builder/src/stores/backend/tests/tables.spec.js.disabled index 06f8d3097b..26b4d90229 100644 --- a/packages/builder/src/stores/backend/tests/tables.spec.js +++ b/packages/builder/src/stores/backend/tests/tables.spec.js.disabled @@ -1,18 +1,16 @@ -import { get } from 'svelte/store' -import api from 'builderStore/api' +import { get } from "svelte/store" +import { API } from "api" -jest.mock('builderStore/api'); - -import { SOME_TABLES, SAVE_TABLES_RESPONSE, A_TABLE } from './fixtures/tables' +jest.mock("api") +import { SOME_TABLES, SAVE_TABLES_RESPONSE, A_TABLE } from "./fixtures/tables" import { createTablesStore } from "../tables" -import { views } from '../views' describe("Tables Store", () => { let store = createTablesStore() beforeEach(async () => { - api.get.mockReturnValue({ json: () => SOME_TABLES}) + API.getTables.mockReturnValue({ json: () => SOME_TABLES}) await store.init() }) @@ -21,46 +19,46 @@ describe("Tables Store", () => { }) it("fetches all the tables", async () => { - api.get.mockReturnValue({ json: () => SOME_TABLES}) + API.getTables.mockReturnValue({ json: () => SOME_TABLES}) await store.fetch() - expect(get(store)).toEqual({ list: SOME_TABLES, selected: {}, draft: {}}) + expect(get(store)).toEqual({ list: SOME_TABLES, selected: {}, draft: {}}) }) it("selects a table", async () => { const tableToSelect = SOME_TABLES[0] await store.select(tableToSelect) - - expect(get(store).selected).toEqual(tableToSelect) - expect(get(store).draft).toEqual(tableToSelect) + + expect(get(store).selected).toEqual(tableToSelect) + expect(get(store).draft).toEqual(tableToSelect) }) it("selecting without a param resets the selected property", async () => { await store.select() - - expect(get(store).draft).toEqual({}) + + expect(get(store).draft).toEqual({}) }) it("saving a table also selects it", async () => { - api.post.mockReturnValue({ status: 200, json: () => SAVE_TABLES_RESPONSE}) + API.post.mockReturnValue({ status: 200, json: () => SAVE_TABLES_RESPONSE}) await store.save(A_TABLE) - expect(get(store).selected).toEqual(SAVE_TABLES_RESPONSE) + expect(get(store).selected).toEqual(SAVE_TABLES_RESPONSE) }) it("saving the table returns a response", async () => { - api.post.mockReturnValue({ status: 200, json: () => SAVE_TABLES_RESPONSE}) + API.saveTable.mockReturnValue({ status: 200, json: () => SAVE_TABLES_RESPONSE}) const response = await store.save(A_TABLE) - expect(response).toEqual(SAVE_TABLES_RESPONSE) + expect(response).toEqual(SAVE_TABLES_RESPONSE) }) it("deleting a table removes it from the store", async () => { - api.delete.mockReturnValue({status: 200, message: `Table deleted.`}) - + API.deleteTable.mockReturnValue({status: 200, message: `Table deleted.`}) + await store.delete(A_TABLE) - expect(get(store).list).toEqual(expect.not.arrayContaining([A_TABLE])) + expect(get(store).list).toEqual(expect.not.arrayContaining([A_TABLE])) }) // TODO: Write tests for saving and deleting fields diff --git a/packages/builder/src/stores/portal/apps.js b/packages/builder/src/stores/portal/apps.js index 6323046eef..a83e35e941 100644 --- a/packages/builder/src/stores/portal/apps.js +++ b/packages/builder/src/stores/portal/apps.js @@ -8,14 +8,23 @@ const extractAppId = id => { } const getProdAppID = appId => { - if (!appId || !appId.startsWith("app_dev")) { + if (!appId) { return appId } - // split to take off the app_dev element, then join it together incase any other app_ exist - const split = appId.split("app_dev") - split.shift() - const rest = split.join("app_dev") - return `${"app"}${rest}` + let rest, + separator = "" + if (appId.startsWith("app_dev")) { + // split to take off the app_dev element, then join it together incase any other app_ exist + const split = appId.split("app_dev") + split.shift() + rest = split.join("app_dev") + } else if (!appId.startsWith("app")) { + rest = appId + separator = "_" + } else { + return appId + } + return `app${separator}${rest}` } export function createAppStore() { diff --git a/packages/builder/src/stores/portal/auth.js b/packages/builder/src/stores/portal/auth.js index 8ac19ab785..31b4533738 100644 --- a/packages/builder/src/stores/portal/auth.js +++ b/packages/builder/src/stores/portal/auth.js @@ -2,23 +2,20 @@ import { derived, writable, get } from "svelte/store" import { API } from "api" import { admin } from "stores/portal" import analytics from "analytics" -import { FEATURE_FLAGS } from "helpers/featureFlags" -import { Constants } from "@budibase/frontend-core" export function createAuthStore() { const auth = writable({ user: null, + accountPortalAccess: false, tenantId: "default", tenantSet: false, loaded: false, postLogout: false, - groupsEnabled: false, }) const store = derived(auth, $store => { let initials = null let isAdmin = false let isBuilder = false - let groupsEnabled = false if ($store.user) { const user = $store.user if (user.firstName) { @@ -33,12 +30,10 @@ export function createAuthStore() { } isAdmin = !!user.admin?.global isBuilder = !!user.builder?.global - groupsEnabled = - user?.license.features.includes(Constants.Features.USER_GROUPS) && - user?.featureFlags.includes(FEATURE_FLAGS.USER_GROUPS) } return { user: $store.user, + accountPortalAccess: $store.accountPortalAccess, tenantId: $store.tenantId, tenantSet: $store.tenantSet, loaded: $store.loaded, @@ -46,7 +41,6 @@ export function createAuthStore() { initials, isAdmin, isBuilder, - groupsEnabled, } }) @@ -54,6 +48,7 @@ export function createAuthStore() { auth.update(store => { store.loaded = true store.user = user + store.accountPortalAccess = user?.accountPortalAccess if (user) { store.tenantId = user.tenantId || "default" store.tenantSet = true diff --git a/packages/builder/src/stores/portal/groups.js b/packages/builder/src/stores/portal/groups.js index ca814ac057..eda3961e2b 100644 --- a/packages/builder/src/stores/portal/groups.js +++ b/packages/builder/src/stores/portal/groups.js @@ -1,36 +1,45 @@ import { writable, get } from "svelte/store" import { API } from "api" -import { auth } from "stores/portal" -import { Constants } from "@budibase/frontend-core" +import { licensing } from "stores/portal" export function createGroupsStore() { const store = writable([]) + const updateStore = group => { + store.update(state => { + const currentIdx = state.findIndex(gr => gr._id === group._id) + if (currentIdx >= 0) { + state.splice(currentIdx, 1, group) + } else { + state.push(group) + } + return state + }) + } + + const getGroup = async groupId => { + const group = await API.getGroup(groupId) + updateStore(group) + } + const actions = { init: async () => { - // only init if these is a groups license, just to be sure but the feature will be blocked + // only init if there is a groups license, just to be sure but the feature will be blocked // on the backend anyway - if ( - get(auth).user.license.features.includes(Constants.Features.USER_GROUPS) - ) { - const users = await API.getGroups() - store.set(users) + if (get(licensing).groupsEnabled) { + const groups = await API.getGroups() + store.set(groups) } }, + get: getGroup, + save: async group => { const response = await API.saveGroup(group) group._id = response._id group._rev = response._rev - store.update(state => { - const currentIdx = state.findIndex(gr => gr._id === response._id) - if (currentIdx >= 0) { - state.splice(currentIdx, 1, group) - } else { - state.push(group) - } - return state - }) + updateStore(group) + return group }, delete: async group => { @@ -43,6 +52,34 @@ export function createGroupsStore() { return state }) }, + + addUser: async (groupId, userId) => { + await API.addUsersToGroup(groupId, userId) + // refresh the group enrichment + await getGroup(groupId) + }, + + removeUser: async (groupId, userId) => { + await API.removeUsersFromGroup(groupId, userId) + // refresh the group enrichment + await getGroup(groupId) + }, + + addApp: async (groupId, appId, roleId) => { + await API.addAppsToGroup(groupId, [{ appId, roleId }]) + // refresh the group roles + await getGroup(groupId) + }, + + removeApp: async (groupId, appId) => { + await API.removeAppsFromGroup(groupId, [{ appId }]) + // refresh the group roles + await getGroup(groupId) + }, + + getGroupAppIds: group => { + return Object.keys(group?.roles || {}) + }, } return { diff --git a/packages/builder/src/stores/portal/licensing.js b/packages/builder/src/stores/portal/licensing.js index 653dab52ed..179dac9689 100644 --- a/packages/builder/src/stores/portal/licensing.js +++ b/packages/builder/src/stores/portal/licensing.js @@ -1,15 +1,71 @@ -import { writable } from "svelte/store" +import { writable, get } from "svelte/store" import { API } from "api" +import { auth, admin } from "stores/portal" +import { Constants } from "@budibase/frontend-core" +import { StripeStatus } from "components/portal/licensing/constants" +import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags" export const createLicensingStore = () => { const DEFAULT = { - plans: {}, + // navigation + goToUpgradePage: () => {}, + // the top level license + license: undefined, + isFreePlan: true, + // features + groupsEnabled: false, + // the currently used quotas from the db + quotaUsage: undefined, + // derived quota metrics for percentages used + usageMetrics: undefined, + // quota reset + quotaResetDaysRemaining: undefined, + quotaResetDate: undefined, + // failed payments + accountPastDue: undefined, + pastDueEndDate: undefined, + pastDueDaysRemaining: undefined, + accountDowngraded: undefined, } + const oneDayInMilliseconds = 86400000 const store = writable(DEFAULT) const actions = { - getQuotaUsage: async () => { + init: async () => { + actions.setNavigation() + actions.setLicense() + await actions.setQuotaUsage() + actions.setUsageMetrics() + }, + setNavigation: () => { + const upgradeUrl = `${get(admin).accountPortalUrl}/portal/upgrade` + const goToUpgradePage = () => { + window.location.href = upgradeUrl + } + store.update(state => { + return { + ...state, + goToUpgradePage, + } + }) + }, + setLicense: () => { + const license = get(auth).user.license + const isFreePlan = license?.plan.type === Constants.PlanType.FREE + const groupsEnabled = license.features.includes( + Constants.Features.USER_GROUPS + ) + store.update(state => { + return { + ...state, + license, + isFreePlan, + groupsEnabled, + } + }) + }, + setQuotaUsage: async () => { const quotaUsage = await API.getQuotaUsage() store.update(state => { return { @@ -18,6 +74,79 @@ export const createLicensingStore = () => { } }) }, + setUsageMetrics: () => { + if (isEnabled(TENANT_FEATURE_FLAGS.LICENSING)) { + const quota = get(store).quotaUsage + const license = get(auth).user.license + const now = new Date() + + const getMetrics = (keys, license, quota) => { + if (!license || !quota || !keys) { + return {} + } + return keys.reduce((acc, key) => { + const quotaLimit = license[key].value + const quotaUsed = (quota[key] / quotaLimit) * 100 + acc[key] = quotaLimit > -1 ? Math.floor(quotaUsed) : -1 + return acc + }, {}) + } + const monthlyMetrics = getMetrics( + ["dayPasses", "queries", "automations"], + license.quotas.usage.monthly, + quota.monthly.current + ) + const staticMetrics = getMetrics( + ["apps", "rows"], + license.quotas.usage.static, + quota.usageQuota + ) + + const getDaysBetween = (dateStart, dateEnd) => { + return dateEnd > dateStart + ? Math.round( + (dateEnd.getTime() - dateStart.getTime()) / oneDayInMilliseconds + ) + : 0 + } + + const quotaResetDate = new Date(quota.quotaReset) + const quotaResetDaysRemaining = getDaysBetween(now, quotaResetDate) + + const accountDowngraded = + license?.billing?.subscription?.downgradeAt && + license?.billing?.subscription?.downgradeAt <= now.getTime() && + license?.billing?.subscription?.status === StripeStatus.PAST_DUE && + license?.plan.type === Constants.PlanType.FREE + + const pastDueAtMilliseconds = license?.billing?.subscription?.pastDueAt + const downgradeAtMilliseconds = + license?.billing?.subscription?.downgradeAt + let pastDueDaysRemaining + let pastDueEndDate + + if (pastDueAtMilliseconds && downgradeAtMilliseconds) { + pastDueEndDate = new Date(downgradeAtMilliseconds) + pastDueDaysRemaining = getDaysBetween( + new Date(pastDueAtMilliseconds), + pastDueEndDate + ) + } + + store.update(state => { + return { + ...state, + usageMetrics: { ...monthlyMetrics, ...staticMetrics }, + quotaResetDaysRemaining, + quotaResetDate, + accountDowngraded, + accountPastDue: pastDueAtMilliseconds != null, + pastDueEndDate, + pastDueDaysRemaining, + } + }) + } + }, } return { diff --git a/packages/builder/src/stores/portal/plugins.js b/packages/builder/src/stores/portal/plugins.js index 8997e8f49d..e259f9aa6d 100644 --- a/packages/builder/src/stores/portal/plugins.js +++ b/packages/builder/src/stores/portal/plugins.js @@ -34,8 +34,7 @@ export function createPluginsStore() { } let res = await API.createPlugin(pluginData) - - let newPlugin = res.plugins[0] + let newPlugin = res.plugin update(state => { const currentIdx = state.findIndex(plugin => plugin._id === newPlugin._id) if (currentIdx >= 0) { diff --git a/packages/cli/package.json b/packages/cli/package.json index 42499a3c00..0d99b165f6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/cli", - "version": "1.3.15-alpha.3", + "version": "2.0.24-alpha.0", "description": "Budibase CLI, for developers, self hosting and migrations.", "main": "src/index.js", "bin": { @@ -26,9 +26,9 @@ "outputPath": "build" }, "dependencies": { - "@budibase/backend-core": "1.3.15-alpha.3", - "@budibase/string-templates": "1.3.15-alpha.3", - "@budibase/types": "1.3.15-alpha.3", + "@budibase/backend-core": "2.0.24-alpha.0", + "@budibase/string-templates": "2.0.24-alpha.0", + "@budibase/types": "2.0.24-alpha.0", "axios": "0.21.2", "chalk": "4.1.0", "cli-progress": "3.11.2", @@ -36,6 +36,7 @@ "docker-compose": "0.23.6", "dotenv": "16.0.1", "download": "8.0.0", + "find-free-port": "^2.0.0", "inquirer": "8.0.0", "joi": "17.6.0", "lookpath": "1.1.0", @@ -45,7 +46,8 @@ "pouchdb": "7.3.0", "pouchdb-replication-stream": "1.2.9", "randomstring": "1.1.5", - "tar": "6.1.11" + "tar": "6.1.11", + "yaml": "^2.1.1" }, "devDependencies": { "copyfiles": "^2.4.1", diff --git a/packages/cli/src/constants.js b/packages/cli/src/constants.js index 8fe6c8602d..6b0265ffd2 100644 --- a/packages/cli/src/constants.js +++ b/packages/cli/src/constants.js @@ -1,3 +1,5 @@ +const { Event } = require("@budibase/types") + exports.CommandWords = { BACKUPS: "backups", HOSTING: "hosting", @@ -15,6 +17,9 @@ exports.AnalyticsEvents = { OptOut: "analytics:opt:out", OptIn: "analytics:opt:in", SelfHostInit: "hosting:init", + PluginInit: Event.PLUGIN_INIT, } exports.POSTHOG_TOKEN = "phc_yGOn4i7jWKaCTapdGR6lfA4AvmuEQ2ijn5zAVSFYPlS" + +exports.GENERATED_USER_EMAIL = "admin@admin.com" diff --git a/packages/cli/src/environment.js b/packages/cli/src/environment.js index a42eceb07e..c8c8fe87e9 100644 --- a/packages/cli/src/environment.js +++ b/packages/cli/src/environment.js @@ -1 +1,2 @@ process.env.NO_JS = "1" +process.env.JS_BCRYPT = "1" diff --git a/packages/cli/src/events.js b/packages/cli/src/events.js new file mode 100644 index 0000000000..63d4fca1ea --- /dev/null +++ b/packages/cli/src/events.js @@ -0,0 +1,11 @@ +const AnalyticsClient = require("./analytics/Client") + +const client = new AnalyticsClient() + +exports.captureEvent = (event, properties) => { + client.capture({ + distinctId: "cli", + event, + properties, + }) +} diff --git a/packages/cli/src/exec.js b/packages/cli/src/exec.js index 72fd8e00eb..4df486aed6 100644 --- a/packages/cli/src/exec.js +++ b/packages/cli/src/exec.js @@ -22,6 +22,6 @@ exports.runPkgCommand = async (command, dir = "./") => { throw new Error("Must have yarn or npm installed to run build.") } const npmCmd = command === "install" ? `npm ${command}` : `npm run ${command}` - const cmd = yarn ? `yarn ${command}` : npmCmd + const cmd = yarn ? `yarn ${command} --ignore-engines` : npmCmd await exports.exec(cmd, dir) } diff --git a/packages/cli/src/hosting/genUser.js b/packages/cli/src/hosting/genUser.js new file mode 100644 index 0000000000..7ee11179af --- /dev/null +++ b/packages/cli/src/hosting/genUser.js @@ -0,0 +1,22 @@ +const { success } = require("../utils") +const { updateDockerComposeService } = require("./utils") +const randomString = require("randomstring") +const { GENERATED_USER_EMAIL } = require("../constants") + +exports.generateUser = async (password, silent) => { + const email = GENERATED_USER_EMAIL + if (!password) { + password = randomString.generate({ length: 6 }) + } + updateDockerComposeService(service => { + service.environment["BB_ADMIN_USER_EMAIL"] = email + service.environment["BB_ADMIN_USER_PASSWORD"] = password + }) + if (!silent) { + console.log( + success( + `User admin credentials configured, access with email: ${email} - password: ${password}` + ) + ) + } +} diff --git a/packages/cli/src/hosting/index.js b/packages/cli/src/hosting/index.js index 2ebcee43a5..d8133c4959 100644 --- a/packages/cli/src/hosting/index.js +++ b/packages/cli/src/hosting/index.js @@ -1,170 +1,18 @@ const Command = require("../structures/Command") -const { CommandWords, InitTypes, AnalyticsEvents } = require("../constants") -const { lookpath } = require("lookpath") -const { - downloadFile, - logErrorToFile, - success, - info, - parseEnv, -} = require("../utils") -const { confirmation } = require("../questions") -const fs = require("fs") -const compose = require("docker-compose") -const makeEnv = require("./makeEnv") -const axios = require("axios") -const AnalyticsClient = require("../analytics/Client") - -const BUDIBASE_SERVICES = ["app-service", "worker-service", "proxy-service"] -const ERROR_FILE = "docker-error.log" -const FILE_URLS = [ - "https://raw.githubusercontent.com/Budibase/budibase/master/hosting/docker-compose.yaml", -] -const DO_USER_DATA_URL = "http://169.254.169.254/metadata/v1/user-data" - -const client = new AnalyticsClient() - -async function downloadFiles() { - const promises = [] - for (let url of FILE_URLS) { - const fileName = url.split("/").slice(-1)[0] - promises.push(downloadFile(url, `./${fileName}`)) - } - await Promise.all(promises) -} - -async function checkDockerConfigured() { - const error = - "docker/docker-compose has not been installed, please follow instructions at: https://docs.budibase.com/docs/docker-compose" - const docker = await lookpath("docker") - const compose = await lookpath("docker-compose") - if (!docker || !compose) { - throw error - } -} - -function checkInitComplete() { - if (!fs.existsSync(makeEnv.filePath)) { - throw "Please run the hosting --init command before any other hosting command." - } -} - -async function handleError(func) { - try { - await func() - } catch (err) { - if (err && err.err) { - logErrorToFile(ERROR_FILE, err.err) - } - throw `Failed to start - logs written to file: ${ERROR_FILE}` - } -} - -async function init(type) { - const isQuick = type === InitTypes.QUICK || type === InitTypes.DIGITAL_OCEAN - await checkDockerConfigured() - if (!isQuick) { - const shouldContinue = await confirmation( - "This will create multiple files in current directory, should continue?" - ) - if (!shouldContinue) { - console.log("Stopping.") - return - } - } - client.capture({ - distinctId: "cli", - event: AnalyticsEvents.SelfHostInit, - properties: { - type, - }, - }) - await downloadFiles() - const config = isQuick ? makeEnv.QUICK_CONFIG : {} - if (type === InitTypes.DIGITAL_OCEAN) { - try { - const output = await axios.get(DO_USER_DATA_URL) - const response = parseEnv(output.data) - for (let [key, value] of Object.entries(makeEnv.ConfigMap)) { - if (response[key]) { - config[value] = response[key] - } - } - } catch (err) { - // don't need to handle error, just don't do anything - } - } - await makeEnv.make(config) -} - -async function start() { - await checkDockerConfigured() - checkInitComplete() - console.log( - info( - "Starting services, this may take a moment - first time this may take a few minutes to download images." - ) - ) - const port = makeEnv.get("MAIN_PORT") - await handleError(async () => { - // need to log as it makes it more clear - await compose.upAll({ cwd: "./", log: true }) - }) - console.log( - success( - `Services started, please go to http://localhost:${port} for next steps.` - ) - ) -} - -async function status() { - await checkDockerConfigured() - checkInitComplete() - console.log(info("Budibase status")) - await handleError(async () => { - const response = await compose.ps() - console.log(response.out) - }) -} - -async function stop() { - await checkDockerConfigured() - checkInitComplete() - console.log(info("Stopping services, this may take a moment.")) - await handleError(async () => { - await compose.stop() - }) - console.log(success("Services have been stopped successfully.")) -} - -async function update() { - await checkDockerConfigured() - checkInitComplete() - if (await confirmation("Do you wish to update you docker-compose.yaml?")) { - await downloadFiles() - } - await handleError(async () => { - const status = await compose.ps() - const parts = status.out.split("\n") - const isUp = parts[2] && parts[2].indexOf("Up") !== -1 - if (isUp) { - console.log(info("Stopping services, this may take a moment.")) - await compose.stop() - } - console.log(info("Beginning update, this may take a few minutes.")) - await compose.pullMany(BUDIBASE_SERVICES, { log: true }) - if (isUp) { - console.log(success("Update complete, restarting services...")) - await start() - } - }) -} +const { CommandWords } = require("../constants") +const { init } = require("./init") +const { start } = require("./start") +const { stop } = require("./stop") +const { status } = require("./status") +const { update } = require("./update") +const { generateUser } = require("./genUser") +const { watchPlugins } = require("./watch") const command = new Command(`${CommandWords.HOSTING}`) .addHelp("Controls self hosting on the Budibase platform.") .addSubOption( "--init [type]", - "Configure a self hosted platform in current directory, type can be unspecified or 'quick'.", + "Configure a self hosted platform in current directory, type can be unspecified, 'quick' or 'single'.", init ) .addSubOption( @@ -187,5 +35,16 @@ const command = new Command(`${CommandWords.HOSTING}`) "Update the Budibase images to the latest version.", update ) + .addSubOption( + "--watch-plugin-dir [directory]", + "Add plugin directory watching to a Budibase install.", + watchPlugins + ) + .addSubOption( + "--gen-user", + "Create an admin user automatically as part of first start.", + generateUser + ) + .addSubOption("--single", "Specify this with init to use the single image.") exports.command = command diff --git a/packages/cli/src/hosting/init.js b/packages/cli/src/hosting/init.js new file mode 100644 index 0000000000..88063f1732 --- /dev/null +++ b/packages/cli/src/hosting/init.js @@ -0,0 +1,75 @@ +const { InitTypes, AnalyticsEvents } = require("../constants") +const { confirmation } = require("../questions") +const { captureEvent } = require("../events") +const makeFiles = require("./makeFiles") +const axios = require("axios") +const { parseEnv } = require("../utils") +const { checkDockerConfigured, downloadFiles } = require("./utils") +const { watchPlugins } = require("./watch") +const { generateUser } = require("./genUser") + +const DO_USER_DATA_URL = "http://169.254.169.254/metadata/v1/user-data" + +async function getInitConfig(type, isQuick, port) { + const config = isQuick ? makeFiles.QUICK_CONFIG : {} + if (type === InitTypes.DIGITAL_OCEAN) { + try { + const output = await axios.get(DO_USER_DATA_URL) + const response = parseEnv(output.data) + for (let [key, value] of Object.entries(makeFiles.ConfigMap)) { + if (response[key]) { + config[value] = response[key] + } + } + } catch (err) { + // don't need to handle error, just don't do anything + } + } + // override port + if (port) { + config[makeFiles.ConfigMap.MAIN_PORT] = port + } + return config +} + +exports.init = async opts => { + let type, isSingle, watchDir, genUser, port, silent + if (typeof opts === "string") { + type = opts + } else { + type = opts["init"] + isSingle = opts["single"] + watchDir = opts["watchPluginDir"] + genUser = opts["genUser"] + port = opts["port"] + silent = opts["silent"] + } + const isQuick = type === InitTypes.QUICK || type === InitTypes.DIGITAL_OCEAN + await checkDockerConfigured() + if (!isQuick) { + const shouldContinue = await confirmation( + "This will create multiple files in current directory, should continue?" + ) + if (!shouldContinue) { + console.log("Stopping.") + return + } + } + captureEvent(AnalyticsEvents.SelfHostInit, { + type, + }) + const config = await getInitConfig(type, isQuick, port) + if (!isSingle) { + await downloadFiles() + await makeFiles.makeEnv(config, silent) + } else { + await makeFiles.makeSingleCompose(config, silent) + } + if (watchDir) { + await watchPlugins(watchDir, silent) + } + if (genUser) { + const inputPassword = typeof genUser === "string" ? genUser : null + await generateUser(inputPassword, silent) + } +} diff --git a/packages/cli/src/hosting/makeEnv.js b/packages/cli/src/hosting/makeEnv.js deleted file mode 100644 index d1d23999f8..0000000000 --- a/packages/cli/src/hosting/makeEnv.js +++ /dev/null @@ -1,66 +0,0 @@ -const { number } = require("../questions") -const { success } = require("../utils") -const fs = require("fs") -const path = require("path") -const randomString = require("randomstring") - -const FILE_PATH = path.resolve("./.env") - -function getContents(port) { - return ` -# Use the main port in the builder for your self hosting URL, e.g. localhost:10000 -MAIN_PORT=${port} - -# This section contains all secrets pertaining to the system -JWT_SECRET=${randomString.generate()} -MINIO_ACCESS_KEY=${randomString.generate()} -MINIO_SECRET_KEY=${randomString.generate()} -COUCH_DB_PASSWORD=${randomString.generate()} -COUCH_DB_USER=${randomString.generate()} -REDIS_PASSWORD=${randomString.generate()} -INTERNAL_API_KEY=${randomString.generate()} - -# This section contains variables that do not need to be altered under normal circumstances -APP_PORT=4002 -WORKER_PORT=4003 -MINIO_PORT=4004 -COUCH_DB_PORT=4005 -REDIS_PORT=6379 -WATCHTOWER_PORT=6161 -BUDIBASE_ENVIRONMENT=PRODUCTION` -} - -module.exports.filePath = FILE_PATH -module.exports.ConfigMap = { - MAIN_PORT: "port", -} -module.exports.QUICK_CONFIG = { - key: "budibase", - port: 10000, -} - -module.exports.make = async (inputs = {}) => { - const hostingPort = - inputs.port || - (await number( - "Please enter the port on which you want your installation to run: ", - 10000 - )) - const fileContents = getContents(hostingPort) - fs.writeFileSync(FILE_PATH, fileContents) - console.log( - success( - "Configuration has been written successfully - please check .env file for more details." - ) - ) -} - -module.exports.get = property => { - const props = fs.readFileSync(FILE_PATH, "utf8").split(property) - if (props[0].charAt(0) === "=") { - property = props[0] - } else { - property = props[1] - } - return property.split("=")[1].split("\n")[0] -} diff --git a/packages/cli/src/hosting/makeFiles.js b/packages/cli/src/hosting/makeFiles.js new file mode 100644 index 0000000000..abb0736858 --- /dev/null +++ b/packages/cli/src/hosting/makeFiles.js @@ -0,0 +1,137 @@ +const { number } = require("../questions") +const { success, stringifyToDotEnv } = require("../utils") +const fs = require("fs") +const path = require("path") +const randomString = require("randomstring") +const yaml = require("yaml") +const { getAppService } = require("./utils") + +const SINGLE_IMAGE = "budibase/budibase:latest" +const VOL_NAME = "budibase_data" +const COMPOSE_PATH = path.resolve("./docker-compose.yaml") +const ENV_PATH = path.resolve("./.env") + +function getSecrets(opts = { single: false }) { + const secrets = [ + "JWT_SECRET", + "MINIO_ACCESS_KEY", + "MINIO_SECRET_KEY", + "REDIS_PASSWORD", + "INTERNAL_API_KEY", + ] + const obj = {} + secrets.forEach(secret => (obj[secret] = randomString.generate())) + // setup couch creds separately + if (opts && opts.single) { + obj["COUCHDB_USER"] = "admin" + obj["COUCHDB_PASSWORD"] = randomString.generate() + } else { + obj["COUCH_DB_USER"] = "admin" + obj["COUCH_DB_PASSWORD"] = randomString.generate() + } + return obj +} + +function getSingleCompose(port) { + const singleComposeObj = { + version: "3", + services: { + budibase: { + restart: "unless-stopped", + image: SINGLE_IMAGE, + ports: [`${port}:80`], + environment: getSecrets({ single: true }), + volumes: [`${VOL_NAME}:/data`], + }, + }, + volumes: { + [VOL_NAME]: { + driver: "local", + }, + }, + } + return yaml.stringify(singleComposeObj) +} + +function getEnv(port) { + const partOne = stringifyToDotEnv({ + MAIN_PORT: port, + }) + const partTwo = stringifyToDotEnv(getSecrets()) + const partThree = stringifyToDotEnv({ + APP_PORT: 4002, + WORKER_PORT: 4003, + MINIO_PORT: 4004, + COUCH_DB_PORT: 4005, + REDIS_PORT: 6379, + WATCHTOWER_PORT: 6161, + BUDIBASE_ENVIRONMENT: "PRODUCTION", + }) + return [ + "# Use the main port in the builder for your self hosting URL, e.g. localhost:10000", + partOne, + "# This section contains all secrets pertaining to the system", + partTwo, + "# This section contains variables that do not need to be altered under normal circumstances", + partThree, + ].join("\n") +} + +exports.ENV_PATH = ENV_PATH +exports.COMPOSE_PATH = COMPOSE_PATH + +module.exports.ConfigMap = { + MAIN_PORT: "port", +} + +module.exports.QUICK_CONFIG = { + key: "budibase", + port: 10000, +} + +async function make(path, contentsFn, inputs = {}, silent) { + const port = + inputs.port || + (await number( + "Please enter the port on which you want your installation to run: ", + 10000 + )) + const fileContents = contentsFn(port) + fs.writeFileSync(path, fileContents) + if (!silent) { + console.log( + success( + `Configuration has been written successfully - please check ${path} for more details.` + ) + ) + } +} + +module.exports.makeEnv = async (inputs = {}, silent) => { + return make(ENV_PATH, getEnv, inputs, silent) +} + +module.exports.makeSingleCompose = async (inputs = {}, silent) => { + return make(COMPOSE_PATH, getSingleCompose, inputs, silent) +} + +module.exports.getEnvProperty = property => { + const props = fs.readFileSync(ENV_PATH, "utf8").split(property) + if (props[0].charAt(0) === "=") { + property = props[0] + } else { + property = props[1] + } + return property.split("=")[1].split("\n")[0] +} + +module.exports.getComposeProperty = property => { + const { service } = getAppService(COMPOSE_PATH) + if (property === "port" && Array.isArray(service.ports)) { + const port = service.ports[0] + return port.split(":")[0] + } else if (service.environment) { + return service.environment[property] + } + return null +} diff --git a/packages/cli/src/hosting/start.js b/packages/cli/src/hosting/start.js new file mode 100644 index 0000000000..33b5eb92ce --- /dev/null +++ b/packages/cli/src/hosting/start.js @@ -0,0 +1,34 @@ +const { + checkDockerConfigured, + checkInitComplete, + handleError, +} = require("./utils") +const { info, success } = require("../utils") +const makeFiles = require("./makeFiles") +const compose = require("docker-compose") +const fs = require("fs") + +exports.start = async () => { + await checkDockerConfigured() + checkInitComplete() + console.log( + info( + "Starting services, this may take a moment - first time this may take a few minutes to download images." + ) + ) + let port + if (fs.existsSync(makeFiles.ENV_PATH)) { + port = makeFiles.getEnvProperty("MAIN_PORT") + } else { + port = makeFiles.getComposeProperty("port") + } + await handleError(async () => { + // need to log as it makes it more clear + await compose.upAll({ cwd: "./", log: true }) + }) + console.log( + success( + `Services started, please go to http://localhost:${port} for next steps.` + ) + ) +} diff --git a/packages/cli/src/hosting/status.js b/packages/cli/src/hosting/status.js new file mode 100644 index 0000000000..2b98392133 --- /dev/null +++ b/packages/cli/src/hosting/status.js @@ -0,0 +1,17 @@ +const { + checkDockerConfigured, + checkInitComplete, + handleError, +} = require("./utils") +const { info } = require("../utils") +const compose = require("docker-compose") + +exports.status = async () => { + await checkDockerConfigured() + checkInitComplete() + console.log(info("Budibase status")) + await handleError(async () => { + const response = await compose.ps() + console.log(response.out) + }) +} diff --git a/packages/cli/src/hosting/stop.js b/packages/cli/src/hosting/stop.js new file mode 100644 index 0000000000..5f38c93484 --- /dev/null +++ b/packages/cli/src/hosting/stop.js @@ -0,0 +1,17 @@ +const { + checkDockerConfigured, + checkInitComplete, + handleError, +} = require("./utils") +const { info, success } = require("../utils") +const compose = require("docker-compose") + +exports.stop = async () => { + await checkDockerConfigured() + checkInitComplete() + console.log(info("Stopping services, this may take a moment.")) + await handleError(async () => { + await compose.stop() + }) + console.log(success("Services have been stopped successfully.")) +} diff --git a/packages/cli/src/hosting/update.js b/packages/cli/src/hosting/update.js new file mode 100644 index 0000000000..7d3367ce57 --- /dev/null +++ b/packages/cli/src/hosting/update.js @@ -0,0 +1,49 @@ +const { + checkDockerConfigured, + checkInitComplete, + downloadFiles, + handleError, + getServices, +} = require("./utils") +const { confirmation } = require("../questions") +const compose = require("docker-compose") +const { COMPOSE_PATH } = require("./makeFiles") +const { info, success } = require("../utils") +const { start } = require("./start") + +const BB_COMPOSE_SERVICES = ["app-service", "worker-service", "proxy-service"] +const BB_SINGLE_SERVICE = ["budibase"] + +exports.update = async () => { + const { services } = getServices(COMPOSE_PATH) + const isSingle = Object.keys(services).length === 1 + await checkDockerConfigured() + checkInitComplete() + if ( + !isSingle && + (await confirmation("Do you wish to update you docker-compose.yaml?")) + ) { + await downloadFiles() + } + await handleError(async () => { + const status = await compose.ps() + const parts = status.out.split("\n") + const isUp = parts[2] && parts[2].indexOf("Up") !== -1 + if (isUp) { + console.log(info("Stopping services, this may take a moment.")) + await compose.stop() + } + console.log(info("Beginning update, this may take a few minutes.")) + let services + if (isSingle) { + services = BB_SINGLE_SERVICE + } else { + services = BB_COMPOSE_SERVICES + } + await compose.pullMany(services, { log: true }) + if (isUp) { + console.log(success("Update complete, restarting services...")) + await start() + } + }) +} diff --git a/packages/cli/src/hosting/utils.js b/packages/cli/src/hosting/utils.js new file mode 100644 index 0000000000..952974ee83 --- /dev/null +++ b/packages/cli/src/hosting/utils.js @@ -0,0 +1,87 @@ +const { lookpath } = require("lookpath") +const fs = require("fs") +const makeFiles = require("./makeFiles") +const { logErrorToFile, downloadFile, error } = require("../utils") +const yaml = require("yaml") + +const ERROR_FILE = "docker-error.log" +const FILE_URLS = [ + "https://raw.githubusercontent.com/Budibase/budibase/master/hosting/docker-compose.yaml", +] + +exports.downloadFiles = async () => { + const promises = [] + for (let url of FILE_URLS) { + const fileName = url.split("/").slice(-1)[0] + promises.push(downloadFile(url, `./${fileName}`)) + } + await Promise.all(promises) +} + +exports.checkDockerConfigured = async () => { + const error = + "docker/docker-compose has not been installed, please follow instructions at: https://docs.budibase.com/docs/docker-compose" + const docker = await lookpath("docker") + const compose = await lookpath("docker-compose") + if (!docker || !compose) { + throw error + } +} + +exports.checkInitComplete = () => { + if ( + !fs.existsSync(makeFiles.ENV_PATH) && + !fs.existsSync(makeFiles.COMPOSE_PATH) + ) { + throw "Please run the hosting --init command before any other hosting command." + } +} + +exports.handleError = async func => { + try { + await func() + } catch (err) { + if (err && err.err) { + logErrorToFile(ERROR_FILE, err.err) + } + throw `Failed to start - logs written to file: ${ERROR_FILE}` + } +} + +exports.getServices = path => { + const dockerYaml = fs.readFileSync(path, "utf8") + const parsedYaml = yaml.parse(dockerYaml) + return { yaml: parsedYaml, services: parsedYaml.services } +} + +exports.getAppService = path => { + const { yaml, services } = exports.getServices(path), + serviceList = Object.keys(services) + let service + if (services["app-service"]) { + service = services["app-service"] + } else if (serviceList.length === 1) { + service = services[serviceList[0]] + } + return { yaml, service } +} + +exports.updateDockerComposeService = updateFn => { + const opts = ["docker-compose.yaml", "docker-compose.yml"] + const dockerFilePath = opts.find(name => fs.existsSync(name)) + if (!dockerFilePath) { + console.log(error("Unable to locate docker-compose YAML.")) + return + } + const { yaml: parsedYaml, service } = exports.getAppService(dockerFilePath) + if (!service) { + console.log( + error( + "Unable to locate service within compose file, is it a valid Budibase configuration?" + ) + ) + return + } + updateFn(service) + fs.writeFileSync(dockerFilePath, yaml.stringify(parsedYaml)) +} diff --git a/packages/cli/src/hosting/watch.js b/packages/cli/src/hosting/watch.js new file mode 100644 index 0000000000..56f1c8e201 --- /dev/null +++ b/packages/cli/src/hosting/watch.js @@ -0,0 +1,36 @@ +const { resolve } = require("path") +const fs = require("fs") +const { error, success } = require("../utils") +const { updateDockerComposeService } = require("./utils") + +exports.watchPlugins = async (pluginPath, silent) => { + const PLUGIN_PATH = "/plugins" + // get absolute path + pluginPath = resolve(pluginPath) + if (!fs.existsSync(pluginPath)) { + console.log( + error( + `The directory "${pluginPath}" does not exist, please create and then try again.` + ) + ) + return + } + updateDockerComposeService(service => { + // set environment variable + service.environment["PLUGINS_DIR"] = PLUGIN_PATH + // add volumes to parsed yaml + if (!service.volumes) { + service.volumes = [] + } + const found = service.volumes.find(vol => vol.includes(PLUGIN_PATH)) + if (found) { + service.volumes.splice(service.volumes.indexOf(found), 1) + } + service.volumes.push(`${pluginPath}:${PLUGIN_PATH}`) + }) + if (!silent) { + console.log( + success(`Docker compose configured to watch directory: ${pluginPath}`) + ) + } +} diff --git a/packages/cli/src/plugins/index.js b/packages/cli/src/plugins/index.js index 714187df56..873be10612 100644 --- a/packages/cli/src/plugins/index.js +++ b/packages/cli/src/plugins/index.js @@ -1,5 +1,5 @@ const Command = require("../structures/Command") -const { CommandWords } = require("../constants") +const { CommandWords, AnalyticsEvents, InitTypes } = require("../constants") const { getSkeleton, fleshOutSkeleton } = require("./skeleton") const questions = require("../questions") const fs = require("fs") @@ -7,7 +7,12 @@ const { PLUGIN_TYPE_ARR } = require("@budibase/types") const { validate } = require("@budibase/backend-core/plugins") const { runPkgCommand } = require("../exec") const { join } = require("path") -const { success, error, info } = require("../utils") +const { success, error, info, moveDirectory } = require("../utils") +const { captureEvent } = require("../events") +const fp = require("find-free-port") +const { GENERATED_USER_EMAIL } = require("../constants") +const { init: hostingInit } = require("../hosting/init") +const { start: hostingStart } = require("../hosting/start") function checkInPlugin() { if (!fs.existsSync("package.json")) { @@ -22,6 +27,24 @@ function checkInPlugin() { } } +async function askAboutTopLevel(name) { + const files = fs.readdirSync(process.cwd()) + // we are in an empty git repo, don't ask + if (files.find(file => file === ".git")) { + return false + } else { + console.log( + info(`By default the plugin will be created in the directory "${name}"`) + ) + console.log( + info( + "if you are already in an empty directory, such as a new Git repo, you can disable this functionality." + ) + ) + return questions.confirmation("Create top level directory?") + } +} + async function init(opts) { const type = opts["init"] || opts if (!type || !PLUGIN_TYPE_ARR.includes(type)) { @@ -40,18 +63,31 @@ async function init(opts) { ) return } - const desc = await questions.string( + const description = await questions.string( "Description", `An amazing Budibase ${type}!` ) const version = await questions.string("Version", "1.0.0") + const topLevel = await askAboutTopLevel(name) // get the skeleton console.log(info("Retrieving project...")) await getSkeleton(type, name) - await fleshOutSkeleton(type, name, desc, version) + await fleshOutSkeleton(type, name, description, version) console.log(info("Installing dependencies...")) await runPkgCommand("install", join(process.cwd(), name)) - console.log(info(`Plugin created in directory "${name}"`)) + // if no parent directory desired move to cwd + if (!topLevel) { + moveDirectory(name, process.cwd()) + console.log(info(`Plugin created in current directory.`)) + } else { + console.log(info(`Plugin created in directory "${name}"`)) + } + captureEvent(AnalyticsEvents.PluginInit, { + type, + name, + description, + version, + }) } async function verify() { @@ -109,6 +145,29 @@ async function watch() { } } +async function dev() { + const pluginDir = await questions.string("Directory to watch", "./") + const [port] = await fp(10000) + const password = "admin" + await hostingInit({ + init: InitTypes.QUICK, + single: true, + watchPluginDir: pluginDir, + genUser: password, + port, + silent: true, + }) + await hostingStart() + console.log(success(`Configuration has been written to docker-compose.yaml`)) + console.log( + success("Development environment started successfully - connect at: ") + + info(`http://localhost:${port}`) + ) + console.log(success("Use the following credentials to login:")) + console.log(success("Email: ") + info(GENERATED_USER_EMAIL)) + console.log(success("Password: ") + info(password)) +} + const command = new Command(`${CommandWords.PLUGIN}`) .addHelp( "Custom plugins for Budibase, init, build and verify your components and datasources with this tool." @@ -128,5 +187,10 @@ const command = new Command(`${CommandWords.PLUGIN}`) "Automatically build any changes to your plugin.", watch ) + .addSubOption( + "--dev", + "Run a development environment which automatically watches the current directory.", + dev + ) exports.command = command diff --git a/packages/cli/src/prebuilds.js b/packages/cli/src/prebuilds.js index b582b090e7..0decdc6c63 100644 --- a/packages/cli/src/prebuilds.js +++ b/packages/cli/src/prebuilds.js @@ -27,7 +27,10 @@ function checkForBinaries() { } function cleanup(evt) { - if (evt && evt.errno) { + if (!isNaN(evt)) { + return + } + if (evt) { console.error( error( "Failed to run CLI command - please report with the following message:" diff --git a/packages/cli/src/structures/Command.js b/packages/cli/src/structures/Command.js index dfce96504d..e383c14263 100644 --- a/packages/cli/src/structures/Command.js +++ b/packages/cli/src/structures/Command.js @@ -1,4 +1,9 @@ -const { getSubHelpDescription, getHelpDescription, error } = require("../utils") +const { + getSubHelpDescription, + getHelpDescription, + error, + capitaliseFirstLetter, +} = require("../utils") class Command { constructor(command, func = null) { @@ -8,6 +13,15 @@ class Command { this.func = func } + convertToCommander(lookup) { + const parts = lookup.toLowerCase().split("-") + // camel case, separate out first + const first = parts.shift() + return [first] + .concat(parts.map(part => capitaliseFirstLetter(part))) + .join("") + } + addHelp(help) { this.help = help return this @@ -25,10 +39,7 @@ class Command { command = command.description(getHelpDescription(thisCmd.help)) } for (let opt of thisCmd.opts) { - command = command.option( - `${opt.command}`, - getSubHelpDescription(opt.help) - ) + command = command.option(opt.command, getSubHelpDescription(opt.help)) } command.helpOption( "--help", @@ -36,17 +47,25 @@ class Command { ) command.action(async options => { try { - let executed = false + let executed = false, + found = false for (let opt of thisCmd.opts) { - const lookup = opt.command.split(" ")[0].replace("--", "") - if (!executed && options[lookup]) { + let lookup = opt.command.split(" ")[0].replace("--", "") + // need to handle how commander converts watch-plugin-dir to watchPluginDir + lookup = this.convertToCommander(lookup) + found = !executed && options[lookup] + if (found && opt.func) { const input = Object.keys(options).length > 1 ? options : options[lookup] await opt.func(input) executed = true } } - if (!executed) { + if (found && !executed) { + console.log( + error(`${Object.keys(options)[0]} is an option, not an operation.`) + ) + } else if (!executed) { console.log(error(`Unknown ${this.command} option.`)) command.help() } diff --git a/packages/cli/src/structures/ConfigManager.js b/packages/cli/src/structures/ConfigManager.js index 04b7875b57..35799b8e92 100644 --- a/packages/cli/src/structures/ConfigManager.js +++ b/packages/cli/src/structures/ConfigManager.js @@ -33,11 +33,10 @@ class ConfigManager { } setValue(key, value) { - const updated = { + this.config = { ...this.config, [key]: value, } - this.config = updated } removeKey(key) { diff --git a/packages/cli/src/utils.js b/packages/cli/src/utils.js index 818153ef02..ba793420e7 100644 --- a/packages/cli/src/utils.js +++ b/packages/cli/src/utils.js @@ -3,6 +3,7 @@ const fs = require("fs") const axios = require("axios") const path = require("path") const progress = require("cli-progress") +const { join } = require("path") exports.downloadFile = async (url, filePath) => { filePath = path.resolve(filePath) @@ -67,3 +68,31 @@ exports.progressBar = total => { exports.checkSlashesInUrl = url => { return url.replace(/(https?:\/\/)|(\/)+/g, "$1$2") } + +exports.moveDirectory = (oldPath, newPath) => { + const files = fs.readdirSync(oldPath) + // check any file exists already + for (let file of files) { + if (fs.existsSync(join(newPath, file))) { + throw new Error( + "Unable to remove top level directory - some skeleton files already exist." + ) + } + } + for (let file of files) { + fs.renameSync(join(oldPath, file), join(newPath, file)) + } + fs.rmdirSync(oldPath) +} + +exports.capitaliseFirstLetter = str => { + return str.charAt(0).toUpperCase() + str.slice(1) +} + +exports.stringifyToDotEnv = json => { + let str = "" + for (let [key, value] of Object.entries(json)) { + str += `${key}=${value}\n` + } + return str +} diff --git a/packages/cli/yarn.lock b/packages/cli/yarn.lock index 547e9fd3e2..0850f94154 100644 --- a/packages/cli/yarn.lock +++ b/packages/cli/yarn.lock @@ -1113,6 +1113,11 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" +find-free-port@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/find-free-port/-/find-free-port-2.0.0.tgz#4b22e5f6579eb1a38c41ac6bcb3efed1b6da9b1b" + integrity sha512-J1j8gfEVf5FN4PR5w5wrZZ7NYs2IvqsHcd03cAeQx3Ec/mo+lKceaVNhpsRKoZpZKbId88o8qh+dwUwzBV6WCg== + find-replace@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38" @@ -3080,6 +3085,11 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== +yaml@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.1.1.tgz#1e06fb4ca46e60d9da07e4f786ea370ed3c3cfec" + integrity sha512-o96x3OPo8GjWeSLF+wOAbrPfhFOGY0W00GNaxCDv+9hkcDJEnev1yh8S7pgHF0ik6zc8sQLuL8hjHjJULZp8bw== + yargs-parser@^20.2.2: version "20.2.9" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" diff --git a/packages/client/package.json b/packages/client/package.json index f20fda52f8..7f662c557e 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/client", - "version": "1.3.15-alpha.3", + "version": "2.0.24-alpha.0", "license": "MPL-2.0", "module": "dist/budibase-client.js", "main": "dist/budibase-client.js", @@ -19,9 +19,9 @@ "dev:builder": "rollup -cw" }, "dependencies": { - "@budibase/bbui": "1.3.15-alpha.3", - "@budibase/frontend-core": "1.3.15-alpha.3", - "@budibase/string-templates": "1.3.15-alpha.3", + "@budibase/bbui": "2.0.24-alpha.0", + "@budibase/frontend-core": "2.0.24-alpha.0", + "@budibase/string-templates": "2.0.24-alpha.0", "@spectrum-css/button": "^3.0.3", "@spectrum-css/card": "^3.0.3", "@spectrum-css/divider": "^1.0.3", @@ -35,7 +35,6 @@ "downloadjs": "1.4.7", "leaflet": "^1.7.1", "regexparam": "^1.3.0", - "rollup-plugin-polyfill-node": "^0.8.0", "sanitize-html": "^2.7.0", "screenfull": "^6.0.1", "shortid": "^2.2.15", @@ -52,6 +51,7 @@ "postcss": "^8.2.10", "rollup": "^2.44.0", "rollup-plugin-json": "^4.0.0", + "rollup-plugin-polyfill-node": "^0.8.0", "rollup-plugin-postcss": "^4.0.0", "rollup-plugin-svelte": "^7.1.0", "rollup-plugin-svg": "^2.0.0", diff --git a/packages/client/src/components/ClientApp.svelte b/packages/client/src/components/ClientApp.svelte index c212fcf0f5..537e963ff3 100644 --- a/packages/client/src/components/ClientApp.svelte +++ b/packages/client/src/components/ClientApp.svelte @@ -92,7 +92,7 @@ {#if $builderStore.usedPlugins?.length} {#each $builderStore.usedPlugins as plugin (plugin.hash)} - + {/each} {/if} diff --git a/packages/client/src/components/Component.svelte b/packages/client/src/components/Component.svelte index b72fa16216..2d586df24d 100644 --- a/packages/client/src/components/Component.svelte +++ b/packages/client/src/components/Component.svelte @@ -142,6 +142,10 @@ // Determine and apply settings to the component $: applySettings(staticSettings, enrichedSettings, conditionalSettings) + // Determine custom css. + // Broken out as a separate variable to minimize reactivity updates. + $: customCSS = cachedSettings?._css + // Scroll the selected element into view $: selected && scrollIntoView() @@ -151,6 +155,7 @@ children: children.length, styles: { ...instance._styles, + custom: customCSS, id, empty: emptyState, interactive, @@ -249,14 +254,18 @@ // Get raw settings let settings = {} Object.entries(instance) - .filter(([name]) => name === "_conditions" || !name.startsWith("_")) + .filter(([name]) => !name.startsWith("_")) .forEach(([key, value]) => { settings[key] = value }) - - // Derive static, dynamic and nested settings if the instance changed let newStaticSettings = { ...settings } let newDynamicSettings = { ...settings } + + // Attach some internal properties + newDynamicSettings["_conditions"] = instance._conditions + newDynamicSettings["_css"] = instance._styles?.custom + + // Derive static, dynamic and nested settings if the instance changed settingsDefinition?.forEach(setting => { if (setting.nested) { delete newDynamicSettings[setting.key] @@ -370,6 +379,11 @@ // setting it on initialSettings directly, we avoid a double render. cachedSettings[key] = allSettings[key] + // Don't update components for internal properties + if (key.startsWith("_")) { + return + } + if (ref?.$$set) { // Programmatically set the prop to avoid svelte reactive statements // firing inside components. This circumvents the problems caused by diff --git a/packages/client/src/components/MadeInBudibase.svelte b/packages/client/src/components/FreeLogo.svelte similarity index 78% rename from packages/client/src/components/MadeInBudibase.svelte rename to packages/client/src/components/FreeLogo.svelte index 2e5d6336f1..2a5a936cdb 100644 --- a/packages/client/src/components/MadeInBudibase.svelte +++ b/packages/client/src/components/FreeLogo.svelte @@ -1,15 +1,20 @@ - + import { Link } from "@budibase/bbui" + + +
    Budibase -

    Made In Budibase

    +

    Made with Budibase

    -
    +