1
0
Fork 0
mirror of synced 2024-09-08 13:41:09 +12:00

Merge branch 'develop' of github.com:Budibase/budibase into global-bindings

This commit is contained in:
Andrew Kingston 2023-08-07 13:04:34 +01:00
commit 9ddb8f4c5f
275 changed files with 8334 additions and 3835 deletions

View file

@ -25,13 +25,13 @@ jobs:
steps:
- name: Checkout repo and submodules
uses: actions/checkout@v3
if: github.repository == github.event.pull_request.head.repo.full_name
if: github.repository == 'Budibase/budibase'
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only
uses: actions/checkout@v3
if: github.repository != github.event.pull_request.head.repo.full_name
if: github.repository != 'Budibase/budibase'
- name: Use Node.js 14.x
uses: actions/setup-node@v3
@ -46,13 +46,13 @@ jobs:
steps:
- name: Checkout repo and submodules
uses: actions/checkout@v3
if: github.repository == github.event.pull_request.head.repo.full_name
if: github.repository == 'Budibase/budibase'
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only
uses: actions/checkout@v3
if: github.repository != github.event.pull_request.head.repo.full_name
if: github.repository != 'Budibase/budibase'
- name: Use Node.js 14.x
uses: actions/setup-node@v3
@ -70,13 +70,13 @@ jobs:
steps:
- name: Checkout repo and submodules
uses: actions/checkout@v3
if: github.repository == github.event.pull_request.head.repo.full_name
if: github.repository == 'Budibase/budibase'
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only
uses: actions/checkout@v3
if: github.repository != github.event.pull_request.head.repo.full_name
if: github.repository != 'Budibase/budibase'
- name: Use Node.js 14.x
uses: actions/setup-node@v3
@ -96,13 +96,13 @@ jobs:
steps:
- name: Checkout repo and submodules
uses: actions/checkout@v3
if: github.repository == github.event.pull_request.head.repo.full_name
if: github.repository == 'Budibase/budibase'
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only
uses: actions/checkout@v3
if: github.repository != github.event.pull_request.head.repo.full_name
if: github.repository != 'Budibase/budibase'
- name: Use Node.js 14.x
uses: actions/setup-node@v3
@ -119,7 +119,7 @@ jobs:
test-pro:
runs-on: ubuntu-latest
if: github.repository == github.event.pull_request.head.repo.full_name
if: github.repository == 'Budibase/budibase'
steps:
- name: Checkout repo and submodules
uses: actions/checkout@v3
@ -140,13 +140,13 @@ jobs:
steps:
- name: Checkout repo and submodules
uses: actions/checkout@v3
if: github.repository == github.event.pull_request.head.repo.full_name
if: github.repository == 'Budibase/budibase'
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only
uses: actions/checkout@v3
if: github.repository != github.event.pull_request.head.repo.full_name
if: github.repository != 'Budibase/budibase'
- name: Use Node.js 14.x
uses: actions/setup-node@v3
@ -154,7 +154,7 @@ jobs:
node-version: 14.x
cache: "yarn"
- run: yarn
- run: yarn build
- run: yarn build --projects=@budibase/server,@budibase/worker,@budibase/client
- name: Run tests
run: |
cd qa-core
@ -166,7 +166,7 @@ jobs:
check-pro-submodule:
runs-on: ubuntu-latest
if: github.repository == github.event.pull_request.head.repo.full_name
if: github.repository == 'Budibase/budibase'
steps:
- name: Checkout repo and submodules
uses: actions/checkout@v3
@ -190,6 +190,8 @@ jobs:
base_commit=$(git rev-parse origin/develop)
fi
echo "target_branch=$branch"
echo "target_branch=$branch" >> "$GITHUB_OUTPUT"
echo "pro_commit=$pro_commit"
echo "pro_commit=$pro_commit" >> "$GITHUB_OUTPUT"
echo "base_commit=$base_commit"
@ -204,7 +206,7 @@ jobs:
const baseCommit = '${{ steps.get_pro_commits.outputs.base_commit }}';
if (submoduleCommit !== baseCommit) {
console.error('Submodule commit does not match the latest commit on the develop branch.');
console.error('Submodule commit does not match the latest commit on the "${{ steps.get_pro_commits.outputs.target_branch }}"" branch.');
console.error('Refer to the pro repo to merge your changes: https://github.com/Budibase/budibase-pro/blob/develop/docs/getting_started.md')
process.exit(1);
} else {

View file

@ -0,0 +1,29 @@
name: check_unreleased_changes
on:
pull_request:
branches:
- master
jobs:
check_unreleased:
runs-on: ubuntu-latest
steps:
- name: Check for unreleased changes
env:
REPO: "Budibase/budibase"
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RELEASE_TIMESTAMP=$(curl -s -H "Authorization: token $TOKEN" \
"https://api.github.com/repos/$REPO/releases/latest" | \
jq -r .published_at)
COMMIT_TIMESTAMP=$(curl -s -H "Authorization: token $TOKEN" \
"https://api.github.com/repos/$REPO/commits/master" | \
jq -r .commit.committer.date)
RELEASE_SECONDS=$(date --date="$RELEASE_TIMESTAMP" "+%s")
COMMIT_SECONDS=$(date --date="$COMMIT_TIMESTAMP" "+%s")
if (( COMMIT_SECONDS > RELEASE_SECONDS )); then
echo "There are unreleased changes. Please release these changes before merging."
exit 1
fi
echo "No unreleased changes detected."

View file

@ -1,4 +1,4 @@
name: Deploy Budibase Single Container Image to DockerHub
name: release-singleimage
on:
workflow_dispatch:
@ -8,8 +8,8 @@ env:
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
REGISTRY_URL: registry.hub.docker.com
jobs:
build:
name: "build"
build-amd64:
name: "build-amd64"
runs-on: ubuntu-latest
strategy:
matrix:
@ -27,14 +27,12 @@ jobs:
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 }}
uses: actions/setup-node@v1
with:
@ -70,9 +68,139 @@ jobs:
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: budibase/budibase,budibase/budibase:${{ env.RELEASE_VERSION }}
platforms: linux/amd64
tags: budibase/budibase,budibase/budibase:v${{ env.RELEASE_VERSION }}
file: ./hosting/single/Dockerfile
- name: Tag and release Budibase Azure App Service docker image
uses: docker/build-push-action@v2
with:
context: .
push: true
platforms: linux/amd64
build-args: TARGETBUILD=aas
tags: budibase/budibase-aas,budibase/budibase-aas:v${{ env.RELEASE_VERSION }}
file: ./hosting/single/Dockerfile
build-arm64:
name: "build-arm64"
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x]
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
- name: "Checkout"
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 }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
- name: Setup Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Run Yarn
run: yarn
- name: Update versions
run: ./scripts/updateVersions.sh
- name: Runt Yarn Lint
run: yarn lint
- name: Update versions
run: ./scripts/updateVersions.sh
- name: Run Yarn Build
run: yarn build:docker:pre
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_API_KEY }}
- name: Get the latest release version
id: version
run: |
release_version=$(cat lerna.json | jq -r '.version')
echo $release_version
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- name: Tag and release Budibase service docker image
uses: docker/build-push-action@v2
with:
context: .
push: true
platforms: linux/arm64
tags: budibase/budibase,budibase/budibase:v${{ env.RELEASE_VERSION }}
file: ./hosting/single/Dockerfile
build-aas:
name: "build-aas"
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x]
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
- name: "Checkout"
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 }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
- name: Setup Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Run Yarn
run: yarn
- name: Update versions
run: ./scripts/updateVersions.sh
- name: Runt Yarn Lint
run: yarn lint
- name: Update versions
run: ./scripts/updateVersions.sh
- name: Run Yarn Build
run: yarn build:docker:pre
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_API_KEY }}
- name: Get the latest release version
id: version
run: |
release_version=$(cat lerna.json | jq -r '.version')
echo $release_version
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- name: Tag and release Budibase Azure App Service docker image
uses: docker/build-push-action@v2
with:

2
.gitignore vendored
View file

@ -101,8 +101,6 @@ packages/builder/cypress.env.json
packages/builder/cypress/reports
stats.html
# TypeScript cache
*.tsbuildinfo
# plugins
budibase-component

View file

@ -1,2 +1,3 @@
nodejs 14.21.3
python 3.10.0
python 3.10.0
yarn 1.22.19

View file

@ -201,25 +201,24 @@ spec:
image: budibase/apps:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always
{{- if .Values.services.apps.startupProbe }}
{{- with .Values.services.apps.startupProbe }}
startupProbe:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
{{- if .Values.services.apps.livenessProbe }}
{{- with .Values.services.apps.livenessProbe }}
livenessProbe:
httpGet:
path: /health
port: {{ .Values.services.apps.port }}
initialDelaySeconds: 10
periodSeconds: 5
successThreshold: 1
failureThreshold: 3
timeoutSeconds: 3
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
{{- if .Values.services.apps.readinessProbe }}
{{- with .Values.services.apps.readinessProbe }}
readinessProbe:
httpGet:
path: /health
port: {{ .Values.services.apps.port }}
initialDelaySeconds: 5
periodSeconds: 5
successThreshold: 1
failureThreshold: 3
timeoutSeconds: 3
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
name: bbapps
ports:
- containerPort: {{ .Values.services.apps.port }}

View file

@ -40,24 +40,24 @@ spec:
- image: budibase/proxy:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always
name: proxy-service
{{- if .Values.services.proxy.startupProbe }}
{{- with .Values.services.proxy.startupProbe }}
startupProbe:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
{{- if .Values.services.proxy.livenessProbe }}
{{- with .Values.services.proxy.livenessProbe }}
livenessProbe:
httpGet:
path: /health
port: {{ .Values.services.proxy.port }}
initialDelaySeconds: 0
periodSeconds: 5
successThreshold: 1
failureThreshold: 2
timeoutSeconds: 3
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
{{- if .Values.services.proxy.readinessProbe }}
{{- with .Values.services.proxy.readinessProbe }}
readinessProbe:
httpGet:
path: /health
port: {{ .Values.services.proxy.port }}
initialDelaySeconds: 0
periodSeconds: 5
successThreshold: 1
failureThreshold: 2
timeoutSeconds: 3
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
ports:
- containerPort: {{ .Values.services.proxy.port }}
env:

View file

@ -190,24 +190,24 @@ spec:
{{ end }}
image: budibase/worker:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always
{{- if .Values.services.worker.startupProbe }}
{{- with .Values.services.worker.startupProbe }}
startupProbe:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
{{- if .Values.services.worker.livenessProbe }}
{{- with .Values.services.worker.livenessProbe }}
livenessProbe:
httpGet:
path: /health
port: {{ .Values.services.worker.port }}
initialDelaySeconds: 10
periodSeconds: 5
successThreshold: 1
failureThreshold: 3
timeoutSeconds: 3
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
{{- if .Values.services.worker.readinessProbe }}
{{- with .Values.services.worker.readinessProbe }}
readinessProbe:
httpGet:
path: /health
port: {{ .Values.services.worker.port }}
initialDelaySeconds: 5
periodSeconds: 5
successThreshold: 1
failureThreshold: 3
timeoutSeconds: 3
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
name: bbworker
ports:
- containerPort: {{ .Values.services.worker.port }}

View file

@ -119,15 +119,37 @@ services:
port: 10000
replicaCount: 1
upstreams:
apps: 'http://app-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.apps.port }}'
worker: 'http://worker-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.worker.port }}'
minio: 'http://minio-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.objectStore.port }}'
couchdb: 'http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}'
apps: "http://app-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.apps.port }}"
worker: "http://worker-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.worker.port }}"
minio: "http://minio-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.objectStore.port }}"
couchdb: "http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}"
resources: {}
# annotations:
# co.elastic.logs/module: nginx
# co.elastic.logs/fileset.stdout: access
# co.elastic.logs/fileset.stderr: error
startupProbe:
httpGet:
path: /health
port: 10000
scheme: HTTP
failureThreshold: 30
periodSeconds: 3
readinessProbe:
httpGet:
path: /health
port: 10000
scheme: HTTP
enabled: true
periodSeconds: 3
failureThreshold: 1
livenessProbe:
httpGet:
path: /health
port: 10000
scheme: HTTP
failureThreshold: 3
periodSeconds: 5
# annotations:
# co.elastic.logs/module: nginx
# co.elastic.logs/fileset.stdout: access
# co.elastic.logs/fileset.stderr: error
apps:
port: 4002
@ -135,23 +157,67 @@ services:
logLevel: info
httpLogging: 1
resources: {}
# nodeDebug: "" # set the value of NODE_DEBUG
# annotations:
# co.elastic.logs/multiline.type: pattern
# co.elastic.logs/multiline.pattern: '^[[:space:]]'
# co.elastic.logs/multiline.negate: false
# co.elastic.logs/multiline.match: after
startupProbe:
httpGet:
path: /health
port: 4002
scheme: HTTP
failureThreshold: 30
periodSeconds: 3
readinessProbe:
httpGet:
path: /health
port: 4002
scheme: HTTP
enabled: true
periodSeconds: 3
failureThreshold: 1
livenessProbe:
httpGet:
path: /health
port: 4002
scheme: HTTP
failureThreshold: 3
periodSeconds: 5
# nodeDebug: "" # set the value of NODE_DEBUG
# annotations:
# co.elastic.logs/multiline.type: pattern
# co.elastic.logs/multiline.pattern: '^[[:space:]]'
# co.elastic.logs/multiline.negate: false
# co.elastic.logs/multiline.match: after
worker:
port: 4003
replicaCount: 1
logLevel: info
httpLogging: 1
resources: {}
# annotations:
# co.elastic.logs/multiline.type: pattern
# co.elastic.logs/multiline.pattern: '^[[:space:]]'
# co.elastic.logs/multiline.negate: false
# co.elastic.logs/multiline.match: after
startupProbe:
httpGet:
path: /health
port: 4003
scheme: HTTP
failureThreshold: 30
periodSeconds: 3
readinessProbe:
httpGet:
path: /health
port: 4003
scheme: HTTP
enabled: true
periodSeconds: 3
failureThreshold: 1
livenessProbe:
httpGet:
path: /health
port: 4003
scheme: HTTP
failureThreshold: 3
periodSeconds: 5
# annotations:
# co.elastic.logs/multiline.type: pattern
# co.elastic.logs/multiline.pattern: '^[[:space:]]'
# co.elastic.logs/multiline.negate: false
# co.elastic.logs/multiline.match: after
couchdb:
enabled: true

View file

@ -1,5 +1,5 @@
{
"version": "2.8.22-alpha.3",
"version": "2.8.32-alpha.4",
"npmClient": "yarn",
"packages": [
"packages/*"

13
nx.json
View file

@ -3,19 +3,10 @@
"default": {
"runner": "nx-cloud",
"options": {
"cacheableOperations": ["build", "test"],
"cacheableOperations": ["build", "test", "check:types"],
"accessToken": "MmM4OGYxNzItMDBlYy00ZmE3LTk4MTYtNmJhYWMyZjBjZTUyfHJlYWQ="
}
}
},
"targetDefaults": {
"dev:builder": {
"dependsOn": [
{
"projects": ["@budibase/string-templates"],
"target": "build"
}
]
}
}
"targetDefaults": {}
}

View file

@ -6,8 +6,8 @@
"@nx/js": "16.4.3",
"@rollup/plugin-json": "^4.0.2",
"@typescript-eslint/parser": "5.45.0",
"esbuild": "^0.17.18",
"esbuild-node-externals": "^1.7.0",
"esbuild": "^0.18.17",
"esbuild-node-externals": "^1.8.0",
"eslint": "^8.44.0",
"eslint-plugin-cypress": "^2.11.3",
"husky": "^8.0.3",
@ -36,7 +36,7 @@
"bootstrap": "./scripts/link-dependencies.sh && echo '***BOOTSTRAP ONLY REQUIRED FOR USE WITH ACCOUNT PORTAL***'",
"build": "yarn nx run-many -t=build",
"build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput",
"check:types": "lerna run check:types --skip-nx-cache",
"check:types": "lerna run check:types",
"backend:bootstrap": "./scripts/scopeBackend.sh && yarn run bootstrap",
"backend:build": "./scripts/scopeBackend.sh 'lerna run --stream build'",
"build:sdk": "lerna run --stream build:sdk",
@ -51,7 +51,7 @@
"kill-builder": "kill-port 3000",
"kill-server": "kill-port 4001 4002",
"kill-all": "yarn run kill-builder && yarn run kill-server",
"dev": "yarn run kill-all && lerna run --stream dev:builder --stream",
"dev": "yarn run kill-all && lerna run --stream dev:builder",
"dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && lerna run --stream dev:builder --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker",
"dev:server": "yarn run kill-server && lerna run --stream 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 dev:built",
@ -108,5 +108,8 @@
"@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.0"
},
"engines": {
"node": ">=14.0.0 <15.0.0"
},
"dependencies": {}
}

View file

@ -1,8 +1,6 @@
import { Config } from "@jest/types"
const preset = require("ts-jest/jest-preset")
const baseConfig: Config.InitialProjectOptions = {
...preset,
preset: "@trendyol/jest-testcontainers",
setupFiles: ["./tests/jestEnv.ts"],
setupFilesAfterEnv: ["./tests/jestSetup.ts"],
@ -11,6 +9,7 @@ const baseConfig: Config.InitialProjectOptions = {
},
moduleNameMapper: {
"@budibase/types": "<rootDir>/../types/src",
"@budibase/shared-core": ["<rootDir>/../shared-core/src"],
},
}

View file

@ -16,14 +16,15 @@
"prepack": "cp package.json dist",
"build": "tsc -p tsconfig.build.json",
"build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput",
"check:types": "tsc -p tsconfig.json --noEmit --paths null",
"test": "bash scripts/test.sh",
"test:watch": "jest --watchAll"
},
"dependencies": {
"@budibase/nano": "10.1.2",
"@budibase/pouchdb-replication-stream": "1.2.10",
"@budibase/shared-core": "0.0.0",
"@budibase/types": "0.0.0",
"@shopify/jest-koa-mocks": "5.0.1",
"@techpass/passport-openidconnect": "0.3.2",
"aws-cloudfront-sign": "2.2.0",
"aws-sdk": "2.1030.0",
@ -58,12 +59,13 @@
"uuid": "8.3.2"
},
"devDependencies": {
"@jest/test-sequencer": "29.5.0",
"@swc/core": "^1.3.25",
"@swc/jest": "^0.2.24",
"@jest/test-sequencer": "29.6.2",
"@shopify/jest-koa-mocks": "5.1.1",
"@swc/core": "1.3.71",
"@swc/jest": "0.2.27",
"@trendyol/jest-testcontainers": "^2.1.1",
"@types/chance": "1.1.3",
"@types/jest": "29.5.0",
"@types/jest": "29.5.3",
"@types/koa": "2.13.4",
"@types/lodash": "4.14.180",
"@types/node": "14.18.20",
@ -75,32 +77,16 @@
"@types/uuid": "8.3.4",
"chance": "1.1.8",
"ioredis-mock": "8.7.0",
"jest": "29.5.0",
"jest-environment-node": "29.5.0",
"jest-serial-runner": "^1.2.1",
"jest": "29.6.2",
"jest-environment-node": "29.6.2",
"jest-serial-runner": "1.2.1",
"koa": "2.13.4",
"nodemon": "2.0.16",
"pino-pretty": "10.0.0",
"pouchdb-adapter-memory": "7.2.2",
"timekeeper": "2.2.0",
"ts-jest": "29.0.5",
"ts-node": "10.8.1",
"tsconfig-paths": "4.0.0",
"typescript": "4.7.3"
},
"nx": {
"targets": {
"build": {
"dependsOn": [
{
"projects": [
"@budibase/types"
],
"target": "build"
}
]
}
}
},
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc"
}
}

View file

@ -8,6 +8,6 @@ then
jest --coverage --runInBand --forceExit
else
# --maxWorkers performs better in development
echo "jest --coverage --forceExit"
jest --coverage --forceExit
echo "jest --coverage --detectOpenHandles"
jest --coverage --detectOpenHandles
fi

View file

@ -36,7 +36,7 @@ describe("writethrough", () => {
_id: docId,
value: 1,
})
const output = await db.get(response.id)
const output = await db.get<any>(response.id)
current = output
expect(output.value).toBe(1)
})
@ -45,7 +45,7 @@ describe("writethrough", () => {
it("second put shouldn't update DB", async () => {
await config.doInTenant(async () => {
const response = await writethrough.put({ ...current, value: 2 })
const output = await db.get(response.id)
const output = await db.get<any>(response.id)
expect(current._rev).toBe(output._rev)
expect(output.value).toBe(1)
})
@ -55,7 +55,7 @@ describe("writethrough", () => {
await config.doInTenant(async () => {
tk.freeze(Date.now() + DELAY + 1)
const response = await writethrough.put({ ...current, value: 3 })
const output = await db.get(response.id)
const output = await db.get<any>(response.id)
expect(response.rev).not.toBe(current._rev)
expect(output.value).toBe(3)
@ -79,7 +79,7 @@ describe("writethrough", () => {
expect.arrayContaining([current._rev, current._rev, newRev])
)
const output = await db.get(current._id)
const output = await db.get<any>(current._id)
expect(output.value).toBe(4)
expect(output._rev).toBe(newRev)
@ -107,7 +107,7 @@ describe("writethrough", () => {
})
expect(res.ok).toBe(true)
const output = await db.get(id)
const output = await db.get<any>(id)
expect(output.value).toBe(3)
expect(output._rev).toBe(res.rev)
})
@ -130,8 +130,8 @@ describe("writethrough", () => {
const resp2 = await writethrough2.put({ _id: "db1", value: "second" })
expect(resp1.rev).toBeDefined()
expect(resp2.rev).toBeDefined()
expect((await db.get("db1")).value).toBe("first")
expect((await db2.get("db1")).value).toBe("second")
expect((await db.get<any>("db1")).value).toBe("first")
expect((await db2.get<any>("db1")).value).toBe("second")
})
})
})

View file

@ -1,5 +1,5 @@
export const SEPARATOR = "_"
export const UNICODE_MAX = "\ufff0"
import { prefixed, DocumentType } from "@budibase/types"
export { SEPARATOR, UNICODE_MAX, DocumentType } from "@budibase/types"
/**
* Can be used to create a few different forms of querying a view.
@ -14,8 +14,6 @@ export enum ViewName {
USER_BY_APP = "by_app",
USER_BY_EMAIL = "by_email2",
BY_API_KEY = "by_api_key",
/** @deprecated - could be deleted */
USER_BY_BUILDERS = "by_builders",
LINK = "by_link",
ROUTING = "screen_routes",
AUTOMATION_LOGS = "automation_logs",
@ -36,42 +34,6 @@ export enum InternalTable {
USER_METADATA = "ta_users",
}
export enum DocumentType {
USER = "us",
GROUP = "gr",
WORKSPACE = "workspace",
CONFIG = "config",
TEMPLATE = "template",
APP = "app",
DEV = "dev",
APP_DEV = "app_dev",
APP_METADATA = "app_metadata",
ROLE = "role",
MIGRATIONS = "migrations",
DEV_INFO = "devinfo",
AUTOMATION_LOG = "log_au",
ACCOUNT_METADATA = "acc_metadata",
PLUGIN = "plg",
DATASOURCE = "datasource",
DATASOURCE_PLUS = "datasource_plus",
APP_BACKUP = "backup",
TABLE = "ta",
ROW = "ro",
AUTOMATION = "au",
LINK = "li",
WEBHOOK = "wh",
INSTANCE = "inst",
LAYOUT = "layout",
SCREEN = "screen",
QUERY = "query",
DEPLOYMENTS = "deployments",
METADATA = "metadata",
MEM_VIEW = "view",
USER_FLAG = "flag",
AUTOMATION_METADATA = "meta_au",
AUDIT_LOG = "al",
}
export const StaticDatabases = {
GLOBAL: {
name: "global-db",
@ -95,7 +57,7 @@ export const StaticDatabases = {
},
}
export const APP_PREFIX = DocumentType.APP + SEPARATOR
export const APP_DEV = DocumentType.APP_DEV + SEPARATOR
export const APP_PREFIX = prefixed(DocumentType.APP)
export const APP_DEV = prefixed(DocumentType.APP_DEV)
export const APP_DEV_PREFIX = APP_DEV
export const BUDIBASE_DATASOURCE_TYPE = "budibase"

View file

@ -0,0 +1,10 @@
export const CONSTANT_INTERNAL_ROW_COLS = [
"_id",
"_rev",
"type",
"createdAt",
"updatedAt",
"tableId",
] as const
export const CONSTANT_EXTERNAL_ROW_COLS = ["_id", "_rev", "tableId"] as const

View file

@ -2,3 +2,4 @@ export * from "./connections"
export * from "./DatabaseImpl"
export * from "./utils"
export { init, getPouch, getPouchDB, closePouchDB } from "./pouchDB"
export * from "../constants"

View file

@ -105,16 +105,6 @@ export const createApiKeyView = async () => {
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 interface QueryViewOptions {
arrayResponse?: boolean
}
@ -223,7 +213,6 @@ export const queryPlatformView = async <T>(
const CreateFuncByName: any = {
[ViewName.USER_BY_EMAIL]: createNewUserEmailView,
[ViewName.BY_API_KEY]: createApiKeyView,
[ViewName.USER_BY_BUILDERS]: createUserBuildersView,
[ViewName.USER_BY_APP]: createUserAppView,
}

View file

@ -1,4 +1,5 @@
import { existsSync, readFileSync } from "fs"
import { ServiceType } from "@budibase/types"
function isTest() {
return isCypress() || isJest()
@ -83,10 +84,20 @@ function getPackageJsonFields(): {
}
}
function isWorker() {
return environment.SERVICE_TYPE === ServiceType.WORKER
}
function isApps() {
return environment.SERVICE_TYPE === ServiceType.APPS
}
const environment = {
isTest,
isJest,
isDev,
isWorker,
isApps,
isProd: () => {
return !isDev()
},
@ -153,6 +164,7 @@ const environment = {
SMTP_FROM_ADDRESS: process.env.SMTP_FROM_ADDRESS,
DISABLE_JWT_WARNING: process.env.DISABLE_JWT_WARNING,
BLACKLIST_IPS: process.env.BLACKLIST_IPS,
SERVICE_TYPE: "unknown",
/**
* Enable to allow an admin user to login using a password.
* This can be useful to prevent lockout when configuring SSO.

View file

@ -21,6 +21,7 @@ import { processors } from "./processors"
import { newid } from "../utils"
import * as installation from "../installation"
import * as configs from "../configs"
import * as users from "../users"
import { withCache, TTL, CacheKey } from "../cache/generic"
/**
@ -164,8 +165,8 @@ const identifyUser = async (
const id = user._id as string
const tenantId = await getEventTenantId(user.tenantId)
const type = IdentityType.USER
let builder = user.builder?.global || false
let admin = user.admin?.global || false
let builder = users.hasBuilderPermissions(user)
let admin = users.hasAdminPermissions(user)
let providerType
if (isSSOUser(user)) {
providerType = user.providerType

View file

@ -1,10 +1,8 @@
import { BBContext } from "@budibase/types"
import { UserCtx } from "@budibase/types"
import { isAdmin } from "../users"
export default async (ctx: BBContext, next: any) => {
if (
!ctx.internal &&
(!ctx.user || !ctx.user.admin || !ctx.user.admin.global)
) {
export default async (ctx: UserCtx, next: any) => {
if (!ctx.internal && !isAdmin(ctx.user)) {
ctx.throw(403, "Admin user only endpoint.")
}
return next()

View file

@ -1,10 +1,19 @@
import { BBContext } from "@budibase/types"
import { UserCtx } from "@budibase/types"
import { isBuilder, hasBuilderPermissions } from "../users"
import { getAppId } from "../context"
import env from "../environment"
export default async (ctx: BBContext, next: any) => {
if (
!ctx.internal &&
(!ctx.user || !ctx.user.builder || !ctx.user.builder.global)
) {
export default async (ctx: UserCtx, next: any) => {
const appId = getAppId()
const builderFn = env.isWorker()
? hasBuilderPermissions
: env.isApps()
? isBuilder
: undefined
if (!builderFn) {
throw new Error("Service name unknown - middleware inactive.")
}
if (!ctx.internal && !builderFn(ctx.user, appId)) {
ctx.throw(403, "Builder user only endpoint.")
}
return next()

View file

@ -1,12 +1,20 @@
import { BBContext } from "@budibase/types"
import { UserCtx } from "@budibase/types"
import { isBuilder, isAdmin, hasBuilderPermissions } from "../users"
import { getAppId } from "../context"
import env from "../environment"
export default async (ctx: BBContext, next: any) => {
if (
!ctx.internal &&
(!ctx.user || !ctx.user.builder || !ctx.user.builder.global) &&
(!ctx.user || !ctx.user.admin || !ctx.user.admin.global)
) {
ctx.throw(403, "Builder user only endpoint.")
export default async (ctx: UserCtx, next: any) => {
const appId = getAppId()
const builderFn = env.isWorker()
? hasBuilderPermissions
: env.isApps()
? isBuilder
: undefined
if (!builderFn) {
throw new Error("Service name unknown - middleware inactive.")
}
if (!ctx.internal && !builderFn(ctx.user, appId) && !isAdmin(ctx.user)) {
ctx.throw(403, "Admin/Builder user only endpoint.")
}
return next()
}

View file

@ -0,0 +1,180 @@
import adminOnly from "../adminOnly"
import builderOnly from "../builderOnly"
import builderOrAdmin from "../builderOrAdmin"
import { structures } from "../../../tests"
import { ContextUser, ServiceType } from "@budibase/types"
import { doInAppContext } from "../../context"
import env from "../../environment"
env._set("SERVICE_TYPE", ServiceType.APPS)
const appId = "app_aaa"
const basicUser = structures.users.user()
const adminUser = structures.users.adminUser()
const adminOnlyUser = structures.users.adminOnlyUser()
const builderUser = structures.users.builderUser()
const appBuilderUser = structures.users.appBuilderUser(appId)
function buildUserCtx(user: ContextUser) {
return {
internal: false,
user,
throw: jest.fn(),
} as any
}
function passed(throwFn: jest.Func, nextFn: jest.Func) {
expect(throwFn).not.toBeCalled()
expect(nextFn).toBeCalled()
}
function threw(throwFn: jest.Func) {
// cant check next, the throw function doesn't actually throw - so it still continues
expect(throwFn).toBeCalled()
}
describe("adminOnly middleware", () => {
it("should allow admin user", () => {
const ctx = buildUserCtx(adminUser),
next = jest.fn()
adminOnly(ctx, next)
passed(ctx.throw, next)
})
it("should not allow basic user", () => {
const ctx = buildUserCtx(basicUser),
next = jest.fn()
adminOnly(ctx, next)
threw(ctx.throw)
})
it("should not allow builder user", () => {
const ctx = buildUserCtx(builderUser),
next = jest.fn()
adminOnly(ctx, next)
threw(ctx.throw)
})
})
describe("builderOnly middleware", () => {
it("should allow builder user", () => {
const ctx = buildUserCtx(builderUser),
next = jest.fn()
builderOnly(ctx, next)
passed(ctx.throw, next)
})
it("should allow app builder user", () => {
const ctx = buildUserCtx(appBuilderUser),
next = jest.fn()
doInAppContext(appId, () => {
builderOnly(ctx, next)
})
passed(ctx.throw, next)
})
it("should allow admin and builder user", () => {
const ctx = buildUserCtx(adminUser),
next = jest.fn()
builderOnly(ctx, next)
passed(ctx.throw, next)
})
it("should not allow admin user", () => {
const ctx = buildUserCtx(adminOnlyUser),
next = jest.fn()
builderOnly(ctx, next)
threw(ctx.throw)
})
it("should not allow app builder user to different app", () => {
const ctx = buildUserCtx(appBuilderUser),
next = jest.fn()
doInAppContext("app_bbb", () => {
builderOnly(ctx, next)
})
threw(ctx.throw)
})
it("should not allow basic user", () => {
const ctx = buildUserCtx(basicUser),
next = jest.fn()
builderOnly(ctx, next)
threw(ctx.throw)
})
})
describe("builderOrAdmin middleware", () => {
it("should allow builder user", () => {
const ctx = buildUserCtx(builderUser),
next = jest.fn()
builderOrAdmin(ctx, next)
passed(ctx.throw, next)
})
it("should allow builder and admin user", () => {
const ctx = buildUserCtx(adminUser),
next = jest.fn()
builderOrAdmin(ctx, next)
passed(ctx.throw, next)
})
it("should allow admin user", () => {
const ctx = buildUserCtx(adminOnlyUser),
next = jest.fn()
builderOrAdmin(ctx, next)
passed(ctx.throw, next)
})
it("should allow app builder user", () => {
const ctx = buildUserCtx(appBuilderUser),
next = jest.fn()
doInAppContext(appId, () => {
builderOrAdmin(ctx, next)
})
passed(ctx.throw, next)
})
it("should not allow basic user", () => {
const ctx = buildUserCtx(basicUser),
next = jest.fn()
builderOrAdmin(ctx, next)
threw(ctx.throw)
})
})
describe("check service difference", () => {
it("should not allow without app ID in apps", () => {
env._set("SERVICE_TYPE", ServiceType.APPS)
const appId = "app_a"
const ctx = buildUserCtx({
...basicUser,
builder: {
apps: [appId],
},
})
const next = jest.fn()
doInAppContext(appId, () => {
builderOnly(ctx, next)
})
passed(ctx.throw, next)
doInAppContext("app_b", () => {
builderOnly(ctx, next)
})
threw(ctx.throw)
})
it("should allow without app ID in worker", () => {
env._set("SERVICE_TYPE", ServiceType.WORKER)
const ctx = buildUserCtx({
...basicUser,
builder: {
apps: ["app_a"],
},
})
const next = jest.fn()
doInAppContext("app_b", () => {
builderOnly(ctx, next)
})
passed(ctx.throw, next)
})
})

View file

@ -1,29 +1,12 @@
const { flatten } = require("lodash")
const { cloneDeep } = require("lodash/fp")
import { PermissionType, PermissionLevel } from "@budibase/types"
export { PermissionType, PermissionLevel } from "@budibase/types"
import flatten from "lodash/flatten"
import cloneDeep from "lodash/fp/cloneDeep"
export type RoleHierarchy = {
permissionId: string
}[]
export enum PermissionLevel {
READ = "read",
WRITE = "write",
EXECUTE = "execute",
ADMIN = "admin",
}
// these are the global types, that govern the underlying default behaviour
export enum PermissionType {
APP = "app",
TABLE = "table",
USER = "user",
AUTOMATION = "automation",
WEBHOOK = "webhook",
BUILDER = "builder",
VIEW = "view",
QUERY = "query",
}
export class Permission {
type: PermissionType
level: PermissionLevel
@ -173,3 +156,4 @@ export function isPermissionLevelHigherThanRead(level: PermissionLevel) {
// utility as a lot of things need simply the builder permission
export const BUILDER = PermissionType.BUILDER
export const GLOBAL_BUILDER = PermissionType.GLOBAL_BUILDER

View file

@ -3,7 +3,7 @@ import { prefixRoleID, getRoleParams, DocumentType, SEPARATOR } from "../db"
import { getAppDB } from "../context"
import { doWithDB } from "../db"
import { Screen, Role as RoleDoc } from "@budibase/types"
const { cloneDeep } = require("lodash/fp")
import cloneDeep from "lodash/fp/cloneDeep"
export const BUILTIN_ROLE_IDS = {
ADMIN: "ADMIN",

View file

@ -1,4 +1,4 @@
import { cloneDeep } from "lodash"
import cloneDeep from "lodash/cloneDeep"
import * as permissions from "../permissions"
import { BUILTIN_ROLE_IDS } from "../roles"

View file

@ -0,0 +1,460 @@
import env from "../environment"
import * as eventHelpers from "./events"
import * as accounts from "../accounts"
import * as cache from "../cache"
import { getIdentity, getTenantId, getGlobalDB } from "../context"
import * as dbUtils from "../db"
import { EmailUnavailableError, HTTPError } from "../errors"
import * as platform from "../platform"
import * as sessions from "../security/sessions"
import * as usersCore from "./users"
import {
AllDocsResponse,
BulkUserCreated,
BulkUserDeleted,
RowResponse,
SaveUserOpts,
User,
Account,
isSSOUser,
isSSOAccount,
UserStatus,
} from "@budibase/types"
import * as accountSdk from "../accounts"
import {
validateUniqueUser,
getAccountHolderFromUserIds,
isAdmin,
} from "./utils"
import { searchExistingEmails } from "./lookup"
import { hash } from "../utils"
type QuotaUpdateFn = (change: number, cb?: () => Promise<any>) => Promise<any>
type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any>
type FeatureFn = () => Promise<Boolean>
type QuotaFns = { addUsers: QuotaUpdateFn; removeUsers: QuotaUpdateFn }
type GroupFns = { addUsers: GroupUpdateFn }
type FeatureFns = { isSSOEnforced: FeatureFn; isAppBuildersEnabled: FeatureFn }
const bulkDeleteProcessing = async (dbUser: User) => {
const userId = dbUser._id as string
await platform.users.removeUser(dbUser)
await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "bulk-deletion" })
}
export class UserDB {
static quotas: QuotaFns
static groups: GroupFns
static features: FeatureFns
static init(quotaFns: QuotaFns, groupFns: GroupFns, featureFns: FeatureFns) {
UserDB.quotas = quotaFns
UserDB.groups = groupFns
UserDB.features = featureFns
}
static async isPreventPasswordActions(user: User, account?: Account) {
// when in maintenance mode we allow sso users with the admin role
// to perform any password action - this prevents lockout
if (env.ENABLE_SSO_MAINTENANCE_MODE && isAdmin(user)) {
return false
}
// SSO is enforced for all users
if (await UserDB.features.isSSOEnforced()) {
return true
}
// Check local sso
if (isSSOUser(user)) {
return true
}
// Check account sso
if (!account) {
account = await accountSdk.getAccountByTenantId(getTenantId())
}
return !!(account && account.email === user.email && isSSOAccount(account))
}
static async buildUser(
user: User,
opts: SaveUserOpts = {
hashPassword: true,
requirePassword: true,
},
tenantId: string,
dbUser?: any,
account?: Account
): Promise<User> {
let { password, _id } = user
// don't require a password if the db user doesn't already have one
if (dbUser && !dbUser.password) {
opts.requirePassword = false
}
let hashedPassword
if (password) {
if (await UserDB.isPreventPasswordActions(user, account)) {
throw new HTTPError("Password change is disabled for this user", 400)
}
hashedPassword = opts.hashPassword ? await hash(password) : password
} else if (dbUser) {
hashedPassword = dbUser.password
}
// passwords are never required if sso is enforced
const requirePasswords =
opts.requirePassword && !(await UserDB.features.isSSOEnforced())
if (!hashedPassword && requirePasswords) {
throw "Password must be specified."
}
_id = _id || dbUtils.generateGlobalUserID()
const fullUser = {
createdAt: Date.now(),
...dbUser,
...user,
_id,
password: hashedPassword,
tenantId,
}
// make sure the roles object is always present
if (!fullUser.roles) {
fullUser.roles = {}
}
// add the active status to a user if its not provided
if (fullUser.status == null) {
fullUser.status = UserStatus.ACTIVE
}
return fullUser
}
static async allUsers() {
const db = getGlobalDB()
const response = await db.allDocs(
dbUtils.getGlobalUserParams(null, {
include_docs: true,
})
)
return response.rows.map((row: any) => row.doc)
}
static async countUsersByApp(appId: string) {
let response: any = await usersCore.searchGlobalUsersByApp(appId, {})
return {
userCount: response.length,
}
}
static async getUsersByAppAccess(appId?: string) {
const opts: any = {
include_docs: true,
limit: 50,
}
let response: User[] = await usersCore.searchGlobalUsersByAppAccess(
appId,
opts
)
return response
}
static async getUserByEmail(email: string) {
return usersCore.getGlobalUserByEmail(email)
}
/**
* Gets a user by ID from the global database, based on the current tenancy.
*/
static async getUser(userId: string) {
const user = await usersCore.getById(userId)
if (user) {
delete user.password
}
return user
}
static async save(user: User, opts: SaveUserOpts = {}): Promise<User> {
// default booleans to true
if (opts.hashPassword == null) {
opts.hashPassword = true
}
if (opts.requirePassword == null) {
opts.requirePassword = true
}
const tenantId = getTenantId()
const db = getGlobalDB()
let { email, _id, userGroups = [], roles } = user
if (!email && !_id) {
throw new Error("_id or email is required")
}
if (
user.builder?.apps?.length &&
!(await UserDB.features.isAppBuildersEnabled())
) {
throw new Error("Unable to update app builders, please check license")
}
let dbUser: User | undefined
if (_id) {
// try to get existing user from db
try {
dbUser = (await db.get(_id)) as User
if (email && dbUser.email !== email) {
throw "Email address cannot be changed"
}
email = dbUser.email
} catch (e: any) {
if (e.status === 404) {
// do nothing, save this new user with the id specified - required for SSO auth
} else {
throw e
}
}
}
if (!dbUser && email) {
// no id was specified - load from email instead
dbUser = await usersCore.getGlobalUserByEmail(email)
if (dbUser && dbUser._id !== _id) {
throw new EmailUnavailableError(email)
}
}
const change = dbUser ? 0 : 1 // no change if there is existing user
return UserDB.quotas.addUsers(change, async () => {
await validateUniqueUser(email, tenantId)
let builtUser = await UserDB.buildUser(user, opts, tenantId, dbUser)
// don't allow a user to update its own roles/perms
if (opts.currentUserId && opts.currentUserId === dbUser?._id) {
builtUser = usersCore.cleanseUserObject(builtUser, dbUser) as User
}
if (!dbUser && roles?.length) {
builtUser.roles = { ...roles }
}
// make sure we set the _id field for a new user
// Also if this is a new user, associate groups with them
let groupPromises = []
if (!_id) {
_id = builtUser._id!
if (userGroups.length > 0) {
for (let groupId of userGroups) {
groupPromises.push(UserDB.groups.addUsers(groupId, [_id!]))
}
}
}
try {
// save the user to db
let response = await db.put(builtUser)
builtUser._rev = response.rev
await eventHelpers.handleSaveEvents(builtUser, dbUser)
await platform.users.addUser(tenantId, builtUser._id!, builtUser.email)
await cache.user.invalidateUser(response.id)
await Promise.all(groupPromises)
// finally returned the saved user from the db
return db.get(builtUser._id!)
} catch (err: any) {
if (err.status === 409) {
throw "User exists already"
} else {
throw err
}
}
})
}
static async bulkCreate(
newUsersRequested: User[],
groups: string[]
): Promise<BulkUserCreated> {
const tenantId = getTenantId()
let usersToSave: any[] = []
let newUsers: any[] = []
const emails = newUsersRequested.map((user: User) => user.email)
const existingEmails = await searchExistingEmails(emails)
const unsuccessful: { email: string; reason: string }[] = []
for (const newUser of newUsersRequested) {
if (
newUsers.find(
(x: User) => x.email.toLowerCase() === newUser.email.toLowerCase()
) ||
existingEmails.includes(newUser.email.toLowerCase())
) {
unsuccessful.push({
email: newUser.email,
reason: `Unavailable`,
})
continue
}
newUser.userGroups = groups
newUsers.push(newUser)
}
const account = await accountSdk.getAccountByTenantId(tenantId)
return UserDB.quotas.addUsers(newUsers.length, async () => {
// create the promises array that will be called by bulkDocs
newUsers.forEach((user: any) => {
usersToSave.push(
UserDB.buildUser(
user,
{
hashPassword: true,
requirePassword: user.requirePassword,
},
tenantId,
undefined, // no dbUser
account
)
)
})
const usersToBulkSave = await Promise.all(usersToSave)
await usersCore.bulkUpdateGlobalUsers(usersToBulkSave)
// Post-processing of bulk added users, e.g. events and cache operations
for (const user of usersToBulkSave) {
// TODO: Refactor to bulk insert users into the info db
// instead of relying on looping tenant creation
await platform.users.addUser(tenantId, user._id, user.email)
await eventHelpers.handleSaveEvents(user, undefined)
}
const saved = usersToBulkSave.map(user => {
return {
_id: user._id,
email: user.email,
}
})
// now update the groups
if (Array.isArray(saved) && groups) {
const groupPromises = []
const createdUserIds = saved.map(user => user._id)
for (let groupId of groups) {
groupPromises.push(UserDB.groups.addUsers(groupId, createdUserIds))
}
await Promise.all(groupPromises)
}
return {
successful: saved,
unsuccessful,
}
})
}
static async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> {
const db = getGlobalDB()
const response: BulkUserDeleted = {
successful: [],
unsuccessful: [],
}
// remove the account holder from the delete request if present
const account = await getAccountHolderFromUserIds(userIds)
if (account) {
userIds = userIds.filter(u => u !== account.budibaseUserId)
// mark user as unsuccessful
response.unsuccessful.push({
_id: account.budibaseUserId,
email: account.email,
reason: "Account holder cannot be deleted",
})
}
// Get users and delete
const allDocsResponse: AllDocsResponse<User> = await db.allDocs({
include_docs: true,
keys: userIds,
})
const usersToDelete: User[] = allDocsResponse.rows.map(
(user: RowResponse<User>) => {
return user.doc
}
)
// Delete from DB
const toDelete = usersToDelete.map(user => ({
...user,
_deleted: true,
}))
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
await UserDB.quotas.removeUsers(toDelete.length)
for (let user of usersToDelete) {
await bulkDeleteProcessing(user)
}
// Build Response
// index users by id
const userIndex: { [key: string]: User } = {}
usersToDelete.reduce((prev, current) => {
prev[current._id!] = current
return prev
}, userIndex)
// add the successful and unsuccessful users to response
dbResponse.forEach(item => {
const email = userIndex[item.id].email
if (item.ok) {
response.successful.push({ _id: item.id, email })
} else {
response.unsuccessful.push({
_id: item.id,
email,
reason: "Database error",
})
}
})
return response
}
static async destroy(id: string) {
const db = getGlobalDB()
const dbUser = (await db.get(id)) as User
const userId = dbUser._id as string
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
// root account holder can't be deleted from inside budibase
const email = dbUser.email
const account = await accounts.getAccount(email)
if (account) {
if (dbUser.userId === getIdentity()!._id) {
throw new HTTPError('Please visit "Account" to delete this user', 400)
} else {
throw new HTTPError("Account holder cannot be deleted", 400)
}
}
}
await platform.users.removeUser(dbUser)
await db.remove(userId, dbUser._rev)
await UserDB.quotas.removeUsers(1)
await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "deletion" })
}
}

View file

@ -1,15 +1,18 @@
import env from "../../environment"
import { events, accounts, tenancy } from "@budibase/backend-core"
import env from "../environment"
import * as events from "../events"
import * as accounts from "../accounts"
import { getTenantId } from "../context"
import { User, UserRoles, CloudAccount } from "@budibase/types"
import { hasBuilderPermissions, hasAdminPermissions } from "./utils"
export const handleDeleteEvents = async (user: any) => {
await events.user.deleted(user)
if (isBuilder(user)) {
if (hasBuilderPermissions(user)) {
await events.user.permissionBuilderRemoved(user)
}
if (isAdmin(user)) {
if (hasAdminPermissions(user)) {
await events.user.permissionAdminRemoved(user)
}
}
@ -55,7 +58,7 @@ export const handleSaveEvents = async (
user: User,
existingUser: User | undefined
) => {
const tenantId = tenancy.getTenantId()
const tenantId = getTenantId()
let tenantAccount: CloudAccount | undefined
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
tenantAccount = await accounts.getAccountByTenantId(tenantId)
@ -103,23 +106,20 @@ export const handleSaveEvents = async (
await handleAppRoleEvents(user, existingUser)
}
const isBuilder = (user: any) => user.builder && user.builder.global
const isAdmin = (user: any) => user.admin && user.admin.global
export const isAddingBuilder = (user: any, existingUser: any) => {
return isAddingPermission(user, existingUser, isBuilder)
return isAddingPermission(user, existingUser, hasBuilderPermissions)
}
export const isRemovingBuilder = (user: any, existingUser: any) => {
return isRemovingPermission(user, existingUser, isBuilder)
return isRemovingPermission(user, existingUser, hasBuilderPermissions)
}
const isAddingAdmin = (user: any, existingUser: any) => {
return isAddingPermission(user, existingUser, isAdmin)
return isAddingPermission(user, existingUser, hasAdminPermissions)
}
const isRemovingAdmin = (user: any, existingUser: any) => {
return isRemovingPermission(user, existingUser, isAdmin)
return isRemovingPermission(user, existingUser, hasAdminPermissions)
}
const isOnboardingComplete = (user: any, existingUser: any) => {

View file

@ -0,0 +1,4 @@
export * from "./users"
export * from "./utils"
export * from "./lookup"
export { UserDB } from "./db"

View file

@ -0,0 +1,102 @@
import {
AccountMetadata,
PlatformUser,
PlatformUserByEmail,
User,
} from "@budibase/types"
import * as dbUtils from "../db"
import { ViewName } from "../constants"
/**
* Apply a system-wide search on emails:
* - in tenant
* - cross tenant
* - accounts
* return an array of emails that match the supplied emails.
*/
export async function searchExistingEmails(emails: string[]) {
let matchedEmails: string[] = []
const existingTenantUsers = await getExistingTenantUsers(emails)
matchedEmails.push(...existingTenantUsers.map(user => user.email))
const existingPlatformUsers = await getExistingPlatformUsers(emails)
matchedEmails.push(...existingPlatformUsers.map(user => user._id!))
const existingAccounts = await getExistingAccounts(emails)
matchedEmails.push(...existingAccounts.map(account => account.email))
return [...new Set(matchedEmails.map(email => email.toLowerCase()))]
}
// lookup, could be email or userId, either will return a doc
export async function getPlatformUser(
identifier: string
): Promise<PlatformUser | null> {
// use the view here and allow to find anyone regardless of casing
// Use lowercase to ensure email login is case insensitive
return (await dbUtils.queryPlatformView(ViewName.PLATFORM_USERS_LOWERCASE, {
keys: [identifier.toLowerCase()],
include_docs: true,
})) as PlatformUser
}
export async function getExistingTenantUsers(
emails: string[]
): Promise<User[]> {
const lcEmails = emails.map(email => email.toLowerCase())
const params = {
keys: lcEmails,
include_docs: true,
}
const opts = {
arrayResponse: true,
}
return (await dbUtils.queryGlobalView(
ViewName.USER_BY_EMAIL,
params,
undefined,
opts
)) as User[]
}
export async function getExistingPlatformUsers(
emails: string[]
): Promise<PlatformUserByEmail[]> {
const lcEmails = emails.map(email => email.toLowerCase())
const params = {
keys: lcEmails,
include_docs: true,
}
const opts = {
arrayResponse: true,
}
return (await dbUtils.queryPlatformView(
ViewName.PLATFORM_USERS_LOWERCASE,
params,
opts
)) as PlatformUserByEmail[]
}
export async function getExistingAccounts(
emails: string[]
): Promise<AccountMetadata[]> {
const lcEmails = emails.map(email => email.toLowerCase())
const params = {
keys: lcEmails,
include_docs: true,
}
const opts = {
arrayResponse: true,
}
return (await dbUtils.queryPlatformView(
ViewName.ACCOUNT_BY_EMAIL,
params,
opts
)) as AccountMetadata[]
}

View file

@ -11,10 +11,16 @@ import {
SEPARATOR,
UNICODE_MAX,
ViewName,
} from "./db"
import { BulkDocsResponse, SearchUsersRequest, User } from "@budibase/types"
import { getGlobalDB } from "./context"
import * as context from "./context"
} from "../db"
import {
BulkDocsResponse,
SearchUsersRequest,
User,
ContextUser,
} from "@budibase/types"
import { getGlobalDB } from "../context"
import * as context from "../context"
import { user as userCache } from "../cache"
type GetOpts = { cleanup?: boolean }
@ -178,7 +184,7 @@ export const getGlobalUserByAppPage = (appId: string, user: User) => {
* Performs a starts with search on the global email view.
*/
export const searchGlobalUsersByEmail = async (
email: string,
email: string | unknown,
opts: any,
getOpts?: GetOpts
) => {
@ -248,3 +254,23 @@ export async function getUserCount() {
})
return response.total_rows
}
// used to remove the builder/admin permissions, for processing the
// user as an app user (they may have some specific role/group
export function removePortalUserPermissions(user: User | ContextUser) {
delete user.admin
delete user.builder
return user
}
export function cleanseUserObject(user: User | ContextUser, base?: User) {
delete user.admin
delete user.builder
delete user.roles
if (base) {
user.admin = base.admin
user.builder = base.builder
user.roles = base.roles
}
return user
}

View file

@ -0,0 +1,55 @@
import { CloudAccount } from "@budibase/types"
import * as accountSdk from "../accounts"
import env from "../environment"
import { getPlatformUser } from "./lookup"
import { EmailUnavailableError } from "../errors"
import { getTenantId } from "../context"
import { sdk } from "@budibase/shared-core"
import { getAccountByTenantId } from "../accounts"
// extract from shared-core to make easily accessible from backend-core
export const isBuilder = sdk.users.isBuilder
export const isAdmin = sdk.users.isAdmin
export const isGlobalBuilder = sdk.users.isGlobalBuilder
export const isAdminOrBuilder = sdk.users.isAdminOrBuilder
export const hasAdminPermissions = sdk.users.hasAdminPermissions
export const hasBuilderPermissions = sdk.users.hasBuilderPermissions
export const hasAppBuilderPermissions = sdk.users.hasAppBuilderPermissions
export async function validateUniqueUser(email: string, tenantId: string) {
// check budibase users in other tenants
if (env.MULTI_TENANCY) {
const tenantUser = await getPlatformUser(email)
if (tenantUser != null && tenantUser.tenantId !== tenantId) {
throw new EmailUnavailableError(email)
}
}
// check root account users in account portal
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const account = await accountSdk.getAccount(email)
if (account && account.verified && account.tenantId !== tenantId) {
throw new EmailUnavailableError(email)
}
}
}
/**
* For the given user id's, return the account holder if it is in the ids.
*/
export async function getAccountHolderFromUserIds(
userIds: string[]
): Promise<CloudAccount | undefined> {
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const tenantId = getTenantId()
const account = await getAccountByTenantId(tenantId)
if (!account) {
throw new Error(`Account not found for tenantId=${tenantId}`)
}
const budibaseUserId = account.budibaseUserId
if (userIds.includes(budibaseUserId)) {
return account
}
}
}

View file

@ -1,3 +1,5 @@
import { db } from "../../../src"
export function expectFunctionWasCalledTimesWith(
jestFunction: any,
times: number,
@ -7,3 +9,22 @@ export function expectFunctionWasCalledTimesWith(
jestFunction.mock.calls.filter((call: any) => call[0] === argument).length
).toBe(times)
}
export const expectAnyInternalColsAttributes: {
[K in (typeof db.CONSTANT_INTERNAL_ROW_COLS)[number]]: any
} = {
tableId: expect.anything(),
type: expect.anything(),
_id: expect.anything(),
_rev: expect.anything(),
createdAt: expect.anything(),
updatedAt: expect.anything(),
}
export const expectAnyExternalColsAttributes: {
[K in (typeof db.CONSTANT_EXTERNAL_ROW_COLS)[number]]: any
} = {
tableId: expect.anything(),
_id: expect.anything(),
_rev: expect.anything(),
}

View file

@ -1,5 +1,3 @@
import * as events from "../../../../src/events"
beforeAll(async () => {
const processors = await import("../../../../src/events/processors")
const events = await import("../../../../src/events")

View file

@ -1,5 +1,5 @@
import { Feature, License, Quotas } from "@budibase/types"
import _ from "lodash"
import cloneDeep from "lodash/cloneDeep"
let CLOUD_FREE_LICENSE: License
let UNLIMITED_LICENSE: License
@ -58,7 +58,7 @@ export const useCloudFree = () => {
// FEATURES
const useFeature = (feature: Feature) => {
const license = _.cloneDeep(UNLIMITED_LICENSE)
const license = cloneDeep(UNLIMITED_LICENSE)
const opts: UseLicenseOpts = {
features: [feature],
}
@ -94,10 +94,14 @@ export const useSyncAutomations = () => {
return useFeature(Feature.SYNC_AUTOMATIONS)
}
export const useAppBuilders = () => {
return useFeature(Feature.APP_BUILDERS)
}
// QUOTAS
export const setAutomationLogsQuota = (value: number) => {
const license = _.cloneDeep(UNLIMITED_LICENSE)
const license = cloneDeep(UNLIMITED_LICENSE)
license.quotas.constant.automationLogRetentionDays.value = value
return useLicense(license)
}

View file

@ -11,7 +11,7 @@ import {
CreateAccount,
CreatePassswordAccount,
} from "@budibase/types"
import _ from "lodash"
import sample from "lodash/sample"
export const account = (partial: Partial<Account> = {}): Account => {
return {
@ -46,13 +46,11 @@ export const cloudAccount = (): CloudAccount => {
}
function providerType(): AccountSSOProviderType {
return _.sample(
Object.values(AccountSSOProviderType)
) as AccountSSOProviderType
return sample(Object.values(AccountSSOProviderType)) as AccountSSOProviderType
}
function provider(): AccountSSOProvider {
return _.sample(Object.values(AccountSSOProvider)) as AccountSSOProvider
return sample(Object.values(AccountSSOProvider)) as AccountSSOProvider
}
export function ssoAccount(account: Account = cloudAccount()): SSOAccount {

View file

@ -1,7 +1,6 @@
import { ScimCreateGroupRequest, ScimCreateUserRequest } from "@budibase/types"
import { uuid } from "./common"
import { generator } from "./generator"
import _ from "lodash"
interface CreateUserRequestFields {
externalId: string
@ -20,10 +19,10 @@ export function createUserRequest(userData?: Partial<CreateUserRequestFields>) {
username: generator.name(),
}
const { externalId, email, firstName, lastName, username } = _.assign(
defaultValues,
userData
)
const { externalId, email, firstName, lastName, username } = {
...defaultValues,
...userData,
}
let user: ScimCreateUserRequest = {
schemas: [

View file

@ -15,7 +15,7 @@ import { generator } from "./generator"
import { email, uuid } from "./common"
import * as shared from "./shared"
import { user } from "./shared"
import _ from "lodash"
import sample from "lodash/sample"
export function OAuth(): OAuth2 {
return {
@ -47,7 +47,7 @@ export function authDetails(userDoc?: User): SSOAuthDetails {
}
export function providerType(): SSOProviderType {
return _.sample(Object.values(SSOProviderType)) as SSOProviderType
return sample(Object.values(SSOProviderType)) as SSOProviderType
}
export function ssoProfile(user?: User): SSOProfile {

View file

@ -1,5 +1,6 @@
import {
AdminUser,
AdminOnlyUser,
BuilderUser,
SSOAuthDetails,
SSOUser,
@ -21,6 +22,15 @@ export const adminUser = (userProps?: any): AdminUser => {
}
}
export const adminOnlyUser = (userProps?: any): AdminOnlyUser => {
return {
...user(userProps),
admin: {
global: true,
},
}
}
export const builderUser = (userProps?: any): BuilderUser => {
return {
...user(userProps),
@ -30,6 +40,15 @@ export const builderUser = (userProps?: any): BuilderUser => {
}
}
export const appBuilderUser = (appId: string, userProps?: any): BuilderUser => {
return {
...user(userProps),
builder: {
apps: [appId],
},
}
}
export function ssoUser(
opts: { user?: any; details?: SSOAuthDetails } = {}
): SSOUser {

View file

@ -4,9 +4,9 @@
"composite": true,
"baseUrl": ".",
"paths": {
"@budibase/types": ["../types/src"]
"@budibase/types": ["../types/src"],
"@budibase/shared-core": ["../shared-core/src"]
}
},
"exclude": ["node_modules", "dist"]
}

View file

@ -85,7 +85,8 @@
"dayjs": "^1.10.4",
"easymde": "^2.16.1",
"svelte-flatpickr": "3.2.3",
"svelte-portal": "^1.0.0"
"svelte-portal": "^1.0.0",
"svelte-dnd-action": "^0.9.8"
},
"resolutions": {
"loader-utils": "1.4.1"
@ -96,13 +97,14 @@
"dependsOn": [
{
"projects": [
"@budibase/string-templates"
"@budibase/string-templates",
"@budibase/shared-core",
"@budibase/types"
],
"target": "build"
}
]
}
}
},
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc"
}
}

View file

@ -64,7 +64,7 @@ export default function positionDropdown(element, opts) {
// Apply styles
Object.entries(styles).forEach(([style, value]) => {
if (value) {
if (value != null) {
element.style[style] = `${value.toFixed(0)}px`
} else {
element.style[style] = null

View file

@ -1,5 +1,4 @@
<script>
//import { createEventDispatcher } from "svelte"
import "@spectrum-css/popover/dist/index-vars.css"
import clickOutside from "../Actions/click_outside"
import { fly } from "svelte/transition"

View file

@ -0,0 +1,252 @@
<script>
import { flip } from "svelte/animate"
import { dndzone } from "svelte-dnd-action"
import Icon from "../Icon/Icon.svelte"
import Popover from "../Popover/Popover.svelte"
import { onMount } from "svelte"
const flipDurationMs = 150
export let constraints
export let optionColors = {}
let options = []
let colorPopovers = []
let anchors = []
let colorsArray = [
"hsla(0, 90%, 75%, 0.3)",
"hsla(50, 80%, 75%, 0.3)",
"hsla(120, 90%, 75%, 0.3)",
"hsla(200, 90%, 75%, 0.3)",
"hsla(240, 90%, 75%, 0.3)",
"hsla(320, 90%, 75%, 0.3)",
]
$: {
if (constraints.inclusion.length) {
options = constraints.inclusion.map(value => ({
name: value,
id: Math.random(),
}))
}
}
const removeInput = idx => {
delete optionColors[options[idx].name]
constraints.inclusion = constraints.inclusion.filter((e, i) => i !== idx)
options = options.filter((e, i) => i !== idx)
colorPopovers.pop(undefined)
anchors.pop(undefined)
}
const addNewInput = () => {
options = [
...options,
{ name: `Option ${constraints.inclusion.length + 1}`, id: Math.random() },
]
constraints.inclusion = [
...constraints.inclusion,
`Option ${constraints.inclusion.length + 1}`,
]
colorPopovers.push(undefined)
anchors.push(undefined)
}
const handleDndConsider = e => {
options = e.detail.items
}
const handleDndFinalize = e => {
options = e.detail.items
constraints.inclusion = options.map(option => option.name)
}
const handleColorChange = (optionName, color, idx) => {
optionColors[optionName] = color
colorPopovers[idx].hide()
}
const handleNameChange = (optionName, idx, value) => {
constraints.inclusion[idx] = value
options[idx].name = value
optionColors[value] = optionColors[optionName]
delete optionColors[optionName]
}
const openColorPickerPopover = (optionIdx, target) => {
colorPopovers[optionIdx].show()
anchors[optionIdx] = target
}
onMount(() => {
// Initialize anchor arrays on mount, assuming 'options' is already populated
colorPopovers = constraints.inclusion.map(() => undefined)
anchors = constraints.inclusion.map(() => undefined)
})
</script>
<div>
<div
class="actions"
use:dndzone={{
items: options,
flipDurationMs,
dropTargetStyle: { outline: "none" },
}}
on:consider={handleDndConsider}
on:finalize={handleDndFinalize}
>
{#each options as option, idx (option.id)}
<div
class="no-border action-container"
animate:flip={{ duration: flipDurationMs }}
>
<div class="child drag-handle-spacing">
<Icon name="DragHandle" size="L" />
</div>
<div class="child color-picker">
<div
id="color-picker"
bind:this={anchors[idx]}
style="--color:{optionColors?.[option.name] ||
'hsla(0, 1%, 50%, 0.3)'}"
class="circle"
on:click={e => openColorPickerPopover(idx, e.target)}
>
<Popover
bind:this={colorPopovers[idx]}
anchor={anchors[idx]}
align="left"
offset={0}
style=""
popoverTarget={document.getElementById(`color-picker`)}
animate={false}
>
<div class="colors">
{#each colorsArray as color}
<div
on:click={() => handleColorChange(option.name, color, idx)}
style="--color:{color};"
class="circle circle-hover"
/>
{/each}
</div>
</Popover>
</div>
</div>
<div class="child">
<input
class="input-field"
type="text"
on:change={e => handleNameChange(option.name, idx, e.target.value)}
value={option.name}
placeholder="Option name"
/>
</div>
<div class="child">
<Icon name="Close" hoverable size="S" on:click={removeInput(idx)} />
</div>
</div>
{/each}
</div>
<div on:click={addNewInput} class="add-option">
<Icon hoverable name="Add" />
<div>Add option</div>
</div>
</div>
<style>
.action-container {
background-color: var(--spectrum-alias-background-color-primary);
border-radius: 0px;
border: 1px solid var(--spectrum-global-color-gray-300);
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
border-color 130ms ease-in-out;
display: flex;
flex-direction: row;
align-items: center;
}
.no-border {
border-bottom: none;
}
.action-container:last-child {
border-bottom: 1px solid var(--spectrum-global-color-gray-300) !important;
}
.child {
height: 30px;
}
.child:hover,
.child:focus {
background: var(--spectrum-global-color-gray-200);
}
.add-option {
display: flex;
flex-direction: row;
align-items: center;
padding: var(--spacing-m);
gap: var(--spacing-m);
cursor: pointer;
}
.input-field {
border: none;
outline: none;
background-color: transparent;
width: 100%;
color: var(--text);
}
.child input[type="text"] {
padding-left: 10px;
}
.input-field:hover,
.input-field:focus {
background: var(--spectrum-global-color-gray-200);
}
.action-container > :nth-child(1) {
flex-grow: 1;
justify-content: center;
display: flex;
}
.action-container > :nth-child(2) {
flex-grow: 1;
display: flex;
justify-content: center;
align-items: center;
}
.action-container > :nth-child(3) {
flex-grow: 4;
display: flex;
}
.action-container > :nth-child(4) {
flex-grow: 1;
justify-content: center;
display: flex;
}
.circle {
height: 20px;
width: 20px;
background-color: var(--color);
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
}
.circle-hover:hover {
border: 1px solid var(--spectrum-global-color-blue-400);
cursor: pointer;
}
.colors {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--spacing-xl);
justify-items: center;
margin: var(--spacing-m);
}
</style>

View file

@ -21,6 +21,7 @@
export let offset = 5
export let customHeight
export let animate = true
export let customZindex
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
@ -77,8 +78,9 @@
}}
on:keydown={handleEscape}
class="spectrum-Popover is-open"
class:customZindex
role="presentation"
style="height: {customHeight}"
style="height: {customHeight}; --customZindex: {customZindex};"
transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }}
>
<slot />
@ -92,4 +94,8 @@
border-color: var(--spectrum-global-color-gray-300);
overflow: auto;
}
.customZindex {
z-index: var(--customZindex) !important;
}
</style>

View file

@ -84,7 +84,7 @@ export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte
export { default as Slider } from "./Form/Slider.svelte"
export { default as Accordion } from "./Accordion/Accordion.svelte"
export { default as File } from "./Form/File.svelte"
export { default as OptionSelectDnD } from "./OptionSelectDnD/OptionSelectDnD.svelte"
// Renderers
export { default as BoldRenderer } from "./Table/BoldRenderer.svelte"
export { default as CodeRenderer } from "./Table/CodeRenderer.svelte"

View file

@ -101,14 +101,14 @@
"@rollup/plugin-replace": "^2.4.2",
"@roxi/routify": "2.18.5",
"@sveltejs/vite-plugin-svelte": "1.0.1",
"@testing-library/jest-dom": "^5.11.10",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/svelte": "^3.2.2",
"babel-jest": "^26.6.3",
"babel-jest": "29.6.2",
"cypress": "^9.3.1",
"cypress-multi-reporters": "^1.6.0",
"cypress-terminal-report": "^1.4.1",
"identity-obj-proxy": "^3.0.0",
"jest": "^26.6.3",
"jest": "29.6.2",
"jsdom": "^21.1.1",
"mochawesome": "^7.1.3",
"mochawesome-merge": "^4.2.1",
@ -133,8 +133,21 @@
"dependsOn": [
{
"projects": [
"@budibase/shared-core",
"@budibase/string-templates",
"@budibase/shared-core"
"@budibase/types"
],
"target": "build"
}
]
},
"dev:builder": {
"dependsOn": [
{
"projects": [
"@budibase/shared-core",
"@budibase/string-templates",
"@budibase/types"
],
"target": "build"
}
@ -145,13 +158,13 @@
{
"projects": [
"@budibase/shared-core",
"@budibase/string-templates"
"@budibase/string-templates",
"@budibase/types"
],
"target": "build"
}
]
}
}
},
"gitHead": "115189f72a850bfb52b65ec61d932531bf327072"
}
}

View file

@ -507,6 +507,7 @@ const getSelectedRowsBindings = asset => {
readableBinding: `${table._instanceName}.Selected rows`,
category: "Selected rows",
icon: "ViewRow",
display: { name: table._instanceName },
}))
)
@ -522,6 +523,7 @@ const getSelectedRowsBindings = asset => {
)}.${makePropSafe("selectedRows")}`,
readableBinding: `${block._instanceName}.Selected rows`,
category: "Selected rows",
display: { name: block._instanceName },
}))
)
}

View file

@ -108,7 +108,10 @@
/****************************************************/
const getInputData = (testData, blockInputs) => {
let newInputData = testData || blockInputs
// Test data is not cloned for reactivity
let newInputData = testData || cloneDeep(blockInputs)
// Ensures the app action fields are populated
if (block.event === "app:trigger" && !newInputData?.fields) {
newInputData = cloneDeep(blockInputs)
}

View file

@ -50,6 +50,7 @@
type="string"
{bindings}
fillWidth={true}
updateOnChange={false}
/>
{/each}
</div>

View file

@ -1,5 +1,5 @@
<script>
import { datasources, tables } from "stores/backend"
import { datasources, tables, integrations } from "stores/backend"
import EditRolesButton from "./buttons/EditRolesButton.svelte"
import { TableNames } from "constants"
import { Grid } from "@budibase/frontend-core"
@ -27,6 +27,17 @@
$: isUsersTable = id === TableNames.USERS
$: isInternal = $tables.selected?.type !== "external"
$: datasource = $datasources.list.find(datasource => {
return datasource._id === $tables.selected?.sourceId
})
$: relationshipsEnabled = relationshipSupport(datasource)
const relationshipSupport = datasource => {
const integration = $integrations[datasource?.source]
return !isInternal && integration?.relationships !== false
}
const handleGridTableUpdate = async e => {
tables.replaceTable(id, e.detail)
@ -53,12 +64,19 @@
<svelte:fragment slot="filter">
<GridFilterButton />
</svelte:fragment>
<svelte:fragment slot="edit-column">
<GridEditColumnModal />
</svelte:fragment>
<svelte:fragment slot="add-column">
<GridAddColumnModal />
</svelte:fragment>
<svelte:fragment slot="controls">
{#if isInternal}
<GridCreateViewButton />
{/if}
<GridManageAccessButton />
{#if !isInternal}
{#if relationshipsEnabled}
<GridRelationshipButton />
{/if}
{#if isUsersTable}
@ -66,9 +84,8 @@
{:else}
<GridImportButton />
{/if}
<GridExportButton />
<GridAddColumnModal />
<GridEditColumnModal />
{#if isUsersTable}
<GridEditUserModal />
{:else}

View file

@ -7,12 +7,12 @@
Toggle,
RadioGroup,
DatePicker,
ModalContent,
Context,
Modal,
notifications,
OptionSelectDnD,
Layout,
} from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { createEventDispatcher, getContext } from "svelte"
import { cloneDeep } from "lodash/fp"
import { tables, datasources } from "stores/backend"
import { TableNames, UNEDITABLE_USER_FIELDS } from "constants"
@ -26,12 +26,10 @@
SWITCHABLE_TYPES,
} from "constants/backend"
import { getAutoColumnInformation, buildAutoColumn } from "builderStore/utils"
import ValuesList from "components/common/ValuesList.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { truncate } from "lodash"
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import { getBindings } from "components/backend/DataTable/formula"
import { getContext } from "svelte"
import JSONSchemaModal from "./JSONSchemaModal.svelte"
import { ValidColumnNameRegex } from "@budibase/shared-core"
@ -45,11 +43,11 @@
const dispatch = createEventDispatcher()
const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"]
const { hide } = getContext(Context.Modal)
let fieldDefinitions = cloneDeep(FIELDS)
const { dispatch: gridDispatch } = getContext("grid")
export let field
let fieldDefinitions = cloneDeep(FIELDS)
let originalName
let linkEditDisabled
let primaryDisplay
@ -61,11 +59,10 @@
let savingColumn
let deleteColName
let jsonSchemaModal
let allowedTypes = []
let editableColumn = {
type: "string",
constraints: fieldDefinitions.STRING.constraints,
// Initial value for column name in other table for linked records
fieldName: $tables.selected.name,
}
@ -83,7 +80,23 @@
primaryDisplay =
$tables.selected.primaryDisplay == null ||
$tables.selected.primaryDisplay === editableColumn.name
} else if (!savingColumn) {
let highestNumber = 0
Object.keys(table.schema).forEach(columnName => {
const columnNumber = extractColumnNumber(columnName)
if (columnNumber > highestNumber) {
highestNumber = columnNumber
}
return highestNumber
})
if (highestNumber >= 1) {
editableColumn.name = `Column 0${highestNumber + 1}`
} else {
editableColumn.name = "Column 01"
}
}
allowedTypes = getAllowedTypes()
}
$: initialiseField(field, savingColumn)
@ -182,6 +195,8 @@
indexes,
})
dispatch("updatecolumns")
gridDispatch("close-edit-column")
if (
saveColumn.type === LINK_TYPE &&
saveColumn.relationshipType === RelationshipType.MANY_TO_MANY
@ -203,6 +218,7 @@
function cancelEdit() {
editableColumn.name = originalName
gridDispatch("close-edit-column")
}
async function deleteColumn() {
@ -214,8 +230,8 @@
await tables.deleteField(editableColumn)
notifications.success(`Column ${editableColumn.name} deleted`)
confirmDeleteDialog.hide()
hide()
dispatch("updatecolumns")
gridDispatch("close-edit-column")
}
} catch (error) {
notifications.error(`Error deleting column: ${error.message}`)
@ -251,14 +267,6 @@
required = req
}
function onChangePrimaryDisplay(e) {
const isPrimary = e.detail
// primary display is always required
if (isPrimary) {
editableColumn.constraints.presence = { allowEmpty: false }
}
}
function openJsonSchemaEditor() {
jsonSchemaModal.show()
}
@ -272,6 +280,11 @@
deleteColName = ""
}
function extractColumnNumber(columnName) {
const match = columnName.match(/Column (\d+)/)
return match ? parseInt(match[1]) : 0
}
function getRelationshipOptions(field) {
if (!field || !field.tableId) {
return null
@ -402,15 +415,8 @@
}
</script>
<ModalContent
title={originalName ? "Edit Column" : "Create Column"}
confirmText="Save Column"
onConfirm={saveColumn}
onCancel={cancelEdit}
disabled={invalid}
>
<Layout noPadding gap="S">
<Input
label="Name"
bind:value={editableColumn.name}
disabled={uneditable ||
(linkEditDisabled && editableColumn.type === LINK_TYPE)}
@ -419,12 +425,12 @@
<Select
disabled={!typeEnabled}
label="Type"
bind:value={editableColumn.type}
on:change={handleTypeChange}
options={getAllowedTypes()}
options={allowedTypes}
getOptionLabel={field => field.name}
getOptionValue={field => field.type}
getOptionIcon={field => field.icon}
isOptionEnabled={option => {
if (option.type == AUTO_TYPE) {
return availableAutoColumnKeys?.length > 0
@ -433,28 +439,6 @@
}}
/>
{#if canBeRequired || canBeDisplay}
<div>
{#if canBeRequired}
<Toggle
value={required}
on:change={onChangeRequired}
disabled={primaryDisplay}
thin
text="Required"
/>
{/if}
{#if canBeDisplay}
<Toggle
bind:value={primaryDisplay}
on:change={onChangePrimaryDisplay}
thin
text="Use as table display column"
/>
{/if}
</div>
{/if}
{#if editableColumn.type === "string"}
<Input
type="number"
@ -462,9 +446,9 @@
bind:value={editableColumn.constraints.length.maximum}
/>
{:else if editableColumn.type === "options"}
<ValuesList
label="Options (one per line)"
bind:values={editableColumn.constraints.inclusion}
<OptionSelectDnD
bind:constraints={editableColumn.constraints}
bind:optionColors={editableColumn.optionColors}
/>
{:else if editableColumn.type === "longform"}
<div>
@ -480,19 +464,28 @@
/>
</div>
{:else if editableColumn.type === "array"}
<ValuesList
label="Options (one per line)"
bind:values={editableColumn.constraints.inclusion}
<OptionSelectDnD
bind:constraints={editableColumn.constraints}
bind:optionColors={editableColumn.optionColors}
/>
{:else if editableColumn.type === "datetime" && !editableColumn.autocolumn}
<DatePicker
label="Earliest"
bind:value={editableColumn.constraints.datetime.earliest}
/>
<DatePicker
label="Latest"
bind:value={editableColumn.constraints.datetime.latest}
/>
<div class="split-label">
<div class="label-length">
<Label size="M">Earliest</Label>
</div>
<div class="input-length">
<DatePicker bind:value={editableColumn.constraints.datetime.earliest} />
</div>
</div>
<div class="split-label">
<div class="label-length">
<Label size="M">Latest</Label>
</div>
<div class="input-length">
<DatePicker bind:value={editableColumn.constraints.datetime.latest} />
</div>
</div>
{#if datasource?.source !== "ORACLE" && datasource?.source !== "SQL_SERVER"}
<div>
<Label
@ -509,16 +502,30 @@
</div>
{/if}
{:else if editableColumn.type === "number" && !editableColumn.autocolumn}
<Input
type="number"
label="Min Value"
bind:value={editableColumn.constraints.numericality.greaterThanOrEqualTo}
/>
<Input
type="number"
label="Max Value"
bind:value={editableColumn.constraints.numericality.lessThanOrEqualTo}
/>
<div class="split-label">
<div class="label-length">
<Label size="M">Max Value</Label>
</div>
<div class="input-length">
<Input
type="number"
bind:value={editableColumn.constraints.numericality
.greaterThanOrEqualTo}
/>
</div>
</div>
<div class="split-label">
<div class="label-length">
<Label size="M">Max Value</Label>
</div>
<div class="input-length">
<Input
type="number"
bind:value={editableColumn.constraints.numericality.lessThanOrEqualTo}
/>
</div>
</div>
{:else if editableColumn.type === "link"}
<Select
label="Table"
@ -547,32 +554,44 @@
/>
{:else if editableColumn.type === FORMULA_TYPE}
{#if !table.sql}
<Select
label="Formula type"
bind:value={editableColumn.formulaType}
options={[
{ label: "Dynamic", value: "dynamic" },
{ label: "Static", value: "static" },
]}
getOptionLabel={option => option.label}
getOptionValue={option => option.value}
tooltip="Dynamic formula are calculated when retrieved, but cannot be filtered or sorted by,
<div class="split-label">
<div class="label-length">
<Label size="M">Formula Type</Label>
</div>
<div class="input-length">
<Select
bind:value={editableColumn.formulaType}
options={[
{ label: "Dynamic", value: "dynamic" },
{ label: "Static", value: "static" },
]}
getOptionLabel={option => option.label}
getOptionValue={option => option.value}
tooltip="Dynamic formula are calculated when retrieved, but cannot be filtered or sorted by,
while static formula are calculated when the row is saved."
/>
/>
</div>
</div>
{/if}
<ModalBindableInput
title="Formula"
label="Formula"
value={editableColumn.formula}
on:change={e => {
editableColumn = {
...editableColumn,
formula: e.detail,
}
}}
bindings={getBindings({ table })}
allowJS
/>
<div class="split-label">
<div class="label-length">
<Label size="M">Formula</Label>
</div>
<div class="input-length">
<ModalBindableInput
title="Formula"
value={editableColumn.formula}
on:change={e => {
editableColumn = {
...editableColumn,
formula: e.detail,
}
}}
bindings={getBindings({ table })}
allowJS
/>
</div>
</div>
{:else if editableColumn.type === JSON_TYPE}
<Button primary text on:click={openJsonSchemaEditor}
>Open schema editor</Button
@ -591,12 +610,28 @@
/>
{/if}
<div slot="footer">
{#if !uneditable && originalName != null}
<Button warning text on:click={confirmDelete}>Delete</Button>
{/if}
</div>
</ModalContent>
{#if canBeRequired || canBeDisplay}
<div>
{#if canBeRequired}
<Toggle
value={required}
on:change={onChangeRequired}
disabled={primaryDisplay}
thin
text="Required"
/>
{/if}
</div>
{/if}
</Layout>
<div class="action-buttons">
{#if !uneditable && originalName != null}
<Button quiet warning text on:click={confirmDelete}>Delete</Button>
{/if}
<Button secondary newStyles on:click={cancelEdit}>Cancel</Button>
<Button disabled={invalid} newStyles cta on:click={saveColumn}>Save</Button>
</div>
<Modal bind:this={jsonSchemaModal}>
<JSONSchemaModal
schema={editableColumn.schema}
@ -607,6 +642,7 @@
}}
/>
</Modal>
<ConfirmDialog
bind:this={confirmDeleteDialog}
okText="Delete Column"
@ -622,3 +658,24 @@
</p>
<Input bind:value={deleteColName} placeholder={originalName} />
</ConfirmDialog>
<style>
.action-buttons {
display: flex;
justify-content: flex-end;
margin-top: var(--spacing-s);
gap: var(--spacing-l);
}
.split-label {
display: flex;
align-items: center;
}
.label-length {
flex-basis: 40%;
}
.input-length {
flex-grow: 1;
}
</style>

View file

@ -1,15 +1,8 @@
<script>
import { getContext, onMount } from "svelte"
import { Modal } from "@budibase/bbui"
import { getContext } from "svelte"
import CreateEditColumn from "components/backend/DataTable/modals/CreateEditColumn.svelte"
const { rows, subscribe } = getContext("grid")
let modal
onMount(() => subscribe("add-column", modal.show))
const { rows } = getContext("grid")
</script>
<Modal bind:this={modal}>
<CreateEditColumn on:updatecolumns={rows.actions.refreshTableDefinition} />
</Modal>
<CreateEditColumn on:updatecolumns={rows.actions.refreshTableDefinition} />

View file

@ -1,24 +1,19 @@
<script>
import { getContext, onMount } from "svelte"
import { Modal } from "@budibase/bbui"
import CreateEditColumn from "../CreateEditColumn.svelte"
const { rows, subscribe } = getContext("grid")
let editableColumn
let editColumnModal
const editColumn = column => {
editableColumn = column
editColumnModal.show()
}
onMount(() => subscribe("edit-column", editColumn))
</script>
<Modal bind:this={editColumnModal}>
<CreateEditColumn
field={editableColumn}
on:updatecolumns={rows.actions.refreshData}
/>
</Modal>
<CreateEditColumn
field={editableColumn}
on:updatecolumns={rows.actions.refreshData}
/>

View file

@ -341,7 +341,7 @@
</Tab>
{/if}
<div class="drawer-actions">
{#if typeof drawerActions.hide === "function" && drawerActions.headless}
{#if typeof drawerActions?.hide === "function" && drawerActions?.headless}
<Button
secondary
quiet
@ -352,7 +352,7 @@
Cancel
</Button>
{/if}
{#if typeof bindingDrawerActions?.save === "function" && drawerActions.headless}
{#if typeof bindingDrawerActions?.save === "function" && drawerActions?.headless}
<Button
cta
disabled={!valid}

View file

@ -206,6 +206,11 @@
return allBindings
}
const toDisplay = eventKey => {
const type = actionTypes.find(action => action.name == eventKey)
return type?.displayName || type?.name
}
</script>
<DrawerContent>
@ -231,7 +236,9 @@
<ul>
{#each category as actionType}
<li on:click={onAddAction(actionType)}>
<span class="action-name">{actionType.name}</span>
<span class="action-name">
{actionType.displayName || actionType.name}
</span>
</li>
{/each}
</ul>
@ -262,7 +269,7 @@
>
<Icon name="DragHandle" size="XL" />
<div class="action-header">
{index + 1}.&nbsp;{action[EVENT_TYPE_KEY]}
{index + 1}.&nbsp;{toDisplay(action[EVENT_TYPE_KEY])}
</div>
<Icon
name="Close"

View file

@ -1,5 +1,5 @@
<script>
import { Select, Label, Checkbox, Input } from "@budibase/bbui"
import { Select, Label, Checkbox, Input, Body } from "@budibase/bbui"
import { tables } from "stores/backend"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
@ -10,47 +10,59 @@
</script>
<div class="root">
<Label>Table</Label>
<Select
bind:value={parameters.tableId}
options={tableOptions}
getOptionLabel={table => table.name}
getOptionValue={table => table._id}
/>
<Label small>Row ID</Label>
<DrawerBindableInput
{bindings}
title="Row ID to delete"
value={parameters.rowId}
on:change={value => (parameters.rowId = value.detail)}
/>
<Label small />
<Checkbox
text="Do not display default notification"
bind:value={parameters.notificationOverride}
/>
<br />
<Checkbox text="Require confirmation" bind:value={parameters.confirm} />
{#if parameters.confirm}
<Label small>Confirm text</Label>
<Input
placeholder="Are you sure you want to delete this row?"
bind:value={parameters.confirmText}
<Body size="small">Please specify one or more rows to delete.</Body>
<div class="params">
<Label>Table</Label>
<Select
bind:value={parameters.tableId}
options={tableOptions}
getOptionLabel={table => table.name}
getOptionValue={table => table._id}
/>
{/if}
<Label small>Row IDs</Label>
<DrawerBindableInput
{bindings}
title="Rows to delete"
value={parameters.rowId}
on:change={value => (parameters.rowId = value.detail)}
/>
<Label small />
<Checkbox
text="Do not display default notification"
bind:value={parameters.notificationOverride}
/>
<br />
<Checkbox text="Require confirmation" bind:value={parameters.confirm} />
{#if parameters.confirm}
<Label small>Confirm text</Label>
<Input
placeholder="Are you sure you want to delete?"
bind:value={parameters.confirmText}
/>
{/if}
</div>
</div>
<style>
.root {
width: 100%;
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-xl);
}
.params {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
grid-template-columns: 60px 1fr;
align-items: center;
max-width: 800px;
margin: 0 auto;
}
</style>

View file

@ -73,9 +73,12 @@
{#if query?.parameters?.length > 0}
<div class="params">
<BindingBuilder
bind:customParams={parameters.queryParams}
customParams={parameters.queryParams}
queryBindings={query.parameters}
bind:bindings
on:change={v => {
parameters.queryParams = { ...v.detail }
}}
/>
<IntegrationQueryEditor
height={200}

View file

@ -24,6 +24,7 @@
},
{
"name": "Delete Row",
"displayName": "Delete Rows",
"type": "data",
"component": "DeleteRow"
},

View file

@ -143,13 +143,12 @@
}
const openQueryParamsDrawer = () => {
tmpQueryParams = value.queryParams
tmpQueryParams = { ...value.queryParams }
drawer.show()
}
const getQueryValue = queries => {
value = queries.find(q => q._id === value._id) || value
return value
return queries.find(q => q._id === value._id) || value
}
const saveQueryParams = () => {
@ -176,7 +175,10 @@
<Layout noPadding gap="XS">
{#if getQueryParams(value).length > 0}
<BindingBuilder
bind:customParams={tmpQueryParams}
customParams={tmpQueryParams}
on:change={v => {
tmpQueryParams = { ...v.detail }
}}
queryBindings={getQueryParams(value)}
bind:bindings
/>

View file

@ -5,6 +5,9 @@
runtimeToReadableBinding,
} from "builderStore/dataBinding"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let bindable = true
export let queryBindings = []
@ -20,7 +23,10 @@
// The readable binding in the UI gets converted to a UUID value that the client understands
// for parsing, then converted back so we can display it the readable form in the UI
function onBindingChange(param, valueToParse) {
customParams[param] = readableToRuntimeBinding(bindings, valueToParse)
dispatch("change", {
...customParams,
[param]: readableToRuntimeBinding(bindings, valueToParse),
})
}
</script>

View file

@ -14,8 +14,9 @@
Tab,
Modal,
ModalContent,
notifications,
Divider,
} from "@budibase/bbui"
import { notifications, Divider } from "@budibase/bbui"
import ExtraQueryConfig from "./ExtraQueryConfig.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte"
import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte"
@ -28,6 +29,7 @@
import KeyValueBuilder from "./KeyValueBuilder.svelte"
import { fieldsToSchema, schemaToFields } from "helpers/data/utils"
import AccessLevelSelect from "./AccessLevelSelect.svelte"
import { ValidQueryNameRegex } from "@budibase/shared-core"
export let query
@ -47,6 +49,7 @@
let saveModal
let override = false
let navigateTo = null
let nameError = null
// seed the transformer
if (query && !query.transformer) {
@ -77,7 +80,7 @@
$: queryConfig = integrationInfo?.query
$: shouldShowQueryConfig = queryConfig && query.queryVerb
$: readQuery = query.queryVerb === "read" || query.readable
$: queryInvalid = !query.name || (readQuery && data.length === 0)
$: queryInvalid = !query.name || nameError || (readQuery && data.length === 0)
//Cast field in query preview response to number if specified by schema
$: {
@ -139,9 +142,10 @@
queryStr = JSON.stringify(query)
}
notifications.success("Query saved successfully")
return response
} catch (error) {
notifications.error("Error saving query")
notifications.error(error.message || "Error saving query")
}
}
</script>
@ -183,8 +187,14 @@
value={query.name}
on:input={e => {
let newValue = e.target.value || ""
query.name = newValue.trim()
if (newValue.match(ValidQueryNameRegex)) {
query.name = newValue.trim()
nameError = null
} else {
nameError = "Invalid query name"
}
}}
error={nameError}
/>
</div>
{#if queryConfig}
@ -250,9 +260,9 @@
size="L"
/>
</div>
<Body size="S"
>Add a JavaScript function to transform the query result.</Body
>
<Body size="S">
Add a JavaScript function to transform the query result.
</Body>
<CodeMirrorEditor
height={200}
label="Transformer"
@ -264,13 +274,12 @@
</div>
<div class="viewer-controls">
<Heading size="S">Results</Heading>
<ButtonGroup gap="XS">
<ButtonGroup gap="S">
<Button
cta
disabled={queryInvalid}
on:click={async () => {
await saveQuery()
notifications.success(`Query saved successfully`)
// Go to the correct URL if we just created a new query
if (!query._rev) {
$goto(`../../${query._id}`)

View file

@ -1,16 +1,21 @@
<script>
import { Heading, Body, Button, Icon } from "@budibase/bbui"
import { processStringSync } from "@budibase/string-templates"
import { auth } from "stores/portal"
import { goto } from "@roxi/routify"
import { UserAvatars } from "@budibase/frontend-core"
import { sdk } from "@budibase/shared-core"
export let app
export let lockedAction
$: editing = app.sessions?.length
$: isBuilder = sdk.users.isBuilder($auth.user, app?.devId)
const handleDefaultClick = () => {
if (window.innerWidth < 640) {
if (!isBuilder) {
goToApp()
} else if (window.innerWidth < 640) {
goToOverview()
} else {
goToBuilder()
@ -24,6 +29,10 @@
const goToOverview = () => {
$goto(`../../app/${app.devId}/settings`)
}
const goToApp = () => {
window.open(`/app/${app.name}`, "_blank")
}
</script>
<div class="app-row" on:click={lockedAction || handleDefaultClick}>
@ -39,7 +48,7 @@
</div>
<div class="updated">
{#if editing}
{#if editing && isBuilder}
Currently editing
<UserAvatars users={app.sessions} />
{:else if app.updatedAt}
@ -56,14 +65,21 @@
<Body size="S">{app.deployed ? "Published" : "Unpublished"}</Body>
</div>
<div class="app-row-actions">
<Button size="S" secondary on:click={lockedAction || goToOverview}>
Manage
</Button>
<Button size="S" primary on:click={lockedAction || goToBuilder}>
Edit
</Button>
</div>
{#if isBuilder}
<div class="app-row-actions">
<Button size="S" secondary on:click={lockedAction || goToOverview}>
Manage
</Button>
<Button size="S" primary on:click={lockedAction || goToBuilder}>
Edit
</Button>
</div>
{:else}
<!-- this can happen if an app builder has app user access to an app -->
<div class="app-row-actions">
<Button size="S" secondary>View</Button>
</div>
{/if}
</div>
<style>

View file

@ -2,6 +2,7 @@ export const FIELDS = {
STRING: {
name: "Text",
type: "string",
icon: "Text",
constraints: {
type: "string",
length: {},
@ -11,6 +12,7 @@ export const FIELDS = {
BARCODEQR: {
name: "Barcode/QR",
type: "barcodeqr",
icon: "Camera",
constraints: {
type: "string",
length: {},
@ -20,6 +22,7 @@ export const FIELDS = {
LONGFORM: {
name: "Long Form Text",
type: "longform",
icon: "TextAlignLeft",
constraints: {
type: "string",
length: {},
@ -29,6 +32,7 @@ export const FIELDS = {
OPTIONS: {
name: "Options",
type: "options",
icon: "Dropdown",
constraints: {
type: "string",
presence: false,
@ -38,6 +42,7 @@ export const FIELDS = {
ARRAY: {
name: "Multi-select",
type: "array",
icon: "Duplicate",
constraints: {
type: "array",
presence: false,
@ -47,6 +52,7 @@ export const FIELDS = {
NUMBER: {
name: "Number",
type: "number",
icon: "123",
constraints: {
type: "number",
presence: false,
@ -56,10 +62,12 @@ export const FIELDS = {
BIGINT: {
name: "BigInt",
type: "bigint",
icon: "TagBold",
},
BOOLEAN: {
name: "Boolean",
type: "boolean",
icon: "Boolean",
constraints: {
type: "boolean",
presence: false,
@ -68,6 +76,7 @@ export const FIELDS = {
DATETIME: {
name: "Date/Time",
type: "datetime",
icon: "Calendar",
constraints: {
type: "string",
length: {},
@ -81,6 +90,7 @@ export const FIELDS = {
ATTACHMENT: {
name: "Attachment",
type: "attachment",
icon: "Folder",
constraints: {
type: "array",
presence: false,
@ -89,6 +99,7 @@ export const FIELDS = {
LINK: {
name: "Relationship",
type: "link",
icon: "Link",
constraints: {
type: "array",
presence: false,
@ -97,11 +108,13 @@ export const FIELDS = {
FORMULA: {
name: "Formula",
type: "formula",
icon: "Calculator",
constraints: {},
},
JSON: {
name: "JSON",
type: "json",
icon: "Brackets",
constraints: {
type: "object",
presence: false,

View file

@ -12,12 +12,12 @@
} from "@budibase/bbui"
import { store } from "builderStore"
import { groups, licensing, apps, users, auth, admin } from "stores/portal"
import { fetchData } from "@budibase/frontend-core"
import { fetchData, Constants, Utils } from "@budibase/frontend-core"
import { sdk } from "@budibase/shared-core"
import { API } from "api"
import GroupIcon from "../../../portal/users/groups/_components/GroupIcon.svelte"
import RoleSelect from "components/common/RoleSelect.svelte"
import UpgradeModal from "components/common/users/UpgradeModal.svelte"
import { Constants, Utils } from "@budibase/frontend-core"
import { emailValidator } from "helpers/validation"
import { roles } from "stores/backend"
import { fly } from "svelte/transition"
@ -108,9 +108,9 @@
await usersFetch.refresh()
filteredUsers = $usersFetch.rows.map(user => {
const isBuilderOrAdmin = user.admin?.global || user.builder?.global
const isAdminOrBuilder = sdk.users.isAdminOrBuilder(user, prodAppId)
let role = undefined
if (isBuilderOrAdmin) {
if (isAdminOrBuilder) {
role = Constants.Roles.ADMIN
} else {
const appRole = Object.keys(user.roles).find(x => x === prodAppId)
@ -122,7 +122,7 @@
return {
...user,
role,
isBuilderOrAdmin,
isAdminOrBuilder,
}
})
}
@ -258,7 +258,7 @@
}
// Must exclude users who have explicit privileges
const userByEmail = filteredUsers.reduce((acc, user) => {
if (user.role || user.admin?.global || user.builder?.global) {
if (user.role || sdk.users.isAdminOrBuilder(user, prodAppId)) {
acc.push(user.email)
}
return acc
@ -389,9 +389,9 @@
}
const userTitle = user => {
if (user.admin?.global) {
if (sdk.users.isAdmin(user)) {
return "Admin"
} else if (user.builder?.global) {
} else if (sdk.users.isBuilder(user, prodAppId)) {
return "Developer"
} else {
return "App user"
@ -403,7 +403,7 @@
const role = $roles.find(role => role._id === user.role)
return `This user has been given ${role?.name} access from the ${user.group} group`
}
if (user.isBuilderOrAdmin) {
if (user.isAdminOrBuilder) {
return "This user's role grants admin access to all apps"
}
return null
@ -614,7 +614,7 @@
}}
autoWidth
align="right"
allowedRoles={user.isBuilderOrAdmin
allowedRoles={user.isAdminOrBuilder
? [Constants.Roles.ADMIN]
: null}
/>

View file

@ -2,14 +2,15 @@
import { Button, Layout } from "@budibase/bbui"
import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte"
import Panel from "components/design/Panel.svelte"
import { isActive, goto, redirect } from "@roxi/routify"
import { isActive, redirect, goto, params } from "@roxi/routify"
import BetaButton from "./_components/BetaButton.svelte"
import { datasources } from "stores/backend"
$: {
// If we ever don't have any data other than the users table, prompt the
// user to add some
if (!$datasources.hasData) {
// Don't redirect if setting up google sheets, or we lose the query parameter
if (!$datasources.hasData && !$params["?continue_google_setup"]) {
$redirect("./new")
}
}

View file

@ -75,6 +75,14 @@
{
"name": "Chart",
"icon": "GraphBarVertical",
"children": ["bar", "line", "area", "candlestick", "pie", "donut"]
"children": [
"bar",
"line",
"area",
"candlestick",
"pie",
"donut",
"histogram"
]
}
]

View file

@ -4,6 +4,8 @@
import { url, isActive } from "@roxi/routify"
import DeleteModal from "components/deploy/DeleteModal.svelte"
import { isOnlyUser } from "builderStore"
import { auth } from "stores/portal"
import { sdk } from "@budibase/shared-core"
let deleteModal
</script>
@ -44,22 +46,24 @@
url={$url("./version")}
active={$isActive("./version")}
/>
<div class="delete-action">
<AbsTooltip
position={TooltipPosition.Bottom}
text={$isOnlyUser
? null
: "Unavailable - another user is editing this app"}
>
<SideNavItem
text="Delete app"
disabled={!$isOnlyUser}
on:click={() => {
deleteModal.show()
}}
/>
</AbsTooltip>
</div>
{#if sdk.users.isGlobalBuilder($auth.user)}
<div class="delete-action">
<AbsTooltip
position={TooltipPosition.Bottom}
text={$isOnlyUser
? null
: "Unavailable - another user is editing this app"}
>
<SideNavItem
text="Delete app"
disabled={!$isOnlyUser}
on:click={() => {
deleteModal.show()
}}
/>
</AbsTooltip>
</div>
{/if}
</SideNav>
<slot />
</Content>

View file

@ -22,53 +22,39 @@
import Spaceman from "assets/bb-space-man.svg"
import Logo from "assets/bb-emblem.svg"
import { UserAvatar } from "@budibase/frontend-core"
import { helpers } from "@budibase/shared-core"
import { helpers, sdk } from "@budibase/shared-core"
let loaded = false
let userInfoModal
let changePasswordModal
onMount(async () => {
try {
await organisation.init()
await apps.load()
await groups.actions.init()
} catch (error) {
notifications.error("Error loading apps")
}
loaded = true
})
const publishedAppsOnly = app => app.status === AppStatus.DEPLOYED
$: userGroups = $groups.filter(group =>
group.users.find(user => user._id === $auth.user?._id)
)
let userApps = []
$: publishedApps = $apps.filter(publishedAppsOnly)
$: publishedApps = $apps.filter(app => app.status === AppStatus.DEPLOYED)
$: userApps = getUserApps(publishedApps, userGroups, $auth.user)
$: {
if (!Object.keys($auth.user?.roles).length && $auth.user?.userGroups) {
userApps =
$auth.user?.builder?.global || $auth.user?.admin?.global
? publishedApps
: publishedApps.filter(app => {
return userGroups.find(group => {
return groups.actions
.getGroupAppIds(group)
.map(role => apps.extractAppId(role))
.includes(app.appId)
})
})
} else {
userApps =
$auth.user?.builder?.global || $auth.user?.admin?.global
? publishedApps
: publishedApps.filter(app =>
Object.keys($auth.user?.roles)
.map(x => apps.extractAppId(x))
.includes(app.appId)
)
function getUserApps(publishedApps, userGroups, user) {
if (sdk.users.isAdmin(user)) {
return publishedApps
}
return publishedApps.filter(app => {
if (sdk.users.isBuilder(user, app.appId)) {
return true
}
if (!Object.keys(user?.roles).length && user?.userGroups) {
return userGroups.find(group => {
return groups.actions
.getGroupAppIds(group)
.map(role => apps.extractAppId(role))
.includes(app.appId)
})
} else {
return Object.keys($auth.user?.roles)
.map(x => apps.extractAppId(x))
.includes(app.appId)
}
})
}
function getUrl(app) {
@ -86,6 +72,17 @@
// Swallow error and do nothing
}
}
onMount(async () => {
try {
await organisation.init()
await apps.load()
await groups.actions.init()
} catch (error) {
notifications.error("Error loading apps")
}
loaded = true
})
</script>
{#if $auth.user && loaded}
@ -109,7 +106,7 @@
>
Update password
</MenuItem>
{#if $auth.isBuilder}
{#if sdk.users.hasBuilderPermissions($auth.user)}
<MenuItem
icon="UserDeveloper"
on:click={() => $goto("../portal")}

View file

@ -1,11 +1,12 @@
<script>
import { redirect } from "@roxi/routify"
import { auth } from "stores/portal"
import { sdk } from "@budibase/shared-core"
auth.checkQueryString()
$: {
if ($auth.user?.builder?.global) {
if (sdk.users.hasBuilderPermissions($auth.user)) {
$redirect(`./portal`)
} else if ($auth.user) {
$redirect(`./apps`)

View file

@ -3,6 +3,7 @@
import { goto } from "@roxi/routify"
import { auth, admin, licensing } from "stores/portal"
import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags"
import { sdk } from "@budibase/shared-core"
</script>
{#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING) && !$licensing.isEnterprisePlan}
@ -17,7 +18,7 @@
>
Upgrade
</Button>
{:else if !$admin.cloud && $auth.isAdmin}
{:else if !$admin.cloud && sdk.users.isAdmin($auth.user)}
<Button
cta
size="S"

View file

@ -8,13 +8,14 @@
import Logo from "./_components/Logo.svelte"
import UserDropdown from "./_components/UserDropdown.svelte"
import HelpMenu from "components/common/HelpMenu.svelte"
import { sdk } from "@budibase/shared-core"
let loaded = false
let mobileMenuVisible = false
let activeTab = "Apps"
$: $url(), updateActiveTab($menu)
$: fullscreen = !$apps.length
$: isOnboarding = !$apps.length && sdk.users.isGlobalBuilder($auth.user)
const updateActiveTab = menu => {
for (let entry of menu) {
@ -33,7 +34,7 @@
onMount(async () => {
// Prevent non-builders from accessing the portal
if ($auth.user) {
if (!$auth.user?.builder?.global) {
if (!sdk.users.hasBuilderPermissions($auth.user)) {
$redirect("../")
} else {
try {
@ -49,7 +50,7 @@
</script>
{#if $auth.user && loaded}
{#if fullscreen}
{#if isOnboarding}
<slot />
{:else}
<HelpMenu />

View file

@ -1,5 +1,5 @@
<script>
import { Layout, Body, Button } from "@budibase/bbui"
import { Layout, Heading, Body, Button } from "@budibase/bbui"
import { downloadStream } from "@budibase/frontend-core"
import Spinner from "components/common/Spinner.svelte"
@ -18,6 +18,7 @@
</script>
<Layout noPadding>
<Heading>System logs</Heading>
<Body>Download your latest logs to share with the Budibase team</Body>
<div class="download-button">
<Button cta on:click={download} disabled={loading}>
@ -25,7 +26,7 @@
{#if loading}
<Spinner size="10" />
{/if}
Download system logs
Download
</div>
</Button>
</div>

View file

@ -19,6 +19,7 @@
import DeleteLicenseKeyModal from "../../../../components/portal/licensing/DeleteLicenseKeyModal.svelte"
import { API } from "api"
import { onMount } from "svelte"
import { sdk } from "@budibase/shared-core"
$: license = $auth.user.license
$: upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
@ -176,7 +177,7 @@
})
</script>
{#if $auth.isAdmin}
{#if sdk.users.isAdmin($auth.user)}
<DeleteLicenseKeyModal
bind:this={deleteLicenseKeyModal}
onConfirm={deleteLicenseKey}

View file

@ -14,6 +14,7 @@
import { Constants } from "@budibase/frontend-core"
import { DashCard, Usage } from "components/usage"
import { PlanModel } from "constants"
import { sdk } from "@budibase/shared-core"
let staticUsage = []
let monthlyUsage = []
@ -51,7 +52,8 @@
$: accountPortalAccess = $auth?.user?.accountPortalAccess
$: quotaReset = quotaUsage?.quotaReset
$: canManagePlan =
($admin.cloud && accountPortalAccess) || (!$admin.cloud && $auth.isAdmin)
($admin.cloud && accountPortalAccess) ||
(!$admin.cloud && sdk.users.isAdmin($auth.user))
$: showButton = !usesInvoicing && accountPortalAccess

View file

@ -1,11 +1,19 @@
<script>
import { notifications } from "@budibase/bbui"
import { admin, apps, templates, licensing, groups } from "stores/portal"
import {
admin,
apps,
templates,
licensing,
groups,
auth,
} from "stores/portal"
import { onMount } from "svelte"
import { redirect } from "@roxi/routify"
import { sdk } from "@budibase/shared-core"
// Don't block loading if we've already hydrated state
let loaded = $apps.length > 0
let loaded = $apps.length != null
onMount(async () => {
try {
@ -25,7 +33,7 @@
}
// Go to new app page if no apps exists
if (!$apps.length) {
if (!$apps.length && sdk.users.isGlobalBuilder($auth.user)) {
$redirect("./onboarding")
}
} catch (error) {

View file

@ -15,6 +15,7 @@
import CreateAppModal from "components/start/CreateAppModal.svelte"
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import AccountLockedModal from "components/portal/licensing/AccountLockedModal.svelte"
import { sdk } from "@budibase/shared-core"
import { store, automationStore } from "builderStore"
import { API } from "api"
@ -203,40 +204,40 @@
})
</script>
{#if $apps.length}
<Page>
<Layout noPadding gap="L">
{#each Object.keys(automationErrors || {}) as appId}
<Notification
wide
dismissable
action={() => goToAutomationError(appId)}
type="error"
icon="Alert"
actionMessage={errorCount(automationErrors[appId]) > 1
? "View errors"
: "View error"}
on:dismiss={async () => {
await automationStore.actions.clearLogErrors({ appId })
await apps.load()
}}
message={automationErrorMessage(appId)}
/>
{/each}
<div class="title">
<div class="welcome">
<Layout noPadding gap="XS">
<Heading size="L">{welcomeHeader}</Heading>
<Body size="M">
Manage your apps and get a head start with templates
</Body>
</Layout>
</div>
<Page>
<Layout noPadding gap="L">
{#each Object.keys(automationErrors || {}) as appId}
<Notification
wide
dismissable
action={() => goToAutomationError(appId)}
type="error"
icon="Alert"
actionMessage={errorCount(automationErrors[appId]) > 1
? "View errors"
: "View error"}
on:dismiss={async () => {
await automationStore.actions.clearLogErrors({ appId })
await apps.load()
}}
message={automationErrorMessage(appId)}
/>
{/each}
<div class="title">
<div class="welcome">
<Layout noPadding gap="XS">
<Heading size="L">{welcomeHeader}</Heading>
<Body size="M">
Below you'll find the list of apps that you have access to
</Body>
</Layout>
</div>
</div>
{#if enrichedApps.length}
<Layout noPadding gap="L">
<div class="title">
{#if enrichedApps.length}
<Layout noPadding gap="L">
<div class="title">
{#if $auth.user && sdk.users.isGlobalBuilder($auth.user)}
<div class="buttons">
<Button
size="M"
@ -266,41 +267,46 @@
</Button>
{/if}
</div>
{#if enrichedApps.length > 1}
<div class="app-actions">
<Select
autoWidth
bind:value={sortBy}
placeholder={null}
options={[
{ label: "Sort by name", value: "name" },
{ label: "Sort by recently updated", value: "updated" },
{ label: "Sort by status", value: "status" },
]}
/>
<Search placeholder="Search" bind:value={searchTerm} />
</div>
{/if}
</div>
<div class="app-table">
{#each filteredApps as app (app.appId)}
<AppRow {app} lockedAction={usersLimitLockAction} />
{/each}
</div>
</Layout>
{/if}
{#if creatingFromTemplate}
<div class="empty-wrapper">
<img class="img-logo img-size" alt="logo" src={Logo} />
<p>Creating your Budibase app from your selected template...</p>
<Spinner size="10" />
{/if}
{#if enrichedApps.length > 1}
<div class="app-actions">
<Select
autoWidth
bind:value={sortBy}
placeholder={null}
options={[
{ label: "Sort by name", value: "name" },
{ label: "Sort by recently updated", value: "updated" },
{ label: "Sort by status", value: "status" },
]}
/>
<Search placeholder="Search" bind:value={searchTerm} />
</div>
{/if}
</div>
{/if}
</Layout>
</Page>
{/if}
<div class="app-table">
{#each filteredApps as app (app.appId)}
<AppRow {app} lockedAction={usersLimitLockAction} />
{/each}
</div>
</Layout>
{:else}
<div class="no-apps">
<img class="spaceman" alt="spaceman" src={Logo} width="100px" />
<Body weight="700">You haven't been given access to any apps yet</Body>
</div>
{/if}
{#if creatingFromTemplate}
<div class="empty-wrapper">
<img class="img-logo img-size" alt="logo" src={Logo} />
<p>Creating your Budibase app from your selected template...</p>
<Spinner size="10" />
</div>
{/if}
</Layout>
</Page>
<Modal
bind:this={creationModal}
@ -368,6 +374,16 @@
height: 160px;
}
.no-apps {
background-color: var(--spectrum-global-color-gray-100);
padding: calc(var(--spacing-xl) * 2);
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: var(--spacing-xl);
}
@media (max-width: 1000px) {
.img-logo {
display: none;

View file

@ -18,6 +18,7 @@
import { API } from "api"
import { onMount } from "svelte"
import { goto } from "@roxi/routify"
import { sdk } from "@budibase/shared-core"
const imageExtensions = [
".png",
@ -206,7 +207,7 @@
})
</script>
{#if $auth.isAdmin && mounted}
{#if sdk.users.isAdmin($auth.user) && mounted}
<Layout noPadding>
<Layout gap="XS" noPadding>
<div class="title">
@ -400,7 +401,7 @@
on:click={() => {
if (isCloud && $auth?.user?.accountPortalAccess) {
window.open($admin.accountPortalUrl + "/portal/upgrade", "_blank")
} else if ($auth.isAdmin) {
} else if (sdk.users.isAdmin($auth.user)) {
$goto("/builder/portal/account/upgrade")
}
}}

View file

@ -13,6 +13,7 @@
import { redirect } from "@roxi/routify"
import { API } from "api"
import { onMount } from "svelte"
import { sdk } from "@budibase/shared-core"
let diagnosticInfo = ""
@ -46,7 +47,7 @@
})
</script>
{#if $auth.isAdmin && diagnosticInfo}
{#if sdk.users.isAdmin($auth.user) && diagnosticInfo}
<Layout noPadding>
<Layout gap="XS">
<Heading size="M">Diagnostics</Heading>

View file

@ -13,10 +13,11 @@
import { auth, organisation, admin } from "stores/portal"
import { writable } from "svelte/store"
import { redirect } from "@roxi/routify"
import { sdk } from "@budibase/shared-core"
// Only admins allowed here
$: {
if (!$auth.isAdmin) {
if (!sdk.users.isAdmin($auth.user)) {
$redirect("../../portal")
}
}
@ -50,7 +51,7 @@
}
</script>
{#if $auth.isAdmin}
{#if sdk.users.isAdmin($auth.user)}
<Layout noPadding>
<Layout gap="XS" noPadding>
<Heading size="M">Organisation</Heading>

View file

@ -14,6 +14,7 @@
import { API } from "api"
import { auth, admin } from "stores/portal"
import { redirect } from "@roxi/routify"
import { sdk } from "@budibase/shared-core"
let version
let loaded = false
@ -25,7 +26,7 @@
// Only admins allowed here
$: {
if (!$auth.isAdmin || $admin.cloud) {
if (!sdk.users.isAdmin($auth.user) || $admin.cloud) {
$redirect("../../portal")
}
}
@ -89,7 +90,7 @@
})
</script>
{#if $auth.isAdmin}
{#if sdk.users.isAdmin($auth.user)}
<Layout noPadding>
<Layout gap="XS" noPadding>
<Heading size="M">Version</Heading>

View file

@ -20,6 +20,7 @@
import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte"
import GroupIcon from "./_components/GroupIcon.svelte"
import GroupUsers from "./_components/GroupUsers.svelte"
import { sdk } from "@budibase/shared-core"
export let groupId
@ -46,7 +47,7 @@
let editModal, deleteModal
$: scimEnabled = $features.isScimEnabled
$: readonly = !$auth.isAdmin || scimEnabled
$: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled
$: group = $groups.find(x => x._id === groupId)
$: groupApps = $apps
.filter(app =>

View file

@ -3,6 +3,7 @@
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
import { createPaginationStore } from "helpers/pagination"
import { auth, groups, users } from "stores/portal"
import { sdk } from "@budibase/shared-core"
export let groupId
export let onUsersUpdated
@ -13,7 +14,7 @@
let prevSearch = undefined
let pageInfo = createPaginationStore()
$: readonly = !$auth.isAdmin
$: readonly = !sdk.users.isAdmin($auth.user)
$: page = $pageInfo.page
$: searchUsers(page, searchTerm)
$: group = $groups.find(x => x._id === groupId)

View file

@ -9,6 +9,7 @@
import { setContext } from "svelte"
import ScimBanner from "../../_components/SCIMBanner.svelte"
import RemoveUserTableRenderer from "../_components/RemoveUserTableRenderer.svelte"
import { sdk } from "@budibase/shared-core"
export let groupId
@ -49,7 +50,7 @@
]
$: scimEnabled = $features.isScimEnabled
$: readonly = !$auth.isAdmin || scimEnabled
$: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled
const removeUser = async id => {
await groups.actions.removeUser(groupId, id)

View file

@ -2,6 +2,7 @@
import { ActionButton } from "@budibase/bbui"
import { getContext } from "svelte"
import { auth } from "stores/portal"
import { sdk } from "@budibase/shared-core"
export let value
@ -13,6 +14,10 @@
}
</script>
<ActionButton disabled={!$auth.isAdmin} size="S" on:click={onClick}>
<ActionButton
disabled={!sdk.users.isAdmin($auth.user)}
size="S"
on:click={onClick}
>
Remove
</ActionButton>

View file

@ -22,6 +22,7 @@
import GroupNameTableRenderer from "./_components/GroupNameTableRenderer.svelte"
import { goto } from "@roxi/routify"
import ScimBanner from "../_components/SCIMBanner.svelte"
import { sdk } from "@budibase/shared-core"
const DefaultGroup = {
name: "",
@ -40,7 +41,7 @@
{ column: "roles", component: GroupAppsTableRenderer },
]
$: readonly = !$auth.isAdmin
$: readonly = !sdk.users.isAdmin($auth.user)
$: schema = {
name: { displayName: "Group", width: "2fr", minWidth: "200px" },
users: { sortable: false, width: "1fr" },

View file

@ -31,6 +31,7 @@
import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte"
import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte"
import ScimBanner from "../_components/SCIMBanner.svelte"
import { sdk } from "@budibase/shared-core"
export let userId
@ -87,8 +88,8 @@
$: scimEnabled = $features.isScimEnabled
$: isSSO = !!user?.provider
$: readonly = !$auth.isAdmin || scimEnabled
$: privileged = user?.admin?.global || user?.builder?.global
$: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled
$: privileged = sdk.users.isAdminOrBuilder(user)
$: nameLabel = getNameLabel(user)
$: filteredGroups = getFilteredGroups($groups, searchTerm)
$: availableApps = getAvailableApps($apps, privileged, user?.roles)
@ -97,9 +98,9 @@
return y._id === userId
})
})
$: globalRole = user?.admin?.global
$: globalRole = sdk.users.isAdmin(user)
? "admin"
: user?.builder?.global
: sdk.users.isBuilder(user)
? "developer"
: "appUser"
@ -285,7 +286,7 @@
<div class="field">
<Label size="L">Role</Label>
<Select
disabled={!$auth.isAdmin}
disabled={!sdk.users.isAdmin($auth.user)}
value={globalRole}
options={Constants.BudibaseRoleOptions}
on:change={updateUserRole}

View file

@ -1,11 +1,12 @@
<script>
import { Icon } from "@budibase/bbui"
import { apps } from "stores/portal"
import { sdk } from "@budibase/shared-core"
export let value
export let row
$: priviliged = row?.admin?.global || row?.builder?.global
$: priviliged = sdk.users.isAdminOrBuilder(row)
$: count = priviliged ? $apps.length : value?.length || 0
</script>

View file

@ -2,6 +2,7 @@
import { ActionButton } from "@budibase/bbui"
import { getContext } from "svelte"
import { auth } from "stores/portal"
import { sdk } from "@budibase/shared-core"
export let value
@ -13,6 +14,10 @@
}
</script>
<ActionButton disabled={!$auth.isAdmin} size="S" on:click={onClick}>
<ActionButton
disabled={!sdk.users.isAdmin($auth.user)}
size="S"
on:click={onClick}
>
Remove
</ActionButton>

View file

@ -2,6 +2,7 @@
import { createEventDispatcher } from "svelte"
import { Body, Select, ModalContent, notifications } from "@budibase/bbui"
import { users } from "stores/portal"
import { sdk } from "@budibase/shared-core"
export let app
export let user
@ -15,7 +16,7 @@
.filter(role => role._id !== "PUBLIC")
.map(role => ({ value: role._id, label: role.name }))
if (!user?.builder?.global) {
if (!sdk.users.isBuilder(user, app?.appId)) {
options.push({ value: NO_ACCESS, label: "No Access" })
}
let selectedRole = user?.roles?.[app?._id]

View file

@ -39,6 +39,7 @@
import { API } from "api"
import { OnboardingType } from "../../../../../constants"
import ScimBanner from "../_components/SCIMBanner.svelte"
import { sdk } from "@budibase/shared-core"
const fetch = fetchData({
API,
@ -66,7 +67,7 @@
let userData = []
$: isOwner = $auth.accountPortalAccess && $admin.cloud
$: readonly = !$auth.isAdmin || $features.isScimEnabled
$: readonly = !sdk.users.isAdmin($auth.user) || $features.isScimEnabled
$: debouncedUpdateFetch(searchEmail)
$: schema = {

View file

@ -2,6 +2,7 @@ import { derived, writable, get } from "svelte/store"
import { API } from "api"
import { admin } from "stores/portal"
import analytics from "analytics"
import { sdk } from "@budibase/shared-core"
export function createAuthStore() {
const auth = writable({
@ -13,13 +14,6 @@ export function createAuthStore() {
postLogout: false,
})
const store = derived(auth, $store => {
let isAdmin = false
let isBuilder = false
if ($store.user) {
const user = $store.user
isAdmin = !!user.admin?.global
isBuilder = !!user.builder?.global
}
return {
user: $store.user,
accountPortalAccess: $store.accountPortalAccess,
@ -27,8 +21,6 @@ export function createAuthStore() {
tenantSet: $store.tenantSet,
loaded: $store.loaded,
postLogout: $store.postLogout,
isAdmin,
isBuilder,
isSSO: !!$store.user?.provider,
}
})
@ -57,8 +49,8 @@ export function createAuthStore() {
name: user.account?.name,
user_id: user._id,
tenant: user.tenantId,
admin: user?.admin?.global,
builder: user?.builder?.global,
admin: sdk.users.isAdmin(user),
builder: sdk.users.isBuilder(user),
"Company size": user.account?.size,
"Job role": user.account?.profession,
},

View file

@ -2,8 +2,12 @@ import { derived } from "svelte/store"
import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags"
import { admin } from "./admin"
import { auth } from "./auth"
import { sdk } from "@budibase/shared-core"
export const menu = derived([admin, auth], ([$admin, $auth]) => {
const user = $auth?.user
const isAdmin = sdk.users.isAdmin(user)
const cloud = $admin?.cloud
// Determine user sub pages
let userSubPages = [
{
@ -24,19 +28,21 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
title: "Apps",
href: "/builder/portal/apps",
},
{
]
if (sdk.users.isGlobalBuilder(user)) {
menu.push({
title: "Users",
href: "/builder/portal/users",
subPages: userSubPages,
},
{
})
menu.push({
title: "Plugins",
href: "/builder/portal/plugins",
},
]
})
}
// Add settings page for admins
if ($auth.isAdmin) {
if (isAdmin) {
let settingsSubPages = [
{
title: "Auth",
@ -59,7 +65,7 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
href: "/builder/portal/settings/environment",
},
]
if (!$admin.cloud) {
if (!cloud) {
settingsSubPages.push({
title: "Version",
href: "/builder/portal/settings/version",
@ -84,38 +90,35 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
href: "/builder/portal/account/usage",
},
]
if ($auth.isAdmin) {
if (isAdmin) {
accountSubPages.push({
title: "Audit Logs",
href: "/builder/portal/account/auditLogs",
})
if (!$admin.cloud) {
if (!cloud) {
accountSubPages.push({
title: "System Logs",
href: "/builder/portal/account/systemLogs",
})
}
}
if ($admin.cloud && $auth?.user?.accountPortalAccess) {
if (cloud && user?.accountPortalAccess) {
accountSubPages.push({
title: "Upgrade",
href: $admin.accountPortalUrl + "/portal/upgrade",
href: $admin?.accountPortalUrl + "/portal/upgrade",
})
} else if (!$admin.cloud && $auth.isAdmin) {
} else if (!cloud && isAdmin) {
accountSubPages.push({
title: "Upgrade",
href: "/builder/portal/account/upgrade",
})
}
// add license check here
if (
$auth?.user?.accountPortalAccess &&
$auth.user.account.stripeCustomerId
) {
if (user?.accountPortalAccess && user.account.stripeCustomerId) {
accountSubPages.push({
title: "Billing",
href: $admin.accountPortalUrl + "/portal/billing",
href: $admin?.accountPortalUrl + "/portal/billing",
})
}
menu.push({

View file

@ -2,6 +2,7 @@ import { writable } from "svelte/store"
import { API } from "api"
import { update } from "lodash"
import { licensing } from "."
import { sdk } from "@budibase/shared-core"
export function createUsersStore() {
const { subscribe, set } = writable({})
@ -111,8 +112,12 @@ export function createUsersStore() {
return await API.saveUser(user)
}
const getUserRole = ({ admin, builder }) =>
admin?.global ? "admin" : builder?.global ? "developer" : "appUser"
const getUserRole = user =>
sdk.users.isAdmin(user)
? "admin"
: sdk.users.isBuilder(user)
? "developer"
: "appUser"
const refreshUsage =
fn =>

View file

@ -14,6 +14,7 @@
"tsc": "tsc -p tsconfig.build.json",
"pkg": "pkg . --out-path build --no-bytecode --public --public-packages \"*\" -C GZip",
"build": "yarn prebuild && yarn rename && yarn tsc && yarn pkg && yarn postbuild",
"check:types": "tsc -p tsconfig.json --noEmit --paths null",
"postbuild": "rm -rf prebuilds 2> /dev/null"
},
"pkg": {
@ -45,7 +46,7 @@
"lookpath": "1.1.0",
"node-fetch": "2.6.7",
"pkg": "5.8.0",
"posthog-node": "1.0.7",
"posthog-node": "1.3.0",
"pouchdb": "7.3.0",
"pouchdb-replication-stream": "1.2.9",
"randomstring": "1.1.5",
@ -53,9 +54,9 @@
"yaml": "^2.1.1"
},
"devDependencies": {
"@swc/core": "^1.3.25",
"@swc/jest": "^0.2.24",
"@types/jest": "^29.4.0",
"@swc/core": "1.3.71",
"@swc/jest": "0.2.27",
"@types/jest": "29.5.3",
"@types/node-fetch": "2.6.1",
"@types/pouchdb": "^6.4.0",
"copyfiles": "^2.4.1",
@ -70,7 +71,8 @@
"dependsOn": [
{
"projects": [
"@budibase/backend-core"
"@budibase/backend-core",
"@budibase/string-templates"
],
"target": "build"
}

View file

@ -16,7 +16,6 @@
"require": ["tsconfig-paths/register"],
"swc": true
},
"references": [{ "path": "../types" }, { "path": "../backend-core" }],
"include": ["src/**/*", "package.json"],
"exclude": ["node_modules", "dist"]
}

View file

@ -2205,6 +2205,147 @@
}
]
},
"histogram": {
"name": "Histogram Chart",
"description": "Histogram chart",
"icon": "Histogram",
"size": {
"width": 600,
"height": 400
},
"requiredAncestors": ["dataprovider"],
"settings": [
{
"type": "text",
"label": "Title",
"key": "title"
},
{
"type": "dataProvider",
"label": "Provider",
"key": "dataProvider",
"required": true
},
{
"type": "field",
"label": "Data column",
"key": "valueColumn",
"dependsOn": "dataProvider",
"required": true
},
{
"type": "text",
"label": "Y axis label",
"key": "yAxisLabel",
"defaultValue": "Frequency"
},
{
"type": "text",
"label": "X axis label",
"key": "xAxisLabel"
},
{
"type": "number",
"label": "Bucket count",
"key": "bucketCount",
"defaultValue": 10,
"min": 2
},
{
"type": "boolean",
"label": "Data labels",
"key": "dataLabels",
"defaultValue": false
},
{
"type": "text",
"label": "Width",
"key": "width"
},
{
"type": "text",
"label": "Height",
"key": "height",
"defaultValue": "400"
},
{
"type": "select",
"label": "Colors",
"key": "palette",
"defaultValue": "Palette 1",
"options": [
"Custom",
"Palette 1",
"Palette 2",
"Palette 3",
"Palette 4",
"Palette 5",
"Palette 6",
"Palette 7",
"Palette 8",
"Palette 9",
"Palette 10"
]
},
{
"type": "color",
"label": "C1",
"key": "c1",
"dependsOn": {
"setting": "palette",
"value": "Custom"
}
},
{
"type": "color",
"label": "C2",
"key": "c2",
"dependsOn": {
"setting": "palette",
"value": "Custom"
}
},
{
"type": "color",
"label": "C3",
"key": "c3",
"dependsOn": {
"setting": "palette",
"value": "Custom"
}
},
{
"type": "color",
"label": "C4",
"key": "c4",
"dependsOn": {
"setting": "palette",
"value": "Custom"
}
},
{
"type": "color",
"label": "C5",
"key": "c5",
"dependsOn": {
"setting": "palette",
"value": "Custom"
}
},
{
"type": "boolean",
"label": "Animate",
"key": "animate",
"defaultValue": true
},
{
"type": "boolean",
"label": "Horizontal",
"key": "horizontal",
"defaultValue": false
}
]
},
"form": {
"name": "Form",
"icon": "Form",
@ -3954,6 +4095,10 @@
"label": "Bar",
"value": "bar"
},
{
"label": "Histogram",
"value": "histogram"
},
{
"label": "Line",
"value": "line"
@ -4204,6 +4349,47 @@
}
]
},
{
"section": true,
"name": "Histogram Chart",
"icon": "Histogram",
"dependsOn": {
"setting": "chartType",
"value": "histogram"
},
"settings": [
{
"type": "field",
"label": "Value column",
"key": "valueColumn",
"dependsOn": "dataSource",
"required": true
},
{
"type": "text",
"label": "Y axis label",
"key": "yAxisLabel"
},
{
"type": "text",
"label": "X axis label",
"key": "xAxisLabel"
},
{
"type": "boolean",
"label": "Horizontal",
"key": "horizontal",
"defaultValue": false
},
{
"type": "number",
"label": "Bucket count",
"key": "bucketCount",
"defaultValue": 10,
"min": 2
}
]
},
{
"section": true,
"name": "Line Chart",
@ -5223,11 +5409,7 @@
"type": "boolean",
"label": "Hide notifications",
"key": "notificationOverride",
"defaultValue": false,
"dependsOn": {
"setting": "showSaveButton",
"value": true
}
"defaultValue": false
}
]
}

Some files were not shown because too many files have changed in this diff Show more