Merge branch 'develop' into test/qa-18-api-automation-testing-permissionsuser-settings

This commit is contained in:
Pedro Silva 2023-06-09 11:35:11 +01:00
commit 457e0f8d82
51 changed files with 1061 additions and 651 deletions

View File

@ -16,7 +16,8 @@
"dist", "dist",
"public", "public",
"*.spec.js", "*.spec.js",
"bundle.js" "bundle.js",
"packages/pro"
], ],
"plugins": ["svelte3"], "plugins": ["svelte3"],
"extends": ["eslint:recommended"], "extends": ["eslint:recommended"],

View File

@ -1,43 +1,53 @@
name: Budibase Deploy Production name: Budibase Deploy Production
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: Budibase release version. For example - 1.0.0 description: Budibase release version. For example - 1.0.0
required: false required: false
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Fail if branch is not master # - name: Fail if not a tag
if: github.ref != 'refs/heads/master' # run: |
run: | # if [[ $GITHUB_REF != refs/tags/* ]]; then
echo "Ref is not master, you must run this job from master." # echo "Workflow Dispatch can only be run on tags"
exit 1 # exit 1
# fi
- uses: actions/checkout@v2 - uses: actions/checkout@v2
# with:
# fetch-depth: 0
# - name: Fail if tag is not in master
# run: |
# if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
# echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
# exit 1
# fi
- name: Pull values.yaml from budibase-infra - name: Pull values.yaml from budibase-infra
run: | run: |
curl -H "Authorization: token ${{ secrets.GH_ACCESS_TOKEN }}" \ curl -H "Authorization: token ${{ secrets.GH_ACCESS_TOKEN }}" \
-H 'Accept: application/vnd.github.v3.raw' \ -H 'Accept: application/vnd.github.v3.raw' \
-o values.production.yaml \ -o values.production.yaml \
-L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/values.yaml -L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/values.yaml
wc -l values.production.yaml wc -l values.production.yaml
- name: Get the latest budibase release version - name: Get the latest budibase release version
id: version id: version
run: | run: |
if [ -z "${{ github.event.inputs.version }}" ]; then if [ -z "${{ github.event.inputs.version }}" ]; then
release_version=$(cat lerna.json | jq -r '.version') release_version=$(cat lerna.json | jq -r '.version')
else else
release_version=${{ github.event.inputs.version }} release_version=${{ github.event.inputs.version }}
fi fi
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- name: Configure AWS Credentials - name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1 uses: aws-actions/configure-aws-credentials@v1
with: with:
@ -64,4 +74,3 @@ jobs:
webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }} webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }}
content: "Production Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Cloud." content: "Production Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Cloud."
embed-title: ${{ env.RELEASE_VERSION }} embed-title: ${{ env.RELEASE_VERSION }}

View File

@ -1,27 +1,35 @@
name: "deploy-preprod" name: "deploy-preprod"
on: on:
workflow_dispatch: workflow_dispatch:
inputs:
version:
description: Budibase release version. For example - 1.0.0
required: false
workflow_call: workflow_call:
jobs: jobs:
deploy-to-legacy-preprod-env: deploy-to-legacy-preprod-env:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Fail if not a tag
run: |
if [[ $GITHUB_REF != refs/tags/* ]]; then
echo "Workflow Dispatch can only be run on tags"
exit 1
fi
- uses: actions/checkout@v2 - uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Fail if tag is not in master
run: |
if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
exit 1
fi
- name: Get the latest budibase release version - name: Get the latest budibase release version
id: version id: version
run: | run: |
if [ -z "${{ github.event.inputs.version }}" ]; then release_version=$(cat lerna.json | jq -r '.version')
git pull
release_version=$(cat lerna.json | jq -r '.version')
else
release_version=${{ github.event.inputs.version }}
fi
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- name: Configure AWS Credentials - name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1 uses: aws-actions/configure-aws-credentials@v1

View File

@ -22,6 +22,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Fail if not a tag
run: |
if [[ $GITHUB_REF != refs/tags/* ]]; then
echo "Workflow Dispatch can only be run on tags"
exit 1
fi
- uses: actions/checkout@v2 - uses: actions/checkout@v2
with: with:
submodules: true submodules: true
@ -41,19 +48,8 @@ jobs:
- run: yarn install --frozen-lockfile - run: yarn install --frozen-lockfile
- name: Update versions - name: Update versions
run: | run: ./scripts/updateVersions.sh
version=$(cat lerna.json \ - run: yarn build
| grep version \
| head -1 \
| awk -F: '{gsub(/"/,"",$2);gsub(/[[:space:]]*/,"",$2); print $2}' \
| sed 's/[",]//g')
echo "Setting version $version"
yarn lerna exec "yarn version --no-git-tag-version --new-version=$version"
echo "Updating dependencies"
node scripts/syncLocalDependencies.js $version
echo "Syncing yarn workspace"
yarn
- run: yarn build --configuration=production
- run: yarn build:sdk - run: yarn build:sdk
- name: Publish budibase packages to NPM - name: Publish budibase packages to NPM

View File

@ -9,12 +9,6 @@ on:
- "v[0-9]+.[0-9]+.[0-9]+" - "v[0-9]+.[0-9]+.[0-9]+"
# Exclude all pre-releases # Exclude all pre-releases
- "!v*[0-9]+.[0-9]+.[0-9]+-*" - "!v*[0-9]+.[0-9]+.[0-9]+-*"
workflow_dispatch:
inputs:
tags:
description: "Release tag"
required: true
type: boolean
env: env:
# Posthog token used by ui at build time # Posthog token used by ui at build time
@ -33,12 +27,13 @@ jobs:
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
fetch-depth: 0 fetch-depth: 0
- name: Fail if branch is not master - name: Fail if tag is not in master
if: github.ref != 'refs/heads/master'
run: | run: |
echo "Ref is not master, you must run this job from master." if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
// Change to "exit 1" when merged. Left to 0 to not fail all the pipelines and not to cause noise echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
exit 0 exit 1
fi
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
@ -46,26 +41,14 @@ jobs:
- run: yarn install --frozen-lockfile - run: yarn install --frozen-lockfile
- name: Update versions - name: Update versions
run: | run: ./scripts/updateVersions.sh
version=$(cat lerna.json \
| grep version \
| head -1 \
| awk -F: '{gsub(/"/,"",$2);gsub(/[[:space:]]*/,"",$2); print $2}' \
| sed 's/[",]//g')
echo "Setting version $version"
yarn lerna exec "yarn version --no-git-tag-version --new-version=$version"
echo "Updating dependencies"
node scripts/syncLocalDependencies.js $version
echo "Syncing yarn workspace"
yarn
- run: yarn lint - run: yarn lint
- run: yarn build --configuration=production - run: yarn build
- run: yarn build:sdk - run: yarn build:sdk
- name: Publish budibase packages to NPM - name: Publish budibase packages to NPM
env: env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
RELEASE_VERSION_TYPE: ${{ github.event.inputs.versioning }}
run: | run: |
# setup the username and email. I tend to use 'GitHub Actions Bot' with no email by default # setup the username and email. I tend to use 'GitHub Actions Bot' with no email by default
git config --global user.name "Budibase Release Bot" git config --global user.name "Budibase Release Bot"
@ -140,7 +123,6 @@ jobs:
- name: Get the latest budibase release version - name: Get the latest budibase release version
id: version id: version
run: | run: |
git pull
release_version=$(cat lerna.json | jq -r '.version') release_version=$(cat lerna.json | jq -r '.version')
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV

View File

@ -1,22 +1,32 @@
name: Budibase Release Selfhost name: Budibase Release Selfhost
on: on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Fail if branch is not master - name: Fail if not a tag
if: github.ref != 'refs/heads/master'
run: | run: |
echo "Ref is not master, you must run this job from master." if [[ $GITHUB_REF != refs/tags/* ]]; then
exit 1 echo "Workflow Dispatch can only be run on tags"
exit 1
fi
- uses: actions/checkout@v2 - uses: actions/checkout@v2
with: with:
fetch_depth: 0 submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
fetch-depth: 0
- name: Fail if tag is not in master
run: |
if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
exit 1
fi
- name: Use Node.js 14.x - name: Use Node.js 14.x
uses: actions/setup-node@v1 uses: actions/setup-node@v1
@ -30,7 +40,7 @@ jobs:
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- name: Tag and release Docker images (Self Host) - name: Tag and release Docker images (Self Host)
run: | run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
release_tag=v${{ env.RELEASE_VERSION }} release_tag=v${{ env.RELEASE_VERSION }}
@ -44,7 +54,7 @@ jobs:
docker tag budibase/apps:$release_tag budibase/apps:$SELFHOST_TAG docker tag budibase/apps:$release_tag budibase/apps:$SELFHOST_TAG
docker tag budibase/worker:$release_tag budibase/worker:$SELFHOST_TAG docker tag budibase/worker:$release_tag budibase/worker:$SELFHOST_TAG
docker tag budibase/proxy:$release_tag budibase/proxy:$SELFHOST_TAG docker tag budibase/proxy:$release_tag budibase/proxy:$SELFHOST_TAG
# Push images # Push images
docker push budibase/apps:$SELFHOST_TAG docker push budibase/apps:$SELFHOST_TAG
docker push budibase/worker:$SELFHOST_TAG docker push budibase/worker:$SELFHOST_TAG
@ -66,19 +76,19 @@ jobs:
yarn yarn
yarn specs yarn specs
popd popd
- name: Setup Helm - name: Setup Helm
uses: azure/setup-helm@v1 uses: azure/setup-helm@v1
id: helm-install id: helm-install
# due to helm repo index issue: https://github.com/helm/helm/issues/7363 # due to helm repo index issue: https://github.com/helm/helm/issues/7363
# we need to create new package in a different dir, merge the index and move the package back # we need to create new package in a different dir, merge the index and move the package back
- name: Build and release helm chart - name: Build and release helm chart
run: | run: |
git config user.name "Budibase Helm Bot" git config user.name "Budibase Helm Bot"
git config user.email "<>" git config user.email "<>"
git reset --hard git reset --hard
git pull git fetch
mkdir sync mkdir sync
echo "Packaging chart to sync dir" echo "Packaging chart to sync dir"
helm package charts/budibase --version "$RELEASE_VERSION" --app-version "$RELEASE_VERSION" --destination sync helm package charts/budibase --version "$RELEASE_VERSION" --app-version "$RELEASE_VERSION" --destination sync

View File

@ -5,7 +5,7 @@ on:
env: env:
CI: true CI: true
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }} PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
REGISTRY_URL: registry.hub.docker.com REGISTRY_URL: registry.hub.docker.com
jobs: jobs:
build: build:
@ -15,13 +15,26 @@ jobs:
matrix: matrix:
node-version: [14.x] node-version: [14.x]
steps: steps:
- name: Fail if branch is not master - name: Fail if not a tag
if: github.ref != 'refs/heads/master' run: |
run: | if [[ $GITHUB_REF != refs/tags/* ]]; then
echo "Ref is not master, you must run this job from master." echo "Workflow Dispatch can only be run on tags"
exit 1 exit 1
fi
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@v2 uses: actions/checkout@v2
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
fetch-depth: 0
- name: Fail if tag is not in master
run: |
if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
exit 1
fi
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
@ -33,10 +46,12 @@ jobs:
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v1
- name: Run Yarn - name: Run Yarn
run: yarn run: yarn
- name: Run Yarn Bootstrap - name: Update versions
run: yarn bootstrap run: ./scripts/updateVersions.sh
- name: Runt Yarn Lint - name: Runt Yarn Lint
run: yarn lint run: yarn lint
- name: Update versions
run: ./scripts/updateVersions.sh
- name: Run Yarn Build - name: Run Yarn Build
run: yarn build:docker:pre run: yarn build:docker:pre
- name: Login to Docker Hub - name: Login to Docker Hub

View File

@ -28,7 +28,7 @@ on:
required: true required: true
jobs: jobs:
tag-prerelease: tag-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -43,9 +43,11 @@ jobs:
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- run: yarn - run: yarn
- name: Tag prerelease - name: Tag release
run: | run: |
# setup the username and email. # setup the username and email.
git config --global user.name "Budibase Staging Release Bot" git config --global user.name "Budibase Staging Release Bot"
git config --global user.email "<>" git config --global user.email "<>"
./scripts/versionCommit.sh ${{ github.event.inputs.versioning }} BUMP_TYPE_INPUT=${{ github.event.inputs.versioning }}
BUMP_TYPE=${BUMP_TYPE_INPUT:-"patch"}
./scripts/versionCommit.sh $BUMP_TYPE

View File

@ -1,4 +1,4 @@
FROM node:16-slim as build FROM node:14-slim as build
# install node-gyp dependencies # install node-gyp dependencies
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends apt-utils cron g++ make python RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends apt-utils cron g++ make python
@ -11,12 +11,16 @@ RUN chmod +x /cleanup.sh
# build server # build server
WORKDIR /app WORKDIR /app
ADD packages/server . ADD packages/server .
RUN yarn install --frozen-lockfile --production=true && /cleanup.sh COPY yarn.lock .
RUN yarn install --production=true
RUN /cleanup.sh
# build worker # build worker
WORKDIR /worker WORKDIR /worker
ADD packages/worker . ADD packages/worker .
RUN yarn install --frozen-lockfile --production=true && /cleanup.sh COPY yarn.lock .
RUN yarn install --production=true
RUN /cleanup.sh
FROM budibase/couchdb FROM budibase/couchdb
ARG TARGETARCH ARG TARGETARCH

View File

@ -17,6 +17,7 @@ declare -a DOCKER_VARS=("APP_PORT" "APPS_URL" "ARCHITECTURE" "BUDIBASE_ENVIRONME
[[ -z "${WORKER_PORT}" ]] && export WORKER_PORT=4002 [[ -z "${WORKER_PORT}" ]] && export WORKER_PORT=4002
[[ -z "${WORKER_URL}" ]] && export WORKER_URL=http://localhost:4002 [[ -z "${WORKER_URL}" ]] && export WORKER_URL=http://localhost:4002
[[ -z "${APPS_URL}" ]] && export APPS_URL=http://localhost:4001 [[ -z "${APPS_URL}" ]] && export APPS_URL=http://localhost:4001
[[ -z "${SERVER_TOP_LEVEL_PATH}" ]] && export SERVER_TOP_LEVEL_PATH=/app
# export CUSTOM_DOMAIN=budi001.custom.com # export CUSTOM_DOMAIN=budi001.custom.com
# Azure App Service customisations # Azure App Service customisations

View File

@ -1,5 +1,5 @@
{ {
"version": "2.6.19-alpha.54", "version": "2.7.7-alpha.2",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/backend-core", "packages/backend-core",

View File

@ -38,8 +38,8 @@
"backend:build": "./scripts/scopeBackend.sh 'lerna run --stream build'", "backend:build": "./scripts/scopeBackend.sh 'lerna run --stream build'",
"build:sdk": "lerna run --stream build:sdk", "build:sdk": "lerna run --stream 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", "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 --no-git-tag-version --no-push --no-git-reset", "release": "lerna publish from-package --yes --force-publish --no-git-tag-version --no-push --no-git-reset",
"release:develop": "lerna publish from-package --yes --force-publish --dist-tag develop --exact --no-git-tag-version --no-push --no-git-reset", "release:develop": "yarn release --dist-tag develop",
"restore": "yarn run clean && yarn run bootstrap && yarn run build", "restore": "yarn run clean && yarn run bootstrap && yarn run build",
"nuke": "yarn run nuke:packages && yarn run nuke:docker", "nuke": "yarn run nuke:packages && yarn run nuke:docker",
"nuke:packages": "yarn run restore", "nuke:packages": "yarn run restore",
@ -52,7 +52,7 @@
"dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && lerna run --stream --parallel dev:builder --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker", "dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && lerna run --stream --parallel dev:builder --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker",
"dev:server": "yarn run kill-server && lerna run --stream --parallel dev:builder --scope @budibase/worker --scope @budibase/server", "dev:server": "yarn run kill-server && lerna run --stream --parallel dev:builder --scope @budibase/worker --scope @budibase/server",
"dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream --parallel dev:built", "dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream --parallel dev:built",
"dev:docker": "yarn build && docker-compose -f hosting/docker-compose.build.yaml -f hosting/docker-compose.dev.yaml --env-file hosting/.env up --build --scale proxy-service=0", "dev:docker": "yarn build:docker:pre && docker-compose -f hosting/docker-compose.build.yaml -f hosting/docker-compose.dev.yaml --env-file hosting/.env up --build --scale proxy-service=0",
"test": "lerna run --stream test --stream", "test": "lerna run --stream test --stream",
"lint:eslint": "eslint packages && eslint qa-core", "lint:eslint": "eslint packages && eslint qa-core",
"lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --check \"qa-core/**/*.{js,ts,svelte}\"", "lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --check \"qa-core/**/*.{js,ts,svelte}\"",
@ -62,7 +62,7 @@
"lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint", "lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint",
"build:specs": "lerna run --stream specs", "build:specs": "lerna run --stream specs",
"build:docker": "lerna run --stream build:docker && yarn build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh $BUDIBASE_RELEASE_VERSION && cd -", "build:docker": "lerna run --stream build:docker && yarn build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh $BUDIBASE_RELEASE_VERSION && cd -",
"build:docker:pre": "lerna run --stream build && lerna run --stream predocker", "build:docker:pre": "yarn build && lerna run --stream predocker",
"build:docker:proxy": "docker build hosting/proxy -t proxy-service", "build:docker:proxy": "docker build hosting/proxy -t proxy-service",
"build:docker:selfhost": "lerna run --stream build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh latest && cd -", "build:docker:selfhost": "lerna run --stream build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh latest && cd -",
"build:docker:develop": "node scripts/pinVersions && lerna run --stream build:docker && yarn build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -", "build:docker:develop": "node scripts/pinVersions && lerna run --stream build:docker && yarn build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -",

View File

@ -86,6 +86,7 @@ const getCurrentIdentity = async (): Promise<Identity> => {
installationId, installationId,
tenantId, tenantId,
environment, environment,
realTenantId: context.getTenantId(),
hostInfo: userContext.hostInfo, hostInfo: userContext.hostInfo,
} }
} else { } else {

View File

@ -3,6 +3,7 @@ import {
Event, Event,
User, User,
UserCreatedEvent, UserCreatedEvent,
UserDataCollaborationEvent,
UserDeletedEvent, UserDeletedEvent,
UserInviteAcceptedEvent, UserInviteAcceptedEvent,
UserInvitedEvent, UserInvitedEvent,
@ -173,6 +174,15 @@ async function passwordReset(user: User) {
await publishEvent(Event.USER_PASSWORD_RESET, properties) await publishEvent(Event.USER_PASSWORD_RESET, properties)
} }
// COLLABORATION
async function dataCollaboration(users: number) {
const properties: UserDataCollaborationEvent = {
users,
}
await publishEvent(Event.USER_DATA_COLLABORATION, properties)
}
export default { export default {
created, created,
updated, updated,
@ -188,4 +198,5 @@ export default {
passwordUpdated, passwordUpdated,
passwordResetRequested, passwordResetRequested,
passwordReset, passwordReset,
dataCollaboration,
} }

View File

@ -1,10 +1,11 @@
import * as google from "../sso/google" import * as google from "../sso/google"
import { Cookie } from "../../../constants" import { Cookie } from "../../../constants"
import { clearCookie, getCookie } from "../../../utils"
import { doWithDB } from "../../../db"
import * as configs from "../../../configs" import * as configs from "../../../configs"
import { BBContext, Database, SSOProfile } from "@budibase/types" import * as cache from "../../../cache"
import * as utils from "../../../utils"
import { UserCtx, SSOProfile } from "@budibase/types"
import { ssoSaveUserNoOp } from "../sso/sso" import { ssoSaveUserNoOp } from "../sso/sso"
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
type Passport = { type Passport = {
@ -22,7 +23,7 @@ async function fetchGoogleCreds() {
export async function preAuth( export async function preAuth(
passport: Passport, passport: Passport,
ctx: BBContext, ctx: UserCtx,
next: Function next: Function
) { ) {
// get the relevant config // get the relevant config
@ -36,8 +37,8 @@ export async function preAuth(
ssoSaveUserNoOp ssoSaveUserNoOp
) )
if (!ctx.query.appId || !ctx.query.datasourceId) { if (!ctx.query.appId) {
ctx.throw(400, "appId and datasourceId query params not present.") ctx.throw(400, "appId query param not present.")
} }
return passport.authenticate(strategy, { return passport.authenticate(strategy, {
@ -49,7 +50,7 @@ export async function preAuth(
export async function postAuth( export async function postAuth(
passport: Passport, passport: Passport,
ctx: BBContext, ctx: UserCtx,
next: Function next: Function
) { ) {
// get the relevant config // get the relevant config
@ -57,7 +58,7 @@ export async function postAuth(
const platformUrl = await configs.getPlatformUrl({ tenantAware: false }) const platformUrl = await configs.getPlatformUrl({ tenantAware: false })
let callbackUrl = `${platformUrl}/api/global/auth/datasource/google/callback` let callbackUrl = `${platformUrl}/api/global/auth/datasource/google/callback`
const authStateCookie = getCookie(ctx, Cookie.DatasourceAuth) const authStateCookie = utils.getCookie(ctx, Cookie.DatasourceAuth)
return passport.authenticate( return passport.authenticate(
new GoogleStrategy( new GoogleStrategy(
@ -69,33 +70,26 @@ export async function postAuth(
( (
accessToken: string, accessToken: string,
refreshToken: string, refreshToken: string,
profile: SSOProfile, _profile: SSOProfile,
done: Function done: Function
) => { ) => {
clearCookie(ctx, Cookie.DatasourceAuth) utils.clearCookie(ctx, Cookie.DatasourceAuth)
done(null, { accessToken, refreshToken }) done(null, { accessToken, refreshToken })
} }
), ),
{ successRedirect: "/", failureRedirect: "/error" }, { successRedirect: "/", failureRedirect: "/error" },
async (err: any, tokens: string[]) => { async (err: any, tokens: string[]) => {
const baseUrl = `/builder/app/${authStateCookie.appId}/data` const baseUrl = `/builder/app/${authStateCookie.appId}/data`
// update the DB for the datasource with all the user info
await doWithDB(authStateCookie.appId, async (db: Database) => { const id = utils.newid()
let datasource await cache.store(
try { `datasource:creation:${authStateCookie.appId}:google:${id}`,
datasource = await db.get(authStateCookie.datasourceId) {
} catch (err: any) { tokens,
if (err.status === 404) {
ctx.redirect(baseUrl)
}
} }
if (!datasource.config) { )
datasource.config = {}
} ctx.redirect(`${baseUrl}/new?continue_google_setup=${id}`)
datasource.config.auth = { type: "google", ...tokens }
await db.put(datasource)
ctx.redirect(`${baseUrl}/datasource/${authStateCookie.datasourceId}`)
})
} }
)(ctx, next) )(ctx, next)
} }

View File

@ -13,6 +13,8 @@
Modal, Modal,
notifications, notifications,
Icon, Icon,
Checkbox,
DatePicker,
} from "@budibase/bbui" } from "@budibase/bbui"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte" import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import { automationStore, selectedAutomation } from "builderStore" import { automationStore, selectedAutomation } from "builderStore"
@ -306,6 +308,11 @@
drawer.hide() drawer.hide()
} }
function canShowField(key, value) {
const dependsOn = value.dependsOn
return !dependsOn || !!inputData[dependsOn]
}
onMount(async () => { onMount(async () => {
try { try {
await environment.loadVariables() await environment.loadVariables()
@ -317,210 +324,233 @@
<div class="fields"> <div class="fields">
{#each deprecatedSchemaProperties as [key, value]} {#each deprecatedSchemaProperties as [key, value]}
<div class="block-field"> {#if canShowField(key, value)}
{#if key !== "fields"} <div class="block-field">
<Label {#if key !== "fields" && value.type !== "boolean"}
tooltip={value.title === "Binding / Value" <Label
? "If using the String input type, please use a comma or newline separated string" tooltip={value.title === "Binding / Value"
: null}>{value.title || (key === "row" ? "Table" : key)}</Label ? "If using the String input type, please use a comma or newline separated string"
> : null}>{value.title || (key === "row" ? "Table" : key)}</Label
{/if} >
{#if value.type === "string" && value.enum}
<Select
on:change={e => onChange(e, key)}
value={inputData[key]}
placeholder={false}
options={value.enum}
getOptionLabel={(x, idx) => (value.pretty ? value.pretty[idx] : x)}
/>
{:else if value.type === "json"}
<Editor
editorHeight="250"
editorWidth="448"
mode="json"
value={inputData[key]?.value}
on:change={e => {
/**
* TODO - Remove after November 2023
* *******************************
* Code added to provide backwards compatibility between Values 1,2,3,4,5
* and the new JSON body.
*/
delete inputData.value1
delete inputData.value2
delete inputData.value3
delete inputData.value4
delete inputData.value5
/***********************/
onChange(e, key)
}}
/>
{:else if value.customType === "column"}
<Select
on:change={e => onChange(e, key)}
value={inputData[key]}
options={Object.keys(table?.schema || {})}
/>
{:else if value.customType === "filters"}
<ActionButton on:click={drawer.show}>Define filters</ActionButton>
<Drawer bind:this={drawer} {fillWidth} title="Filtering">
<Button cta slot="buttons" on:click={() => saveFilters(key)}>
Save
</Button>
<FilterDrawer
slot="body"
{filters}
{bindings}
{schemaFields}
datasource={{ type: "table", tableId }}
panel={AutomationBindingPanel}
fillWidth
on:change={e => (tempFilters = e.detail)}
/>
</Drawer>
{:else if value.customType === "password"}
<Input
type="password"
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "email"}
{#if isTestModal}
<ModalBindableInput
title={value.title}
value={inputData[key]}
panel={AutomationBindingPanel}
type="email"
on:change={e => onChange(e, key)}
{bindings}
fillWidth
updateOnChange={false}
/>
{:else}
<DrawerBindableInput
fillWidth
title={value.title}
panel={AutomationBindingPanel}
type="email"
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
allowJS={false}
updateOnChange={false}
drawerLeft="260px"
/>
{/if} {/if}
{:else if value.customType === "query"} {#if value.type === "string" && value.enum && canShowField(key)}
<QuerySelector <Select
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "cron"}
<CronBuilder on:change={e => onChange(e, key)} value={inputData[key]} />
{:else if value.customType === "queryParams"}
<QueryParamSelector
on:change={e => onChange(e, key)}
value={inputData[key]}
{bindings}
/>
{:else if value.customType === "table"}
<TableSelector
{isTrigger}
value={inputData[key]}
on:change={e => onChange(e, key)}
/>
{:else if value.customType === "row"}
<RowSelector
{block}
value={inputData[key]}
meta={inputData["meta"] || {}}
on:change={e => {
if (e.detail?.key) {
onChange(e, e.detail.key)
} else {
onChange(e, key)
}
}}
{bindings}
{isTestModal}
{isUpdateRow}
/>
{:else if value.customType === "webhookUrl"}
<WebhookDisplay
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "fields"}
<FieldSelector
{block}
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
{isTestModal}
/>
{:else if value.customType === "triggerSchema"}
<SchemaSetup on:change={e => onChange(e, key)} value={inputData[key]} />
{:else if value.customType === "code"}
<CodeEditorModal>
<CodeEditor
value={inputData[key]}
on:change={e => {
// need to pass without the value inside
onChange({ detail: e.detail }, key)
inputData[key] = e.detail
}}
completions={[
jsAutocomplete([
...bindingsToCompletions(bindings, EditorModes.JS),
]),
]}
mode={EditorModes.JS}
height={500}
/>
<div class="messaging">
<Icon name="FlashOn" />
<div class="messaging-wrap">
<div>Add available bindings by typing <strong>$</strong></div>
</div>
</div>
</CodeEditorModal>
{:else if value.customType === "loopOption"}
<Select
on:change={e => onChange(e, key)}
autoWidth
value={inputData[key]}
options={["Array", "String"]}
defaultValue={"Array"}
/>
{:else if value.type === "string" || value.type === "number" || value.type === "integer"}
{#if isTestModal}
<ModalBindableInput
title={value.title}
value={inputData[key]}
panel={AutomationBindingPanel}
type={value.customType}
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
{bindings} value={inputData[key]}
updateOnChange={false} placeholder={false}
options={value.enum}
getOptionLabel={(x, idx) => (value.pretty ? value.pretty[idx] : x)}
/> />
{:else} {:else if value.type === "json"}
<div class="test"> <Editor
editorHeight="250"
editorWidth="448"
mode="json"
value={inputData[key]?.value}
on:change={e => {
/**
* TODO - Remove after November 2023
* *******************************
* Code added to provide backwards compatibility between Values 1,2,3,4,5
* and the new JSON body.
*/
delete inputData.value1
delete inputData.value2
delete inputData.value3
delete inputData.value4
delete inputData.value5
/***********************/
onChange(e, key)
}}
/>
{:else if value.type === "boolean"}
<div style="margin-top: 10px">
<Checkbox
text={value.title}
value={inputData[key]}
on:change={e => onChange(e, key)}
/>
</div>
{:else if value.type === "date"}
<DatePicker
value={inputData[key]}
on:change={e => onChange(e, key)}
/>
{:else if value.customType === "column"}
<Select
on:change={e => onChange(e, key)}
value={inputData[key]}
options={Object.keys(table?.schema || {})}
/>
{:else if value.customType === "filters"}
<ActionButton on:click={drawer.show}>Define filters</ActionButton>
<Drawer bind:this={drawer} {fillWidth} title="Filtering">
<Button cta slot="buttons" on:click={() => saveFilters(key)}>
Save
</Button>
<FilterDrawer
slot="body"
{filters}
{bindings}
{schemaFields}
datasource={{ type: "table", tableId }}
panel={AutomationBindingPanel}
fillWidth
on:change={e => (tempFilters = e.detail)}
/>
</Drawer>
{:else if value.customType === "password"}
<Input
type="password"
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "email"}
{#if isTestModal}
<ModalBindableInput
title={value.title}
value={inputData[key]}
panel={AutomationBindingPanel}
type="email"
on:change={e => onChange(e, key)}
{bindings}
fillWidth
updateOnChange={false}
/>
{:else}
<DrawerBindableInput <DrawerBindableInput
fillWidth={true} fillWidth
title={value.title} title={value.title}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
type={value.customType} type="email"
value={inputData[key]} value={inputData[key]}
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
{bindings} {bindings}
allowJS={false}
updateOnChange={false} updateOnChange={false}
placeholder={value.customType === "queryLimit" ? queryLimit : ""}
drawerLeft="260px" drawerLeft="260px"
/> />
</div> {/if}
{:else if value.customType === "query"}
<QuerySelector
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "cron"}
<CronBuilder
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "queryParams"}
<QueryParamSelector
on:change={e => onChange(e, key)}
value={inputData[key]}
{bindings}
/>
{:else if value.customType === "table"}
<TableSelector
{isTrigger}
value={inputData[key]}
on:change={e => onChange(e, key)}
/>
{:else if value.customType === "row"}
<RowSelector
{block}
value={inputData[key]}
meta={inputData["meta"] || {}}
on:change={e => {
if (e.detail?.key) {
onChange(e, e.detail.key)
} else {
onChange(e, key)
}
}}
{bindings}
{isTestModal}
{isUpdateRow}
/>
{:else if value.customType === "webhookUrl"}
<WebhookDisplay
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "fields"}
<FieldSelector
{block}
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
{isTestModal}
/>
{:else if value.customType === "triggerSchema"}
<SchemaSetup
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "code"}
<CodeEditorModal>
<CodeEditor
value={inputData[key]}
on:change={e => {
// need to pass without the value inside
onChange({ detail: e.detail }, key)
inputData[key] = e.detail
}}
completions={[
jsAutocomplete([
...bindingsToCompletions(bindings, EditorModes.JS),
]),
]}
mode={EditorModes.JS}
height={500}
/>
<div class="messaging">
<Icon name="FlashOn" />
<div class="messaging-wrap">
<div>Add available bindings by typing <strong>$</strong></div>
</div>
</div>
</CodeEditorModal>
{:else if value.customType === "loopOption"}
<Select
on:change={e => onChange(e, key)}
autoWidth
value={inputData[key]}
options={["Array", "String"]}
defaultValue={"Array"}
/>
{:else if value.type === "string" || value.type === "number" || value.type === "integer"}
{#if isTestModal}
<ModalBindableInput
title={value.title}
value={inputData[key]}
panel={AutomationBindingPanel}
type={value.customType}
on:change={e => onChange(e, key)}
{bindings}
updateOnChange={false}
/>
{:else}
<div class="test">
<DrawerBindableInput
fillWidth={true}
title={value.title}
panel={AutomationBindingPanel}
type={value.customType}
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
updateOnChange={false}
placeholder={value.customType === "queryLimit"
? queryLimit
: ""}
drawerLeft="260px"
/>
</div>
{/if}
{/if} {/if}
{/if} </div>
</div> {/if}
{/each} {/each}
</div> </div>
<Modal bind:this={webhookModal} width="30%"> <Modal bind:this={webhookModal} width="30%">

View File

@ -3,8 +3,6 @@
import { store } from "builderStore" import { store } from "builderStore"
import { auth } from "stores/portal" import { auth } from "stores/portal"
export let preAuthStep
export let datasource
export let disabled export let disabled
export let samePage export let samePage
@ -15,18 +13,8 @@
class:disabled class:disabled
{disabled} {disabled}
on:click={async () => { on:click={async () => {
let ds = datasource
let appId = $store.appId let appId = $store.appId
if (!ds) { const url = `/api/global/auth/${tenantId}/datasource/google?appId=${appId}`
const resp = await preAuthStep()
if (resp.datasource && resp.appId) {
ds = resp.datasource
appId = resp.appId
} else {
ds = resp
}
}
const url = `/api/global/auth/${tenantId}/datasource/google?datasourceId=${ds._id}&appId=${appId}`
if (samePage) { if (samePage) {
window.location = url window.location = url
} else { } else {

View File

@ -1,43 +1,110 @@
<script> <script>
import { ModalContent, Body, Layout, Link } from "@budibase/bbui" import {
import { IntegrationNames } from "constants/backend" ModalContent,
import cloneDeep from "lodash/cloneDeepWith" Body,
Layout,
Link,
notifications,
} from "@budibase/bbui"
import { IntegrationNames, IntegrationTypes } from "constants/backend"
import GoogleButton from "../_components/GoogleButton.svelte" import GoogleButton from "../_components/GoogleButton.svelte"
import { saveDatasource as save } from "builderStore/datasource"
import { organisation } from "stores/portal" import { organisation } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import { validateDatasourceConfig } from "builderStore/datasource"
import cloneDeep from "lodash/cloneDeepWith"
import IntegrationConfigForm from "../TableIntegrationMenu/IntegrationConfigForm.svelte"
import { goto } from "@roxi/routify"
import { saveDatasource } from "builderStore/datasource"
import { DatasourceFeature } from "@budibase/types"
export let integration export let integration
export let continueSetupId = false
// kill the reference so the input isn't saved
let datasource = cloneDeep(integration) let datasource = cloneDeep(integration)
datasource.config.continueSetupId = continueSetupId
$: isGoogleConfigured = !!$organisation.googleDatasourceConfigured $: isGoogleConfigured = !!$organisation.googleDatasourceConfigured
onMount(async () => { onMount(async () => {
await organisation.init() await organisation.init()
}) })
const integrationName = IntegrationNames[IntegrationTypes.GOOGLE_SHEETS]
export const GoogleDatasouceConfigStep = {
AUTH: "Auth",
SET_URL: "Set_url",
}
let step = continueSetupId
? GoogleDatasouceConfigStep.SET_URL
: GoogleDatasouceConfigStep.AUTH
let isValid = false
const modalConfig = {
[GoogleDatasouceConfigStep.AUTH]: {},
[GoogleDatasouceConfigStep.SET_URL]: {
confirmButtonText: "Connect",
onConfirm: async () => {
if (integration.features[DatasourceFeature.CONNECTION_CHECKING]) {
const resp = await validateDatasourceConfig(datasource)
if (!resp.connected) {
notifications.error(`Unable to connect - ${resp.error}`)
return false
}
}
try {
const resp = await saveDatasource(datasource)
$goto(`./datasource/${resp._id}`)
notifications.success(`Datasource created successfully.`)
} catch (err) {
notifications.error(err?.message ?? "Error saving datasource")
// prevent the modal from closing
return false
}
},
},
}
</script> </script>
<ModalContent <ModalContent
title={`Connect to ${IntegrationNames[datasource.type]}`} title={`Connect to ${integrationName}`}
cancelText="Back" cancelText="Cancel"
size="L" size="L"
confirmText={modalConfig[step].confirmButtonText}
showConfirmButton={!!modalConfig[step].onConfirm}
onConfirm={modalConfig[step].onConfirm}
disabled={!isValid}
> >
<!-- check true and false directly, don't render until flag is set --> {#if step === GoogleDatasouceConfigStep.AUTH}
{#if isGoogleConfigured === true} <!-- check true and false directly, don't render until flag is set -->
<Layout noPadding> {#if isGoogleConfigured === true}
<Layout noPadding>
<Body size="S"
>Authenticate with your google account to use the {integrationName} integration.</Body
>
</Layout>
<GoogleButton samePage />
{:else if isGoogleConfigured === false}
<Body size="S" <Body size="S"
>Authenticate with your google account to use the {IntegrationNames[ >Google authentication is not enabled, please complete Google SSO
datasource.type configuration.</Body
]} integration.</Body
> >
<Link href="/builder/portal/settings/auth">Configure Google SSO</Link>
{/if}
{/if}
{#if step === GoogleDatasouceConfigStep.SET_URL}
<Layout noPadding no>
<Body size="S">Add the URL of the sheet you want to connect.</Body>
<IntegrationConfigForm
schema={datasource.schema}
bind:datasource
creating={true}
on:valid={e => (isValid = e.detail)}
/>
</Layout> </Layout>
<GoogleButton preAuthStep={() => save(datasource, true)} />
{:else if isGoogleConfigured === false}
<Body size="S"
>Google authentication is not enabled, please complete Google SSO
configuration.</Body
>
<Link href="/builder/portal/settings/auth">Configure Google SSO</Link>
{/if} {/if}
</ModalContent> </ModalContent>

View File

@ -17,6 +17,7 @@
import IntegrationIcon from "components/backend/DatasourceNavigator/IntegrationIcon.svelte" import IntegrationIcon from "components/backend/DatasourceNavigator/IntegrationIcon.svelte"
import ICONS from "components/backend/DatasourceNavigator/icons/index.js" import ICONS from "components/backend/DatasourceNavigator/icons/index.js"
import FontAwesomeIcon from "components/common/FontAwesomeIcon.svelte" import FontAwesomeIcon from "components/common/FontAwesomeIcon.svelte"
import { onMount } from "svelte"
let internalTableModal let internalTableModal
let externalDatasourceModal let externalDatasourceModal
@ -129,9 +130,19 @@
return integrationsArray return integrationsArray
} }
let continueGoogleSetup
onMount(() => {
const urlParams = new URLSearchParams(window.location.search)
continueGoogleSetup = urlParams.get("continue_google_setup")
})
const fetchIntegrations = async () => { const fetchIntegrations = async () => {
const unsortedIntegrations = await API.getIntegrations() const unsortedIntegrations = await API.getIntegrations()
integrations = sortIntegrations(unsortedIntegrations) integrations = sortIntegrations(unsortedIntegrations)
if (continueGoogleSetup) {
handleIntegrationSelect(IntegrationTypes.GOOGLE_SHEETS)
}
} }
$: fetchIntegrations() $: fetchIntegrations()
@ -141,9 +152,17 @@
<CreateTableModal {promptUpload} afterSave={handleInternalTableSave} /> <CreateTableModal {promptUpload} afterSave={handleInternalTableSave} />
</Modal> </Modal>
<Modal bind:this={externalDatasourceModal}> <Modal
bind:this={externalDatasourceModal}
on:hide={() => {
continueGoogleSetup = null
}}
>
{#if integration?.auth?.type === "google"} {#if integration?.auth?.type === "google"}
<GoogleDatasourceConfigModal {integration} /> <GoogleDatasourceConfigModal
continueSetupId={continueGoogleSetup}
{integration}
/>
{:else} {:else}
<DatasourceConfigModal {integration} /> <DatasourceConfigModal {integration} />
{/if} {/if}

View File

@ -0,0 +1,235 @@
<script>
import GoogleLogo from "./_logos/Google.svelte"
import { isEqual, cloneDeep } from "lodash/fp"
import {
Button,
Heading,
Divider,
Label,
notifications,
Layout,
Input,
Body,
Toggle,
Icon,
Helpers,
Link,
} from "@budibase/bbui"
import { onMount } from "svelte"
import { API } from "api"
import { organisation, admin } from "stores/portal"
const ConfigTypes = {
Google: "google",
}
// Some older google configs contain a manually specified value - retain the functionality to edit the field
// When there is no value or we are in the cloud - prohibit editing the field, must use platform url to change
$: googleCallbackUrl = undefined
$: googleCallbackReadonly = $admin.cloud || !googleCallbackUrl
// Indicate to user that callback is based on platform url
// If there is an existing value, indicate that it may be removed to return to default behaviour
$: googleCallbackTooltip = $admin.cloud
? null
: googleCallbackReadonly
? "Visit the organisation page to update the platform URL"
: "Leave blank to use the default callback URL"
$: googleSheetsCallbackUrl = `${$organisation.platformUrl}/api/global/auth/datasource/google/callback`
$: GoogleConfigFields = {
Google: [
{ name: "clientID", label: "Client ID" },
{ name: "clientSecret", label: "Client secret" },
{
name: "callbackURL",
label: "Callback URL",
readonly: googleCallbackReadonly,
tooltip: googleCallbackTooltip,
placeholder: $organisation.googleCallbackUrl,
copyButton: true,
},
{
name: "sheetsURL",
label: "Sheets URL",
readonly: googleCallbackReadonly,
tooltip: googleCallbackTooltip,
placeholder: googleSheetsCallbackUrl,
copyButton: true,
},
],
}
let google
const providers = { google }
// control the state of the save button depending on whether form has changed
let originalGoogleDoc
let googleSaveButtonDisabled
$: {
isEqual(providers.google?.config, originalGoogleDoc?.config)
? (googleSaveButtonDisabled = true)
: (googleSaveButtonDisabled = false)
}
$: googleComplete = !!(
providers.google?.config?.clientID && providers.google?.config?.clientSecret
)
async function saveConfig(config) {
// Delete unsupported fields
delete config.createdAt
delete config.updatedAt
return API.saveConfig(config)
}
async function saveGoogle() {
if (!googleComplete) {
notifications.error(
`Please fill in all required ${ConfigTypes.Google} fields`
)
return
}
const google = providers.google
try {
const res = await saveConfig(google)
providers[res.type]._rev = res._rev
providers[res.type]._id = res._id
notifications.success(`Settings saved`)
} catch (e) {
notifications.error(e.message)
return
}
googleSaveButtonDisabled = true
originalGoogleDoc = cloneDeep(providers.google)
}
const copyToClipboard = async value => {
await Helpers.copyToClipboard(value)
notifications.success("Copied")
}
onMount(async () => {
try {
await organisation.init()
} catch (error) {
notifications.error("Error getting org config")
}
// Fetch Google config
let googleDoc
try {
googleDoc = await API.getConfig(ConfigTypes.Google)
} catch (error) {
notifications.error("Error fetching Google OAuth config")
}
if (!googleDoc?._id) {
providers.google = {
type: ConfigTypes.Google,
config: { activated: false },
}
originalGoogleDoc = cloneDeep(googleDoc)
} else {
// Default activated to true for older configs
if (googleDoc.config.activated === undefined) {
googleDoc.config.activated = true
}
originalGoogleDoc = cloneDeep(googleDoc)
providers.google = googleDoc
}
googleCallbackUrl = providers?.google?.config?.callbackURL
})
</script>
{#if providers.google}
<Divider />
<Layout gap="XS" noPadding>
<Heading size="S">
<div class="provider-title">
<GoogleLogo />
<span>Google</span>
</div>
</Heading>
<Body size="S">
To allow users to authenticate using their Google accounts, fill out the
fields below. Read the <Link
size="M"
href={"https://docs.budibase.com/docs/sso-with-google"}
>documentation</Link
> for more information.
</Body>
</Layout>
<Layout gap="XS" noPadding>
{#each GoogleConfigFields.Google as field}
<div class="form-row">
<Label size="L" tooltip={field.tooltip}>{field.label}</Label>
<div class="inputContainer">
<div class="input">
<Input
bind:value={providers.google.config[field.name]}
readonly={field.readonly}
placeholder={field.placeholder}
/>
</div>
{#if field.copyButton}
<div
class="copy"
on:click={() => copyToClipboard(field.placeholder)}
>
<Icon size="S" name="Copy" />
</div>
{/if}
</div>
</div>
{/each}
<div class="form-row">
<Label size="L">Activated</Label>
<Toggle text="" bind:value={providers.google.config.activated} />
</div>
</Layout>
<div>
<Button
disabled={googleSaveButtonDisabled}
cta
on:click={() => saveGoogle()}
>
Save
</Button>
</div>
{/if}
<style>
.form-row {
display: grid;
grid-template-columns: 120px 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
.provider-title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: var(--spacing-m);
}
.provider-title span {
flex: 1 1 auto;
}
.inputContainer {
display: flex;
flex-direction: row;
}
.input {
flex: 1;
}
.copy {
display: flex;
align-items: center;
margin-left: 10px;
}
</style>

View File

@ -1,5 +1,4 @@
<script> <script>
import GoogleLogo from "./_logos/Google.svelte"
import OidcLogo from "./_logos/OIDC.svelte" import OidcLogo from "./_logos/OIDC.svelte"
import MicrosoftLogo from "assets/microsoft-logo.png" import MicrosoftLogo from "assets/microsoft-logo.png"
import Auth0Logo from "assets/auth0-logo.png" import Auth0Logo from "assets/auth0-logo.png"
@ -28,9 +27,9 @@
import { API } from "api" import { API } from "api"
import { organisation, admin, licensing } from "stores/portal" import { organisation, admin, licensing } from "stores/portal"
import Scim from "./scim.svelte" import Scim from "./scim.svelte"
import Google from "./google.svelte"
const ConfigTypes = { const ConfigTypes = {
Google: "google",
OIDC: "oidc", OIDC: "oidc",
} }
@ -38,43 +37,6 @@
$: enforcedSSO = $organisation.isSSOEnforced $: enforcedSSO = $organisation.isSSOEnforced
// Some older google configs contain a manually specified value - retain the functionality to edit the field
// When there is no value or we are in the cloud - prohibit editing the field, must use platform url to change
$: googleCallbackUrl = undefined
$: googleCallbackReadonly = $admin.cloud || !googleCallbackUrl
// Indicate to user that callback is based on platform url
// If there is an existing value, indicate that it may be removed to return to default behaviour
$: googleCallbackTooltip = $admin.cloud
? null
: googleCallbackReadonly
? "Visit the organisation page to update the platform URL"
: "Leave blank to use the default callback URL"
$: googleSheetsCallbackUrl = `${$organisation.platformUrl}/api/global/auth/datasource/google/callback`
$: GoogleConfigFields = {
Google: [
{ name: "clientID", label: "Client ID" },
{ name: "clientSecret", label: "Client secret" },
{
name: "callbackURL",
label: "Callback URL",
readonly: googleCallbackReadonly,
tooltip: googleCallbackTooltip,
placeholder: $organisation.googleCallbackUrl,
copyButton: true,
},
{
name: "sheetsURL",
label: "Sheets URL",
readonly: googleCallbackReadonly,
tooltip: googleCallbackTooltip,
placeholder: googleSheetsCallbackUrl,
copyButton: true,
},
],
}
$: OIDCConfigFields = { $: OIDCConfigFields = {
Oidc: [ Oidc: [
{ name: "configUrl", label: "Config URL" }, { name: "configUrl", label: "Config URL" },
@ -133,15 +95,9 @@
const providers = { google, oidc } const providers = { google, oidc }
// control the state of the save button depending on whether form has changed // control the state of the save button depending on whether form has changed
let originalGoogleDoc
let originalOidcDoc let originalOidcDoc
let googleSaveButtonDisabled
let oidcSaveButtonDisabled let oidcSaveButtonDisabled
$: { $: {
isEqual(providers.google?.config, originalGoogleDoc?.config)
? (googleSaveButtonDisabled = true)
: (googleSaveButtonDisabled = false)
// delete the callback url which is never saved to the oidc // delete the callback url which is never saved to the oidc
// config doc, to ensure an accurate comparison // config doc, to ensure an accurate comparison
delete providers.oidc?.config.configs[0].callbackURL delete providers.oidc?.config.configs[0].callbackURL
@ -151,10 +107,6 @@
: (oidcSaveButtonDisabled = false) : (oidcSaveButtonDisabled = false)
} }
$: googleComplete = !!(
providers.google?.config?.clientID && providers.google?.config?.clientSecret
)
$: oidcComplete = !!( $: oidcComplete = !!(
providers.oidc?.config?.configs[0].configUrl && providers.oidc?.config?.configs[0].configUrl &&
providers.oidc?.config?.configs[0].clientID && providers.oidc?.config?.configs[0].clientID &&
@ -230,30 +182,6 @@
originalOidcDoc = cloneDeep(providers.oidc) originalOidcDoc = cloneDeep(providers.oidc)
} }
async function saveGoogle() {
if (!googleComplete) {
notifications.error(
`Please fill in all required ${ConfigTypes.Google} fields`
)
return
}
const google = providers.google
try {
const res = await saveConfig(google)
providers[res.type]._rev = res._rev
providers[res.type]._id = res._id
notifications.success(`Settings saved`)
} catch (e) {
notifications.error(e.message)
return
}
googleSaveButtonDisabled = true
originalGoogleDoc = cloneDeep(providers.google)
}
let defaultScopes = ["profile", "email", "offline_access"] let defaultScopes = ["profile", "email", "offline_access"]
const refreshScopes = idx => { const refreshScopes = idx => {
@ -281,29 +209,6 @@
notifications.error("Error getting org config") notifications.error("Error getting org config")
} }
// Fetch Google config
let googleDoc
try {
googleDoc = await API.getConfig(ConfigTypes.Google)
} catch (error) {
notifications.error("Error fetching Google OAuth config")
}
if (!googleDoc?._id) {
providers.google = {
type: ConfigTypes.Google,
config: { activated: false },
}
originalGoogleDoc = cloneDeep(googleDoc)
} else {
// Default activated to true for older configs
if (googleDoc.config.activated === undefined) {
googleDoc.config.activated = true
}
originalGoogleDoc = cloneDeep(googleDoc)
providers.google = googleDoc
}
googleCallbackUrl = providers?.google?.config?.callbackURL
// Get the list of user uploaded logos and push it to the dropdown options. // Get the list of user uploaded logos and push it to the dropdown options.
// This needs to be done before the config call so they're available when // This needs to be done before the config call so they're available when
// the dropdown renders. // the dropdown renders.
@ -395,62 +300,7 @@
> before enabling this feature. > before enabling this feature.
</Body> </Body>
</Layout> </Layout>
{#if providers.google} <Google />
<Divider />
<Layout gap="XS" noPadding>
<Heading size="S">
<div class="provider-title">
<GoogleLogo />
<span>Google</span>
</div>
</Heading>
<Body size="S">
To allow users to authenticate using their Google accounts, fill out the
fields below. Read the <Link
size="M"
href={"https://docs.budibase.com/docs/sso-with-google"}
>documentation</Link
> for more information.
</Body>
</Layout>
<Layout gap="XS" noPadding>
{#each GoogleConfigFields.Google as field}
<div class="form-row">
<Label size="L" tooltip={field.tooltip}>{field.label}</Label>
<div class="inputContainer">
<div class="input">
<Input
bind:value={providers.google.config[field.name]}
readonly={field.readonly}
placeholder={field.placeholder}
/>
</div>
{#if field.copyButton}
<div
class="copy"
on:click={() => copyToClipboard(field.placeholder)}
>
<Icon size="S" name="Copy" />
</div>
{/if}
</div>
</div>
{/each}
<div class="form-row">
<Label size="L">Activated</Label>
<Toggle text="" bind:value={providers.google.config.activated} />
</div>
</Layout>
<div>
<Button
disabled={googleSaveButtonDisabled}
cta
on:click={() => saveGoogle()}
>
Save
</Button>
</div>
{/if}
{#if providers.oidc} {#if providers.oidc}
<Divider /> <Divider />
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>

@ -1 +1 @@
Subproject commit cd06642b860111aa1bd3443ee10076ca3abf03c3 Subproject commit 01fbc8670021c5a275c2a1a36ee18b984eeafad5

View File

@ -14,7 +14,7 @@ ENV SERVICE=app-service
ENV POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU ENV POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU
ENV TENANT_FEATURE_FLAGS=*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR ENV TENANT_FEATURE_FLAGS=*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR
ENV ACCOUNT_PORTAL_URL=https://account.budibase.app ENV ACCOUNT_PORTAL_URL=https://account.budibase.app
ENV TOP_LEVEL_PATH=/
# handle node-gyp # handle node-gyp
RUN apt-get update \ RUN apt-get update \
@ -27,7 +27,8 @@ COPY scripts/integrations/oracle/ scripts/integrations/oracle/
RUN /bin/bash -e scripts/integrations/oracle/instantclient/linux/x86-64/install.sh RUN /bin/bash -e scripts/integrations/oracle/instantclient/linux/x86-64/install.sh
COPY package.json . COPY package.json .
RUN yarn install --frozen-lockfile --production=true COPY dist/yarn.lock .
RUN yarn install --production=true
# Remove unneeded data from file system to reduce image size # Remove unneeded data from file system to reduce image size
RUN yarn cache clean && apt-get remove -y --purge --auto-remove g++ make python \ RUN yarn cache clean && apt-get remove -y --purge --auto-remove g++ make python \
&& rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp && rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp

View File

@ -18,7 +18,7 @@
"test": "bash scripts/test.sh", "test": "bash scripts/test.sh",
"test:memory": "jest --maxWorkers=2 --logHeapUsage --forceExit", "test:memory": "jest --maxWorkers=2 --logHeapUsage --forceExit",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"predocker": "copyfiles -f ../client/dist/budibase-client.js ../client/manifest.json client && yarn build --configuration=production", "predocker": "copyfiles -f ../client/dist/budibase-client.js ../client/manifest.json client && yarn build && cp ../../yarn.lock ./dist/",
"build:docker": "yarn predocker && docker build . -t app-service --label version=$BUDIBASE_RELEASE_VERSION", "build:docker": "yarn predocker && docker build . -t app-service --label version=$BUDIBASE_RELEASE_VERSION",
"build:docs": "node ./scripts/docs/generate.js open", "build:docs": "node ./scripts/docs/generate.js open",
"run:docker": "node dist/index.js", "run:docker": "node dist/index.js",

View File

@ -11,7 +11,7 @@ import { BuildSchemaErrors, InvalidColumns } from "../../constants"
import { getIntegration } from "../../integrations" import { getIntegration } from "../../integrations"
import { getDatasourceAndQuery } from "./row/utils" import { getDatasourceAndQuery } from "./row/utils"
import { invalidateDynamicVariables } from "../../threads/utils" import { invalidateDynamicVariables } from "../../threads/utils"
import { db as dbCore, context, events } from "@budibase/backend-core" import { db as dbCore, context, events, cache } from "@budibase/backend-core"
import { import {
UserCtx, UserCtx,
Datasource, Datasource,
@ -25,9 +25,11 @@ import {
FetchDatasourceInfoResponse, FetchDatasourceInfoResponse,
IntegrationBase, IntegrationBase,
DatasourcePlus, DatasourcePlus,
SourceName,
} from "@budibase/types" } from "@budibase/types"
import sdk from "../../sdk" import sdk from "../../sdk"
import { builderSocket } from "../../websockets" import { builderSocket } from "../../websockets"
import { setupCreationAuth as googleSetupCreationAuth } from "../../integrations/googlesheets"
function getErrorTables(errors: any, errorType: string) { function getErrorTables(errors: any, errorType: string) {
return Object.entries(errors) return Object.entries(errors)
@ -306,6 +308,12 @@ export async function update(ctx: UserCtx<any, UpdateDatasourceResponse>) {
builderSocket?.emitDatasourceUpdate(ctx, datasource) builderSocket?.emitDatasourceUpdate(ctx, datasource)
} }
const preSaveAction: Partial<Record<SourceName, any>> = {
[SourceName.GOOGLE_SHEETS]: async (datasource: Datasource) => {
await googleSetupCreationAuth(datasource.config as any)
},
}
export async function save( export async function save(
ctx: UserCtx<CreateDatasourceRequest, CreateDatasourceResponse> ctx: UserCtx<CreateDatasourceRequest, CreateDatasourceResponse>
) { ) {
@ -327,6 +335,10 @@ export async function save(
setDefaultDisplayColumns(datasource) setDefaultDisplayColumns(datasource)
} }
if (preSaveAction[datasource.source]) {
await preSaveAction[datasource.source](datasource)
}
const dbResp = await db.put(datasource) const dbResp = await db.put(datasource)
await events.datasource.created(datasource) await events.datasource.created(datasource)
datasource._rev = dbResp.rev datasource._rev = dbResp.rev

View File

@ -1,6 +1,6 @@
require("svelte/register") require("svelte/register")
import { resolve, join } from "../../../utilities/centralPath" import { join } from "../../../utilities/centralPath"
const uuid = require("uuid") const uuid = require("uuid")
import { ObjectStoreBuckets } from "../../../constants" import { ObjectStoreBuckets } from "../../../constants"
import { processString } from "@budibase/string-templates" import { processString } from "@budibase/string-templates"
@ -49,7 +49,7 @@ export const toggleBetaUiFeature = async function (ctx: any) {
return return
} }
let builderPath = resolve(TOP_LEVEL_PATH, "new_design_ui") let builderPath = join(TOP_LEVEL_PATH, "new_design_ui")
// // download it from S3 // // download it from S3
if (!fs.existsSync(builderPath)) { if (!fs.existsSync(builderPath)) {
@ -67,7 +67,7 @@ export const toggleBetaUiFeature = async function (ctx: any) {
} }
export const serveBuilder = async function (ctx: any) { export const serveBuilder = async function (ctx: any) {
const builderPath = resolve(TOP_LEVEL_PATH, "builder") const builderPath = join(TOP_LEVEL_PATH, "builder")
await send(ctx, ctx.file, { root: builderPath }) await send(ctx, ctx.file, { root: builderPath })
} }

View File

@ -208,6 +208,7 @@ export async function save(ctx: UserCtx) {
let tableToSave: TableRequest = { let tableToSave: TableRequest = {
type: "table", type: "table",
_id: buildExternalTableId(datasourceId, inputs.name), _id: buildExternalTableId(datasourceId, inputs.name),
sourceId: datasourceId,
...inputs, ...inputs,
} }

View File

@ -14,6 +14,7 @@ import {
SearchFilters, SearchFilters,
Table, Table,
} from "@budibase/types" } from "@budibase/types"
import { db as dbCore } from "@budibase/backend-core"
enum SortOrder { enum SortOrder {
ASCENDING = "ascending", ASCENDING = "ascending",
@ -121,7 +122,11 @@ function typeCoercion(filters: SearchFilters, table: Table) {
const searchParam = filters[key] const searchParam = filters[key]
if (typeof searchParam === "object") { if (typeof searchParam === "object") {
for (let [property, value] of Object.entries(searchParam)) { for (let [property, value] of Object.entries(searchParam)) {
const column = table.schema[property] // We need to strip numerical prefixes here, so that we can look up
// the correct field name in the schema
const columnName = dbCore.removeKeyNumbering(property)
const column = table.schema[columnName]
// convert string inputs // convert string inputs
if (!column || typeof value !== "string") { if (!column || typeof value !== "string") {
continue continue

View File

@ -48,6 +48,35 @@ export const definition: AutomationStepSchema = {
type: AutomationIOType.STRING, type: AutomationIOType.STRING,
title: "HTML Contents", title: "HTML Contents",
}, },
addInvite: {
type: AutomationIOType.BOOLEAN,
title: "Add calendar invite",
},
startTime: {
type: AutomationIOType.DATE,
title: "Start Time",
dependsOn: "addInvite",
},
endTime: {
type: AutomationIOType.DATE,
title: "End Time",
dependsOn: "addInvite",
},
summary: {
type: AutomationIOType.STRING,
title: "Meeting Summary",
dependsOn: "addInvite",
},
location: {
type: AutomationIOType.STRING,
title: "Location",
dependsOn: "addInvite",
},
url: {
type: AutomationIOType.STRING,
title: "URL",
dependsOn: "addInvite",
},
}, },
required: ["to", "from", "subject", "contents"], required: ["to", "from", "subject", "contents"],
}, },
@ -68,21 +97,43 @@ export const definition: AutomationStepSchema = {
} }
export async function run({ inputs }: AutomationStepInput) { export async function run({ inputs }: AutomationStepInput) {
let { to, from, subject, contents, cc, bcc } = inputs let {
to,
from,
subject,
contents,
cc,
bcc,
addInvite,
startTime,
endTime,
summary,
location,
url,
} = inputs
if (!contents) { if (!contents) {
contents = "<h1>No content</h1>" contents = "<h1>No content</h1>"
} }
to = to || undefined to = to || undefined
try { try {
let response = await sendSmtpEmail( let response = await sendSmtpEmail({
to, to,
from, from,
subject, subject,
contents, contents,
cc, cc,
bcc, bcc,
true automation: true,
) invite: addInvite
? {
startTime,
endTime,
summary,
location,
url,
}
: undefined,
})
return { return {
success: true, success: true,
response, response,

View File

@ -1,71 +0,0 @@
function generateResponse(to, from) {
return {
"success": true,
"response": {
"accepted": [
to
],
"envelope": {
"from": from,
"to": [
to
]
},
"message": `Email sent to ${to}.`
}
}
}
const mockFetch = jest.fn(() => ({
headers: {
raw: () => {
return { "content-type": ["application/json"] }
},
get: () => ["application/json"],
},
json: jest.fn(() => response),
status: 200,
text: jest.fn(),
}))
jest.mock("node-fetch", () => mockFetch)
const setup = require("./utilities")
describe("test the outgoing webhook action", () => {
let inputs
let config = setup.getConfig()
beforeAll(async () => {
await config.init()
})
afterAll(setup.afterAll)
it("should be able to run the action", async () => {
inputs = {
to: "user1@test.com",
from: "admin@test.com",
subject: "hello",
contents: "testing",
}
let resp = generateResponse(inputs.to, inputs.from)
mockFetch.mockImplementationOnce(() => ({
headers: {
raw: () => {
return { "content-type": ["application/json"] }
},
get: () => ["application/json"],
},
json: jest.fn(() => resp),
status: 200,
text: jest.fn(),
}))
const res = await setup.runStep(setup.actions.SEND_EMAIL_SMTP.stepId, inputs)
expect(res.response).toEqual(resp)
expect(res.success).toEqual(true)
})
})

View File

@ -0,0 +1,74 @@
import * as workerRequests from "../../utilities/workerRequests"
jest.mock("../../utilities/workerRequests", () => ({
sendSmtpEmail: jest.fn(),
}))
function generateResponse(to: string, from: string) {
return {
success: true,
response: {
accepted: [to],
envelope: {
from: from,
to: [to],
},
message: `Email sent to ${to}.`,
},
}
}
const setup = require("./utilities")
describe("test the outgoing webhook action", () => {
let inputs
let config = setup.getConfig()
beforeAll(async () => {
await config.init()
})
afterAll(setup.afterAll)
it("should be able to run the action", async () => {
jest
.spyOn(workerRequests, "sendSmtpEmail")
.mockImplementationOnce(async () =>
generateResponse("user1@test.com", "admin@test.com")
)
const invite = {
startTime: new Date(),
endTime: new Date(),
summary: "summary",
location: "location",
url: "url",
}
inputs = {
to: "user1@test.com",
from: "admin@test.com",
subject: "hello",
contents: "testing",
cc: "cc",
bcc: "bcc",
addInvite: true,
...invite,
}
let resp = generateResponse(inputs.to, inputs.from)
const res = await setup.runStep(
setup.actions.SEND_EMAIL_SMTP.stepId,
inputs
)
expect(res.response).toEqual(resp)
expect(res.success).toEqual(true)
expect(workerRequests.sendSmtpEmail).toHaveBeenCalledTimes(1)
expect(workerRequests.sendSmtpEmail).toHaveBeenCalledWith({
to: "user1@test.com",
from: "admin@test.com",
subject: "hello",
contents: "testing",
cc: "cc",
bcc: "bcc",
invite,
automation: true,
})
})
})

View File

@ -98,7 +98,8 @@ const environment = {
isInThread: () => { isInThread: () => {
return process.env.FORKED_PROCESS return process.env.FORKED_PROCESS
}, },
TOP_LEVEL_PATH: process.env.TOP_LEVEL_PATH, TOP_LEVEL_PATH:
process.env.TOP_LEVEL_PATH || process.env.SERVER_TOP_LEVEL_PATH,
} }
// threading can cause memory issues with node-ts in development // threading can cause memory issues with node-ts in development

View File

@ -1,5 +1,6 @@
import { import {
ConnectionInfo, ConnectionInfo,
Datasource,
DatasourceFeature, DatasourceFeature,
DatasourceFieldType, DatasourceFieldType,
DatasourcePlus, DatasourcePlus,
@ -19,13 +20,15 @@ import { OAuth2Client } from "google-auth-library"
import { buildExternalTableId, finaliseExternalTables } from "./utils" import { buildExternalTableId, finaliseExternalTables } from "./utils"
import { GoogleSpreadsheet, GoogleSpreadsheetRow } from "google-spreadsheet" import { GoogleSpreadsheet, GoogleSpreadsheetRow } from "google-spreadsheet"
import fetch from "node-fetch" import fetch from "node-fetch"
import { configs, HTTPError } from "@budibase/backend-core" import { cache, configs, context, HTTPError } from "@budibase/backend-core"
import { dataFilters } from "@budibase/shared-core" import { dataFilters } from "@budibase/shared-core"
import { GOOGLE_SHEETS_PRIMARY_KEY } from "../constants" import { GOOGLE_SHEETS_PRIMARY_KEY } from "../constants"
import sdk from "../sdk"
interface GoogleSheetsConfig { interface GoogleSheetsConfig {
spreadsheetId: string spreadsheetId: string
auth: OAuthClientConfig auth: OAuthClientConfig
continueSetupId?: string
} }
interface OAuthClientConfig { interface OAuthClientConfig {
@ -72,7 +75,7 @@ const SCHEMA: Integration = {
}, },
datasource: { datasource: {
spreadsheetId: { spreadsheetId: {
display: "Google Sheet URL", display: "Spreadsheet URL",
type: DatasourceFieldType.STRING, type: DatasourceFieldType.STRING,
required: true, required: true,
}, },
@ -147,6 +150,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
async testConnection(): Promise<ConnectionInfo> { async testConnection(): Promise<ConnectionInfo> {
try { try {
await setupCreationAuth(this.config)
await this.connect() await this.connect()
return { connected: true } return { connected: true }
} catch (e: any) { } catch (e: any) {
@ -566,6 +570,18 @@ class GoogleSheetsIntegration implements DatasourcePlus {
} }
} }
export async function setupCreationAuth(datasouce: GoogleSheetsConfig) {
if (datasouce.continueSetupId) {
const appId = context.getAppId()
const tokens = await cache.get(
`datasource:creation:${appId}:google:${datasouce.continueSetupId}`
)
datasouce.auth = tokens.tokens
delete datasouce.continueSetupId
}
}
export default { export default {
schema: SCHEMA, schema: SCHEMA,
integration: GoogleSheetsIntegration, integration: GoogleSheetsIntegration,

View File

@ -38,6 +38,9 @@ const SCHEMA: Integration = {
type: "password", type: "password",
required: true, required: true,
}, },
role: {
type: "string",
},
warehouse: { warehouse: {
type: "string", type: "string",
required: true, required: true,

View File

@ -7,7 +7,7 @@ import {
InternalTables, InternalTables,
} from "../../db/utils" } from "../../db/utils"
import { isEqual } from "lodash" import { isEqual } from "lodash"
import { ContextUser, UserMetadata, User } from "@budibase/types" import { ContextUser, UserMetadata, User, Database } from "@budibase/types"
export function combineMetadataAndUser( export function combineMetadataAndUser(
user: ContextUser, user: ContextUser,
@ -51,8 +51,10 @@ export function combineMetadataAndUser(
return null return null
} }
export async function rawUserMetadata() { export async function rawUserMetadata(db?: Database) {
const db = context.getAppDB() if (!db) {
db = context.getAppDB()
}
return ( return (
await db.allDocs( await db.allDocs(
getUserMetadataParams(null, { getUserMetadataParams(null, {
@ -64,30 +66,36 @@ export async function rawUserMetadata() {
export async function syncGlobalUsers() { export async function syncGlobalUsers() {
// sync user metadata // sync user metadata
const db = context.getAppDB() const dbs = [context.getDevAppDB(), context.getProdAppDB()]
const resp = await Promise.all([getGlobalUsers(), rawUserMetadata()]) for (let db of dbs) {
const users = resp[0] as User[] if (!(await db.exists())) {
const metadata = resp[1] as UserMetadata[]
const toWrite = []
for (let user of users) {
const combined = combineMetadataAndUser(user, metadata)
if (combined) {
toWrite.push(combined)
}
}
let foundEmails: string[] = []
for (let data of metadata) {
if (!data._id) {
continue continue
} }
const alreadyExisting = data.email && foundEmails.indexOf(data.email) !== -1 const resp = await Promise.all([getGlobalUsers(), rawUserMetadata(db)])
const globalId = getGlobalIDFromUserMetadataID(data._id) const users = resp[0] as User[]
if (!users.find(user => user._id === globalId) || alreadyExisting) { const metadata = resp[1] as UserMetadata[]
toWrite.push({ ...data, _deleted: true }) const toWrite = []
for (let user of users) {
const combined = combineMetadataAndUser(user, metadata)
if (combined) {
toWrite.push(combined)
}
} }
if (data.email) { let foundEmails: string[] = []
foundEmails.push(data.email) for (let data of metadata) {
if (!data._id) {
continue
}
const alreadyExisting =
data.email && foundEmails.indexOf(data.email) !== -1
const globalId = getGlobalIDFromUserMetadataID(data._id)
if (!users.find(user => user._id === globalId) || alreadyExisting) {
toWrite.push({ ...data, _deleted: true })
}
if (data.email) {
foundEmails.push(data.email)
}
} }
await db.bulkDocs(toWrite)
} }
await db.bulkDocs(toWrite)
} }

View File

@ -1,14 +1,14 @@
import { PathLike } from "fs" import { PathLike } from "fs"
import fs from "fs" import fs from "fs"
import { budibaseTempDir } from "../budibaseDir" import { budibaseTempDir } from "../budibaseDir"
import { join } from "path" import { resolve, join } from "path"
import env from "../../environment" import env from "../../environment"
import tar from "tar" import tar from "tar"
import environment from "../../environment" import environment from "../../environment"
const uuid = require("uuid/v4") const uuid = require("uuid/v4")
export const TOP_LEVEL_PATH = export const TOP_LEVEL_PATH =
environment.TOP_LEVEL_PATH || join(__dirname, "..", "..", "..") environment.TOP_LEVEL_PATH || resolve(join(__dirname, "..", "..", ".."))
/** /**
* Upon first startup of instance there may not be everything we need in tmp directory, set it up. * Upon first startup of instance there may not be everything we need in tmp directory, set it up.

View File

@ -122,11 +122,8 @@ export async function getGlobalUsers(
delete user.forceResetPassword delete user.forceResetPassword
return user return user
}) })
if (!appId) {
return globalUsers
}
if (opts?.noProcessing) { if (opts?.noProcessing || !appId) {
return globalUsers return globalUsers
} else { } else {
// pass in the groups, meaning we don't actually need to retrieve them for // pass in the groups, meaning we don't actually need to retrieve them for

View File

@ -9,7 +9,7 @@ import {
env as coreEnv, env as coreEnv,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { updateAppRole } from "./global" import { updateAppRole } from "./global"
import { BBContext, User } from "@budibase/types" import { BBContext, User, EmailInvite } from "@budibase/types"
export function request(ctx?: BBContext, request?: any) { export function request(ctx?: BBContext, request?: any) {
if (!request.headers) { if (!request.headers) {
@ -65,15 +65,25 @@ async function checkResponse(
} }
// have to pass in the tenant ID as this could be coming from an automation // have to pass in the tenant ID as this could be coming from an automation
export async function sendSmtpEmail( export async function sendSmtpEmail({
to: string, to,
from: string, from,
subject: string, subject,
contents: string, contents,
cc: string, cc,
bcc: string, bcc,
automation,
invite,
}: {
to: string
from: string
subject: string
contents: string
cc: string
bcc: string
automation: boolean automation: boolean
) { invite?: EmailInvite
}) {
// tenant ID will be set in header // tenant ID will be set in header
const response = await fetch( const response = await fetch(
checkSlashesInUrl(env.WORKER_URL + `/api/global/email/send`), checkSlashesInUrl(env.WORKER_URL + `/api/global/email/send`),
@ -88,6 +98,7 @@ export async function sendSmtpEmail(
bcc, bcc,
purpose: "custom", purpose: "custom",
automation, automation,
invite,
}, },
}) })
) )

View File

@ -1,6 +1,6 @@
import authorized from "../middleware/authorized" import authorized from "../middleware/authorized"
import { BaseSocket } from "./websocket" import { BaseSocket } from "./websocket"
import { permissions } from "@budibase/backend-core" import { permissions, events } from "@budibase/backend-core"
import http from "http" import http from "http"
import Koa from "koa" import Koa from "koa"
import { Datasource, Table, SocketSession, ContextUser } from "@budibase/types" import { Datasource, Table, SocketSession, ContextUser } from "@budibase/types"
@ -22,6 +22,9 @@ export default class BuilderSocket extends BaseSocket {
// Reply with all users in current room // Reply with all users in current room
const sessions = await this.getRoomSessions(appId) const sessions = await this.getRoomSessions(appId)
callback({ users: sessions }) callback({ users: sessions })
// Track usage
await events.user.dataCollaboration(sessions.length)
}) })
} }

View File

@ -1,5 +1,6 @@
import { Document } from "../document" import { Document } from "../document"
import { EventEmitter } from "events" import { EventEmitter } from "events"
import { User } from "../global"
export enum AutomationIOType { export enum AutomationIOType {
OBJECT = "object", OBJECT = "object",
@ -8,6 +9,7 @@ export enum AutomationIOType {
NUMBER = "number", NUMBER = "number",
ARRAY = "array", ARRAY = "array",
JSON = "json", JSON = "json",
DATE = "date",
} }
export enum AutomationCustomIOType { export enum AutomationCustomIOType {
@ -66,6 +68,33 @@ export enum AutomationActionStepId {
integromat = "integromat", integromat = "integromat",
} }
export interface EmailInvite {
startTime: Date
endTime: Date
summary: string
location?: string
url?: string
}
export interface SendEmailOpts {
// workspaceId If finer grain controls being used then this will lookup config for workspace.
workspaceId?: string
// user If sending to an existing user the object can be provided, this is used in the context.
user: User
// from If sending from an address that is not what is configured in the SMTP config.
from?: string
// contents If sending a custom email then can supply contents which will be added to it.
contents?: string
// subject A custom subject can be specified if the config one is not desired.
subject?: string
// info Pass in a structure of information to be stored alongside the invitation.
info?: any
cc?: boolean
bcc?: boolean
automation?: boolean
invite?: EmailInvite
}
export const AutomationStepIdArray = [ export const AutomationStepIdArray = [
...Object.values(AutomationActionStepId), ...Object.values(AutomationActionStepId),
...Object.values(AutomationTriggerStepId), ...Object.values(AutomationTriggerStepId),
@ -90,6 +119,7 @@ interface BaseIOStructure {
customType?: AutomationCustomIOType customType?: AutomationCustomIOType
title?: string title?: string
description?: string description?: string
dependsOn?: string
enum?: string[] enum?: string[]
pretty?: string[] pretty?: string[]
properties?: { properties?: {

View File

@ -26,6 +26,9 @@ export enum Event {
USER_PASSWORD_RESET_REQUESTED = "user:password:reset:requested", USER_PASSWORD_RESET_REQUESTED = "user:password:reset:requested",
USER_PASSWORD_RESET = "user:password:reset", USER_PASSWORD_RESET = "user:password:reset",
// USER / COLLABORATION
USER_DATA_COLLABORATION = "user:data:collaboration",
// EMAIL // EMAIL
EMAIL_SMTP_CREATED = "email:smtp:created", EMAIL_SMTP_CREATED = "email:smtp:created",
EMAIL_SMTP_UPDATED = "email:smtp:updated", EMAIL_SMTP_UPDATED = "email:smtp:updated",
@ -233,6 +236,7 @@ export const AuditedEventFriendlyName: Record<Event, string | undefined> = {
[Event.USER_PASSWORD_FORCE_RESET]: undefined, [Event.USER_PASSWORD_FORCE_RESET]: undefined,
[Event.USER_GROUP_ONBOARDING]: undefined, [Event.USER_GROUP_ONBOARDING]: undefined,
[Event.USER_ONBOARDING_COMPLETE]: undefined, [Event.USER_ONBOARDING_COMPLETE]: undefined,
[Event.USER_DATA_COLLABORATION]: undefined,
// EMAIL // EMAIL
[Event.EMAIL_SMTP_CREATED]: `Email configuration created`, [Event.EMAIL_SMTP_CREATED]: `Email configuration created`,

View File

@ -86,3 +86,7 @@ export interface UserPasswordResetEvent extends BaseEvent {
email: string email: string
} }
} }
export interface UserDataCollaborationEvent extends BaseEvent {
users: number
}

View File

@ -13,7 +13,8 @@ RUN yarn global add pm2
COPY package.json . COPY package.json .
RUN yarn install --frozen-lockfile --production=true COPY dist/yarn.lock .
RUN yarn install --production=true
# Remove unneeded data from file system to reduce image size # Remove unneeded data from file system to reduce image size
RUN apk del .gyp \ RUN apk del .gyp \
&& yarn cache clean && yarn cache clean

View File

@ -19,7 +19,7 @@
"run:docker": "node dist/index.js", "run:docker": "node dist/index.js",
"debug": "yarn build && node --expose-gc --inspect=9223 dist/index.js", "debug": "yarn build && node --expose-gc --inspect=9223 dist/index.js",
"run:docker:cluster": "pm2-runtime start pm2.config.js", "run:docker:cluster": "pm2-runtime start pm2.config.js",
"predocker": "yarn build --configuration=production", "predocker": "yarn build && cp ../../yarn.lock ./dist/",
"build:docker": "yarn predocker && docker build . -t worker-service --label version=$BUDIBASE_RELEASE_VERSION", "build:docker": "yarn predocker && docker build . -t worker-service --label version=$BUDIBASE_RELEASE_VERSION",
"dev:stack:init": "node ./scripts/dev/manage.js init", "dev:stack:init": "node ./scripts/dev/manage.js init",
"dev:builder": "npm run dev:stack:init && rimraf dist/ && nodemon", "dev:builder": "npm run dev:stack:init && rimraf dist/ && nodemon",
@ -53,6 +53,7 @@
"elastic-apm-node": "3.38.0", "elastic-apm-node": "3.38.0",
"global-agent": "3.0.0", "global-agent": "3.0.0",
"got": "11.8.3", "got": "11.8.3",
"ical-generator": "4.1.0",
"joi": "17.6.0", "joi": "17.6.0",
"koa": "2.13.4", "koa": "2.13.4",
"koa-body": "4.2.0", "koa-body": "4.2.0",

View File

@ -140,7 +140,6 @@ export const datasourcePreAuth = async (ctx: any, next: any) => {
{ {
provider, provider,
appId: ctx.query.appId, appId: ctx.query.appId,
datasourceId: ctx.query.datasourceId,
}, },
Cookie.DatasourceAuth Cookie.DatasourceAuth
) )

View File

@ -14,6 +14,7 @@ export async function sendEmail(ctx: BBContext) {
cc, cc,
bcc, bcc,
automation, automation,
invite,
} = ctx.request.body } = ctx.request.body
let user let user
if (userId) { if (userId) {
@ -29,6 +30,7 @@ export async function sendEmail(ctx: BBContext) {
cc, cc,
bcc, bcc,
automation, automation,
invite,
}) })
ctx.body = { ctx.body = {
...response, ...response,

View File

@ -4,28 +4,11 @@ import { getTemplateByPurpose, EmailTemplates } from "../constants/templates"
import { getSettingsTemplateContext } from "./templates" import { getSettingsTemplateContext } from "./templates"
import { processString } from "@budibase/string-templates" import { processString } from "@budibase/string-templates"
import { getResetPasswordCode, getInviteCode } from "./redis" import { getResetPasswordCode, getInviteCode } from "./redis"
import { User, SMTPInnerConfig } from "@budibase/types" import { User, SendEmailOpts, SMTPInnerConfig } from "@budibase/types"
import { configs } from "@budibase/backend-core" import { configs } from "@budibase/backend-core"
import ical from "ical-generator"
const nodemailer = require("nodemailer") const nodemailer = require("nodemailer")
type SendEmailOpts = {
// workspaceId If finer grain controls being used then this will lookup config for workspace.
workspaceId?: string
// user If sending to an existing user the object can be provided, this is used in the context.
user: User
// from If sending from an address that is not what is configured in the SMTP config.
from?: string
// contents If sending a custom email then can supply contents which will be added to it.
contents?: string
// subject A custom subject can be specified if the config one is not desired.
subject?: string
// info Pass in a structure of information to be stored alongside the invitation.
info?: any
cc?: boolean
bcc?: boolean
automation?: boolean
}
const TEST_MODE = env.ENABLE_EMAIL_TEST_MODE && env.isDev() const TEST_MODE = env.ENABLE_EMAIL_TEST_MODE && env.isDev()
const TYPE = TemplateType.EMAIL const TYPE = TemplateType.EMAIL
@ -200,6 +183,26 @@ export async function sendEmail(
context context
) )
} }
if (opts?.invite) {
const calendar = ical({
name: "Invite",
})
calendar.createEvent({
start: opts.invite.startTime,
end: opts.invite.endTime,
summary: opts.invite.summary,
location: opts.invite.location,
url: opts.invite.url,
})
message = {
...message,
icalEvent: {
method: "request",
content: calendar.toString(),
},
}
}
const response = await transport.sendMail(message) const response = await transport.sendMail(message)
if (TEST_MODE) { if (TEST_MODE) {
console.log("Test email URL: " + nodemailer.getTestMessageUrl(response)) console.log("Test email URL: " + nodemailer.getTestMessageUrl(response))

7
scripts/resetVersions.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/bash
echo "Resetting package versions"
yarn lerna exec "yarn version --no-git-tag-version --new-version=0.0.0"
echo "Updating dependencies"
node scripts/syncLocalDependencies.js "0.0.0"
git checkout package.json
echo "Package versions reset!"

12
scripts/updateVersions.sh Executable file
View File

@ -0,0 +1,12 @@
#!/bin/bash
version=$(cat lerna.json \
| grep version \
| head -1 \
| awk -F: '{gsub(/"/,"",$2);gsub(/[[:space:]]*/,"",$2); print $2}' \
| sed 's/[",]//g')
echo "Setting version $version"
yarn lerna exec "yarn version --no-git-tag-version --new-version=$version"
echo "Updating dependencies"
node scripts/syncLocalDependencies.js $version
echo "Syncing yarn workspace"
yarn

View File

@ -2,7 +2,7 @@
if [ -z "$1" ] if [ -z "$1" ]
then then
echo "Error: version number is required. Usage: $0 [major|minor|patch|prerelease]" echo "Error: bump type is required. Usage: $0 [major|minor|patch|prerelease]"
exit 1 exit 1
fi fi

View File

@ -13668,6 +13668,13 @@ husky@^8.0.3:
resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.3.tgz#4936d7212e46d1dea28fef29bb3a108872cd9184" resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.3.tgz#4936d7212e46d1dea28fef29bb3a108872cd9184"
integrity sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg== integrity sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==
ical-generator@4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/ical-generator/-/ical-generator-4.1.0.tgz#2a336c951864c5583a2aa715d16f2edcdfd2d90b"
integrity sha512-5GrFDJ8SAOj8cB9P1uEZIfKrNxSZ1R2eOQfZePL+CtdWh4RwNXWe8b0goajz+Hu37vcipG3RVldoa2j57Y20IA==
dependencies:
uuid-random "^1.3.2"
iconv-lite@0.4.24, iconv-lite@^0.4.15, iconv-lite@^0.4.24, iconv-lite@^0.4.5: iconv-lite@0.4.24, iconv-lite@^0.4.15, iconv-lite@^0.4.24, iconv-lite@^0.4.5:
version "0.4.24" version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@ -25279,6 +25286,11 @@ utils-merge@1.0.1, utils-merge@1.x.x:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
uuid-random@^1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/uuid-random/-/uuid-random-1.3.2.tgz#96715edbaef4e84b1dcf5024b00d16f30220e2d0"
integrity sha512-UOzej0Le/UgkbWEO8flm+0y+G+ljUon1QWTEZOq1rnMAsxo2+SckbiZdKzAHHlVh6gJqI1TjC/xwgR50MuCrBQ==
uuid@3.3.2: uuid@3.3.2:
version "3.3.2" version "3.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"