Merge branch 'develop' of github.com:Budibase/budibase into just-dataspace-things
This commit is contained in:
commit
c4e3667b6c
226 changed files with 5936 additions and 1951 deletions
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -2,10 +2,11 @@
|
||||||
name: Bug report
|
name: Bug report
|
||||||
about: Create a report to help us improve
|
about: Create a report to help us improve
|
||||||
title: ''
|
title: ''
|
||||||
labels: bug
|
labels: bug, linear
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Checklist**
|
**Checklist**
|
||||||
- [ ] I have searched budibase discussions and github issues to check if my issue already exists
|
- [ ] I have searched budibase discussions and github issues to check if my issue already exists
|
||||||
|
|
||||||
|
|
2
.github/workflows/deploy-cloud.yaml
vendored
2
.github/workflows/deploy-cloud.yaml
vendored
|
@ -22,7 +22,7 @@ jobs:
|
||||||
|
|
||||||
- name: Pull values.yaml from budibase-infra
|
- name: Pull values.yaml from budibase-infra
|
||||||
run: |
|
run: |
|
||||||
curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \
|
curl -H "Authorization: token ${{ secrets.GH_ACCESS_TOKEN }}" \
|
||||||
-H 'Accept: application/vnd.github.v3.raw' \
|
-H 'Accept: application/vnd.github.v3.raw' \
|
||||||
-o values.production.yaml \
|
-o values.production.yaml \
|
||||||
-L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/values.yaml
|
-L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/values.yaml
|
||||||
|
|
31
.github/workflows/deploy-preprod.yml
vendored
31
.github/workflows/deploy-preprod.yml
vendored
|
@ -1,18 +1,16 @@
|
||||||
name: Budibase Deploy Preprod
|
name: "deploy-preprod"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
workflow_call:
|
||||||
env:
|
|
||||||
INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }}
|
|
||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
deploy-to-legacy-preprod-env:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
- name: 'Get Previous tag'
|
||||||
|
id: previoustag
|
||||||
|
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||||
|
|
||||||
- name: Configure AWS Credentials
|
- name: Configure AWS Credentials
|
||||||
uses: aws-actions/configure-aws-credentials@v1
|
uses: aws-actions/configure-aws-credentials@v1
|
||||||
|
@ -21,23 +19,16 @@ jobs:
|
||||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
aws-region: eu-west-1
|
aws-region: eu-west-1
|
||||||
|
|
||||||
|
|
||||||
- name: Get the latest budibase release version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
release_version=$(cat lerna.json | jq -r '.version')
|
|
||||||
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Pull values.yaml from budibase-infra
|
- name: Pull values.yaml from budibase-infra
|
||||||
run: |
|
run: |
|
||||||
curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \
|
curl -H "Authorization: token ${{ secrets.GH_ACCESS_TOKEN }}" \
|
||||||
-H 'Accept: application/vnd.github.v3.raw' \
|
-H 'Accept: application/vnd.github.v3.raw' \
|
||||||
-o values.preprod.yaml \
|
-o values.preprod.yaml \
|
||||||
-L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/budibase-preprod/values.yaml
|
-L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/budibase-preprod/values.yaml
|
||||||
wc -l values.preprod.yaml
|
wc -l values.preprod.yaml
|
||||||
|
|
||||||
- name: Deploy to Preprod Environment
|
- name: Deploy to Preprod Environment
|
||||||
uses: glopezep/helm@v1.7.1
|
uses: budibase/helm@v1.8.0
|
||||||
with:
|
with:
|
||||||
release: budibase-preprod
|
release: budibase-preprod
|
||||||
namespace: budibase
|
namespace: budibase
|
||||||
|
@ -46,7 +37,7 @@ jobs:
|
||||||
helm: helm3
|
helm: helm3
|
||||||
values: |
|
values: |
|
||||||
globals:
|
globals:
|
||||||
appVersion: v${{ env.RELEASE_VERSION }}
|
appVersion: ${{ steps.previoustag.outputs.tag }}
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
nginx: true
|
nginx: true
|
||||||
|
@ -61,5 +52,5 @@ jobs:
|
||||||
uses: tsickert/discord-webhook@v4.0.0
|
uses: tsickert/discord-webhook@v4.0.0
|
||||||
with:
|
with:
|
||||||
webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }}
|
webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }}
|
||||||
content: "Preprod Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Pre-prod."
|
content: "Preprod Deployment Complete: ${{ steps.previoustag.outputs.tag }} deployed to Budibase Pre-prod."
|
||||||
embed-title: ${{ env.RELEASE_VERSION }}
|
embed-title: ${{ steps.previoustag.outputs.tag }}
|
88
.github/workflows/deploy-release.yml
vendored
88
.github/workflows/deploy-release.yml
vendored
|
@ -1,88 +0,0 @@
|
||||||
name: Budibase Deploy Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Configure AWS Credentials
|
|
||||||
uses: aws-actions/configure-aws-credentials@v1
|
|
||||||
with:
|
|
||||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
|
||||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
|
||||||
aws-region: eu-west-1
|
|
||||||
|
|
||||||
- name: Fail if branch is not develop
|
|
||||||
if: github.ref != 'refs/heads/develop'
|
|
||||||
run: |
|
|
||||||
echo "Ref is not develop, you must run this job from develop."
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Get the latest budibase release version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
release_version=$(cat lerna.json | jq -r '.version')
|
|
||||||
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Pull values.yaml from budibase-infra
|
|
||||||
run: |
|
|
||||||
curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \
|
|
||||||
-H 'Accept: application/vnd.github.v3.raw' \
|
|
||||||
-o values.release.yaml \
|
|
||||||
-L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/budibase-release/values.yaml
|
|
||||||
wc -l values.release.yaml
|
|
||||||
|
|
||||||
- name: Deploy to Release Environment
|
|
||||||
uses: glopezep/helm@v1.7.1
|
|
||||||
with:
|
|
||||||
release: budibase-release
|
|
||||||
namespace: budibase
|
|
||||||
chart: charts/budibase
|
|
||||||
token: ${{ github.token }}
|
|
||||||
helm: helm3
|
|
||||||
values: |
|
|
||||||
globals:
|
|
||||||
appVersion: develop
|
|
||||||
ingress:
|
|
||||||
enabled: true
|
|
||||||
nginx: true
|
|
||||||
value-files: >-
|
|
||||||
[
|
|
||||||
"values.release.yaml"
|
|
||||||
]
|
|
||||||
env:
|
|
||||||
KUBECONFIG_FILE: '${{ secrets.RELEASE_KUBECONFIG }}'
|
|
||||||
|
|
||||||
- name: Re roll app-service
|
|
||||||
uses: actions-hub/kubectl@master
|
|
||||||
env:
|
|
||||||
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
|
|
||||||
with:
|
|
||||||
args: rollout restart deployment app-service -n budibase
|
|
||||||
|
|
||||||
- name: Re roll proxy-service
|
|
||||||
uses: actions-hub/kubectl@master
|
|
||||||
env:
|
|
||||||
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
|
|
||||||
with:
|
|
||||||
args: rollout restart deployment proxy-service -n budibase
|
|
||||||
|
|
||||||
- name: Re roll worker-service
|
|
||||||
uses: actions-hub/kubectl@master
|
|
||||||
env:
|
|
||||||
KUBE_CONFIG: ${{ secrets.RELEASE_KUBECONFIG_BASE64 }}
|
|
||||||
with:
|
|
||||||
args: rollout restart deployment worker-service -n budibase
|
|
||||||
|
|
||||||
|
|
||||||
- name: Discord Webhook Action
|
|
||||||
uses: tsickert/discord-webhook@v4.0.0
|
|
||||||
with:
|
|
||||||
webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }}
|
|
||||||
content: "Release Env Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Release Env."
|
|
||||||
embed-title: ${{ env.RELEASE_VERSION }}
|
|
|
@ -35,9 +35,8 @@ env:
|
||||||
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release-images:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Fail if branch is not master
|
- name: Fail if branch is not master
|
||||||
if: github.ref != 'refs/heads/master'
|
if: github.ref != 'refs/heads/master'
|
||||||
|
@ -57,14 +56,6 @@ jobs:
|
||||||
- run: yarn lint
|
- run: yarn lint
|
||||||
- run: yarn build
|
- run: yarn build
|
||||||
- run: yarn build:sdk
|
- run: yarn build:sdk
|
||||||
- run: yarn test
|
|
||||||
|
|
||||||
- name: Configure AWS Credentials
|
|
||||||
uses: aws-actions/configure-aws-credentials@v1
|
|
||||||
with:
|
|
||||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
|
||||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
|
||||||
aws-region: eu-west-1
|
|
||||||
|
|
||||||
- name: Publish budibase packages to NPM
|
- name: Publish budibase packages to NPM
|
||||||
env:
|
env:
|
||||||
|
@ -91,45 +82,62 @@ jobs:
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}
|
||||||
BUDIBASE_RELEASE_VERSION: ${{ steps.previoustag.outputs.tag }}
|
BUDIBASE_RELEASE_VERSION: ${{ steps.previoustag.outputs.tag }}
|
||||||
|
|
||||||
- name: Configure AWS Credentials
|
release-helm-chart:
|
||||||
uses: aws-actions/configure-aws-credentials@v1
|
needs: [release-images]
|
||||||
with:
|
runs-on: ubuntu-latest
|
||||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
steps:
|
||||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
- uses: actions/checkout@v2
|
||||||
aws-region: eu-west-1
|
- name: Setup Helm
|
||||||
|
uses: azure/setup-helm@v1
|
||||||
|
id: helm-install
|
||||||
|
|
||||||
- name: Pull values.yaml from budibase-infra
|
- name: 'Get Previous tag'
|
||||||
|
id: previoustag
|
||||||
|
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||||
|
|
||||||
|
# due to helm repo index issue: https://github.com/helm/helm/issues/7363
|
||||||
|
# we need to create new package in a different dir, merge the index and move the package back
|
||||||
|
- name: Build and release helm chart
|
||||||
run: |
|
run: |
|
||||||
curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \
|
git config user.name "Budibase Helm Bot"
|
||||||
-H 'Accept: application/vnd.github.v3.raw' \
|
git config user.email "<>"
|
||||||
-o values.preprod.yaml \
|
git reset --hard
|
||||||
-L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/budibase-preprod/values.yaml
|
git pull
|
||||||
wc -l values.preprod.yaml
|
mkdir sync
|
||||||
|
echo "Packaging chart to sync dir"
|
||||||
- name: Deploy to Preprod Environment
|
helm package charts/budibase --version 0.0.0-master --app-version "$RELEASE_VERSION" --destination sync
|
||||||
uses: glopezep/helm@v1.7.1
|
echo "Packaging successful"
|
||||||
with:
|
git checkout gh-pages
|
||||||
release: budibase-preprod
|
echo "Indexing helm repo"
|
||||||
namespace: budibase
|
helm repo index --merge docs/index.yaml sync
|
||||||
chart: charts/budibase
|
mv -f sync/* docs
|
||||||
token: ${{ github.token }}
|
rm -rf sync
|
||||||
helm: helm3
|
echo "Pushing new helm release"
|
||||||
values: |
|
git add -A
|
||||||
globals:
|
git commit -m "Helm Release: ${{ env.RELEASE_VERSION }}"
|
||||||
appVersion: ${{ steps.previoustag.outputs.tag }}
|
git push
|
||||||
ingress:
|
|
||||||
enabled: true
|
|
||||||
nginx: true
|
|
||||||
value-files: >-
|
|
||||||
[
|
|
||||||
"values.preprod.yaml"
|
|
||||||
]
|
|
||||||
env:
|
env:
|
||||||
KUBECONFIG_FILE: '${{ secrets.PREPROD_KUBECONFIG }}'
|
RELEASE_VERSION: ${{ steps.previoustag.outputs.tag }}
|
||||||
|
|
||||||
- name: Discord Webhook Action
|
deploy-to-legacy-preprod-env:
|
||||||
uses: tsickert/discord-webhook@v4.0.0
|
needs: [release-images]
|
||||||
|
uses: ./.github/workflows/deploy-preprod.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
# Trigger deploy to new EKS preprod environment
|
||||||
|
trigger-deploy-to-preprod-env:
|
||||||
|
needs: [release-helm-chart]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: 'Get Previous tag'
|
||||||
|
id: previoustag
|
||||||
|
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||||
|
|
||||||
|
- uses: passeidireto/trigger-external-workflow-action@main
|
||||||
|
env:
|
||||||
|
PAYLOAD_VERSION: ${{ steps.previoustag.outputs.tag }}
|
||||||
with:
|
with:
|
||||||
webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }}
|
repository: budibase/budibase-deploys
|
||||||
content: "Preprod Deployment Complete: ${{ steps.previoustag.outputs.tag }} deployed to Budibase Pre-prod."
|
event: budicloud-preprod-deploy
|
||||||
embed-title: ${{ steps.previoustag.outputs.tag }}
|
github_pat: ${{ secrets.GH_ACCESS_TOKEN }}
|
6
.github/workflows/release-selfhost.yml
vendored
6
.github/workflows/release-selfhost.yml
vendored
|
@ -16,9 +16,13 @@ jobs:
|
||||||
|
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
node-version: 14.x
|
|
||||||
fetch_depth: 0
|
fetch_depth: 0
|
||||||
|
|
||||||
|
- name: Use Node.js 14.x
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: 14.x
|
||||||
|
|
||||||
- name: Get the latest budibase release version
|
- name: Get the latest budibase release version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
|
|
31
.github/workflows/smoke_test.yaml
vendored
31
.github/workflows/smoke_test.yaml
vendored
|
@ -1,31 +0,0 @@
|
||||||
name: Budibase Nightly Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 5 * * *" # every day at 5AM
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
nightly:
|
|
||||||
runs-on: [self-hosted, qa]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Use Node.js 14.x
|
|
||||||
uses: actions/setup-node@v1
|
|
||||||
with:
|
|
||||||
node-version: 14.x
|
|
||||||
- name: QA Core Integration Tests
|
|
||||||
run: |
|
|
||||||
cd qa-core
|
|
||||||
yarn
|
|
||||||
yarn api:test:ci
|
|
||||||
env:
|
|
||||||
BUDIBASE_HOST: budicloud.qa.budibase.net
|
|
||||||
BUDIBASE_ACCOUNTS_URL: https://account-portal.budicloud.qa.budibase.net
|
|
||||||
|
|
||||||
- name: Cypress Discord Notify
|
|
||||||
run: yarn test:notify
|
|
||||||
env:
|
|
||||||
WEBHOOK_URL: ${{ secrets.BUDI_QA_WEBHOOK }}
|
|
||||||
GITHUB_RUN_URL: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID
|
|
|
@ -51,6 +51,14 @@ spec:
|
||||||
value: {{ tpl .Values.services.proxy.upstreams.minio . | quote }}
|
value: {{ tpl .Values.services.proxy.upstreams.minio . | quote }}
|
||||||
- name: COUCHDB_UPSTREAM_URL
|
- name: COUCHDB_UPSTREAM_URL
|
||||||
value: {{ .Values.services.couchdb.url | default (tpl .Values.services.proxy.upstreams.couchdb .) | quote }}
|
value: {{ .Values.services.couchdb.url | default (tpl .Values.services.proxy.upstreams.couchdb .) | quote }}
|
||||||
|
{{ if .Values.services.proxy.proxyRateLimitWebhooksPerSecond }}
|
||||||
|
- name: PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND
|
||||||
|
value: {{ .Values.services.proxy.proxyRateLimitWebhooksPerSecond | quote }}
|
||||||
|
{{ end }}
|
||||||
|
{{ if .Values.services.proxy.proxyRateLimitApiPerSecond }}
|
||||||
|
- name: PROXY_RATE_LIMIT_API_PER_SECOND
|
||||||
|
value: {{ .Values.services.proxy.proxyRateLimitApiPerSecond | quote }}
|
||||||
|
{{ end }}
|
||||||
- name: RESOLVER
|
- name: RESOLVER
|
||||||
{{ if .Values.services.proxy.resolver }}
|
{{ if .Values.services.proxy.resolver }}
|
||||||
value: {{ .Values.services.proxy.resolver }}
|
value: {{ .Values.services.proxy.resolver }}
|
||||||
|
|
|
@ -245,7 +245,7 @@ couchdb:
|
||||||
## The CouchDB image
|
## The CouchDB image
|
||||||
image:
|
image:
|
||||||
repository: couchdb
|
repository: couchdb
|
||||||
tag: 3.2.1
|
tag: 3.1.1
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
## Experimental integration with Lucene-powered fulltext search
|
## Experimental integration with Lucene-powered fulltext search
|
||||||
|
|
|
@ -53,3 +53,13 @@ So this command will actually run the application in dev mode. It creates .env f
|
||||||
The dev version will be available on port 10000 i.e.
|
The dev version will be available on port 10000 i.e.
|
||||||
|
|
||||||
http://127.0.0.1:10000/builder/admin
|
http://127.0.0.1:10000/builder/admin
|
||||||
|
|
||||||
|
### File descriptor issues with Vite and Chrome in Linux
|
||||||
|
If your dev environment stalls forever, with some network requests stuck in flight, it's likely that Chrome is trying to open more file descriptors than your system allows.
|
||||||
|
To fix this, apply the following tweaks.
|
||||||
|
|
||||||
|
Debian based distros:
|
||||||
|
Add `* - nofile 65536` to `/etc/security/limits.conf`.
|
||||||
|
|
||||||
|
Arch:
|
||||||
|
Add `DefaultLimitNOFILE=65536` to `/etc/systemd/system.conf`.
|
|
@ -6,8 +6,7 @@ services:
|
||||||
minio-service:
|
minio-service:
|
||||||
container_name: budi-minio-dev
|
container_name: budi-minio-dev
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
# Last version that supports the "fs" backend
|
image: minio/minio
|
||||||
image: minio/minio:RELEASE.2022-10-24T18-35-07Z
|
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
ports:
|
ports:
|
||||||
|
|
|
@ -55,12 +55,12 @@ http {
|
||||||
set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com";
|
set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com";
|
||||||
set $csp_object "object-src 'none'";
|
set $csp_object "object-src 'none'";
|
||||||
set $csp_base_uri "base-uri 'self'";
|
set $csp_base_uri "base-uri 'self'";
|
||||||
set $csp_connect "connect-src 'self' https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.s3.*.amazonaws.com https://s3.*.amazonaws.com https://api.github.com";
|
set $csp_connect "connect-src 'self' https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com";
|
||||||
set $csp_font "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com https://js.intercomcdn.com https://fonts.intercomcdn.com";
|
set $csp_font "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com https://js.intercomcdn.com https://fonts.intercomcdn.com";
|
||||||
set $csp_frame "frame-src 'self' https:";
|
set $csp_frame "frame-src 'self' https:";
|
||||||
set $csp_img "img-src http: https: data: blob:";
|
set $csp_img "img-src http: https: data: blob:";
|
||||||
set $csp_manifest "manifest-src 'self'";
|
set $csp_manifest "manifest-src 'self'";
|
||||||
set $csp_media "media-src 'self' https://js.intercomcdn.com";
|
set $csp_media "media-src 'self' https://js.intercomcdn.com https://cdn.budi.live";
|
||||||
set $csp_worker "worker-src 'none'";
|
set $csp_worker "worker-src 'none'";
|
||||||
|
|
||||||
error_page 502 503 504 /error.html;
|
error_page 502 503 504 /error.html;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "2.3.18-alpha.15",
|
"version": "2.4.12-alpha.0",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@budibase/backend-core",
|
"name": "@budibase/backend-core",
|
||||||
"version": "2.3.18-alpha.15",
|
"version": "2.4.12-alpha.0",
|
||||||
"description": "Budibase backend core libraries used in server and worker",
|
"description": "Budibase backend core libraries used in server and worker",
|
||||||
"main": "dist/src/index.js",
|
"main": "dist/src/index.js",
|
||||||
"types": "dist/src/index.d.ts",
|
"types": "dist/src/index.d.ts",
|
||||||
|
@ -22,9 +22,9 @@
|
||||||
"test:watch": "jest --watchAll"
|
"test:watch": "jest --watchAll"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@budibase/nano": "10.1.1",
|
"@budibase/nano": "10.1.2",
|
||||||
"@budibase/pouchdb-replication-stream": "1.2.10",
|
"@budibase/pouchdb-replication-stream": "1.2.10",
|
||||||
"@budibase/types": "2.3.18-alpha.15",
|
"@budibase/types": "2.4.12-alpha.0",
|
||||||
"@shopify/jest-koa-mocks": "5.0.1",
|
"@shopify/jest-koa-mocks": "5.0.1",
|
||||||
"@techpass/passport-openidconnect": "0.3.2",
|
"@techpass/passport-openidconnect": "0.3.2",
|
||||||
"aws-cloudfront-sign": "2.2.0",
|
"aws-cloudfront-sign": "2.2.0",
|
||||||
|
|
|
@ -28,6 +28,7 @@ import * as events from "../events"
|
||||||
import * as configs from "../configs"
|
import * as configs from "../configs"
|
||||||
import { clearCookie, getCookie } from "../utils"
|
import { clearCookie, getCookie } from "../utils"
|
||||||
import { ssoSaveUserNoOp } from "../middleware/passport/sso/sso"
|
import { ssoSaveUserNoOp } from "../middleware/passport/sso/sso"
|
||||||
|
import env from "../environment"
|
||||||
|
|
||||||
const refresh = require("passport-oauth2-refresh")
|
const refresh = require("passport-oauth2-refresh")
|
||||||
export {
|
export {
|
||||||
|
@ -52,7 +53,7 @@ export const jwt = require("jsonwebtoken")
|
||||||
_passport.use(new LocalStrategy(local.options, local.authenticate))
|
_passport.use(new LocalStrategy(local.options, local.authenticate))
|
||||||
if (jwtPassport.options.secretOrKey) {
|
if (jwtPassport.options.secretOrKey) {
|
||||||
_passport.use(new JwtStrategy(jwtPassport.options, jwtPassport.authenticate))
|
_passport.use(new JwtStrategy(jwtPassport.options, jwtPassport.authenticate))
|
||||||
} else {
|
} else if (!env.DISABLE_JWT_WARNING) {
|
||||||
logAlert("No JWT Secret supplied, cannot configure JWT strategy")
|
logAlert("No JWT Secret supplied, cannot configure JWT strategy")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { getAppClient } from "../redis/init"
|
import { getAppClient } from "../redis/init"
|
||||||
import { doWithDB, DocumentType } from "../db"
|
import { doWithDB, DocumentType } from "../db"
|
||||||
import { Database } from "@budibase/types"
|
import { Database, App } from "@budibase/types"
|
||||||
|
|
||||||
const AppState = {
|
const AppState = {
|
||||||
INVALID: "invalid",
|
INVALID: "invalid",
|
||||||
|
@ -65,7 +65,7 @@ export async function getAppMetadata(appId: string) {
|
||||||
if (isInvalid(metadata)) {
|
if (isInvalid(metadata)) {
|
||||||
throw { status: 404, message: "No app metadata found" }
|
throw { status: 404, message: "No app metadata found" }
|
||||||
}
|
}
|
||||||
return metadata
|
return metadata as App
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
import { structures, DBTestConfiguration } from "../../../tests"
|
import {
|
||||||
|
structures,
|
||||||
|
DBTestConfiguration,
|
||||||
|
expectFunctionWasCalledTimesWith,
|
||||||
|
} from "../../../tests"
|
||||||
import { Writethrough } from "../writethrough"
|
import { Writethrough } from "../writethrough"
|
||||||
import { getDB } from "../../db"
|
import { getDB } from "../../db"
|
||||||
import tk from "timekeeper"
|
import tk from "timekeeper"
|
||||||
|
|
||||||
const START_DATE = Date.now()
|
tk.freeze(Date.now())
|
||||||
tk.freeze(START_DATE)
|
|
||||||
|
|
||||||
const DELAY = 5000
|
const DELAY = 5000
|
||||||
|
|
||||||
|
@ -17,34 +20,99 @@ describe("writethrough", () => {
|
||||||
const writethrough = new Writethrough(db, DELAY)
|
const writethrough = new Writethrough(db, DELAY)
|
||||||
const writethrough2 = new Writethrough(db2, DELAY)
|
const writethrough2 = new Writethrough(db2, DELAY)
|
||||||
|
|
||||||
|
const docId = structures.uuid()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
describe("put", () => {
|
describe("put", () => {
|
||||||
let first: any
|
let current: any
|
||||||
|
|
||||||
it("should be able to store, will go to DB", async () => {
|
it("should be able to store, will go to DB", async () => {
|
||||||
await config.doInTenant(async () => {
|
await config.doInTenant(async () => {
|
||||||
const response = await writethrough.put({ _id: "test", value: 1 })
|
const response = await writethrough.put({
|
||||||
|
_id: docId,
|
||||||
|
value: 1,
|
||||||
|
})
|
||||||
const output = await db.get(response.id)
|
const output = await db.get(response.id)
|
||||||
first = output
|
current = output
|
||||||
expect(output.value).toBe(1)
|
expect(output.value).toBe(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("second put shouldn't update DB", async () => {
|
it("second put shouldn't update DB", async () => {
|
||||||
await config.doInTenant(async () => {
|
await config.doInTenant(async () => {
|
||||||
const response = await writethrough.put({ ...first, value: 2 })
|
const response = await writethrough.put({ ...current, value: 2 })
|
||||||
const output = await db.get(response.id)
|
const output = await db.get(response.id)
|
||||||
expect(first._rev).toBe(output._rev)
|
expect(current._rev).toBe(output._rev)
|
||||||
expect(output.value).toBe(1)
|
expect(output.value).toBe(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should put it again after delay period", async () => {
|
it("should put it again after delay period", async () => {
|
||||||
await config.doInTenant(async () => {
|
await config.doInTenant(async () => {
|
||||||
tk.freeze(START_DATE + DELAY + 1)
|
tk.freeze(Date.now() + DELAY + 1)
|
||||||
const response = await writethrough.put({ ...first, value: 3 })
|
const response = await writethrough.put({ ...current, value: 3 })
|
||||||
const output = await db.get(response.id)
|
const output = await db.get(response.id)
|
||||||
expect(response.rev).not.toBe(first._rev)
|
expect(response.rev).not.toBe(current._rev)
|
||||||
expect(output.value).toBe(3)
|
expect(output.value).toBe(3)
|
||||||
|
|
||||||
|
current = output
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle parallel DB updates ignoring conflicts", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
tk.freeze(Date.now() + DELAY + 1)
|
||||||
|
const responses = await Promise.all([
|
||||||
|
writethrough.put({ ...current, value: 4 }),
|
||||||
|
writethrough.put({ ...current, value: 4 }),
|
||||||
|
writethrough.put({ ...current, value: 4 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const newRev = responses.map(x => x.rev).find(x => x !== current._rev)
|
||||||
|
expect(newRev).toBeDefined()
|
||||||
|
expect(responses.map(x => x.rev)).toEqual(
|
||||||
|
expect.arrayContaining([current._rev, current._rev, newRev])
|
||||||
|
)
|
||||||
|
expectFunctionWasCalledTimesWith(
|
||||||
|
console.warn,
|
||||||
|
2,
|
||||||
|
"bb-warn: Ignoring redlock conflict in write-through cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
const output = await db.get(current._id)
|
||||||
|
expect(output.value).toBe(4)
|
||||||
|
expect(output._rev).toBe(newRev)
|
||||||
|
|
||||||
|
current = output
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle updates with documents falling behind", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
tk.freeze(Date.now() + DELAY + 1)
|
||||||
|
|
||||||
|
const id = structures.uuid()
|
||||||
|
await writethrough.put({ _id: id, value: 1 })
|
||||||
|
const doc = await writethrough.get(id)
|
||||||
|
|
||||||
|
// Updating document
|
||||||
|
tk.freeze(Date.now() + DELAY + 1)
|
||||||
|
await writethrough.put({ ...doc, value: 2 })
|
||||||
|
|
||||||
|
// Update with the old rev value
|
||||||
|
tk.freeze(Date.now() + DELAY + 1)
|
||||||
|
const res = await writethrough.put({
|
||||||
|
...doc,
|
||||||
|
value: 3,
|
||||||
|
})
|
||||||
|
expect(res.ok).toBe(true)
|
||||||
|
|
||||||
|
const output = await db.get(id)
|
||||||
|
expect(output.value).toBe(3)
|
||||||
|
expect(output._rev).toBe(res.rev)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -52,8 +120,8 @@ describe("writethrough", () => {
|
||||||
describe("get", () => {
|
describe("get", () => {
|
||||||
it("should be able to retrieve", async () => {
|
it("should be able to retrieve", async () => {
|
||||||
await config.doInTenant(async () => {
|
await config.doInTenant(async () => {
|
||||||
const response = await writethrough.get("test")
|
const response = await writethrough.get(docId)
|
||||||
expect(response.value).toBe(3)
|
expect(response.value).toBe(4)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
39
packages/backend-core/src/cache/writethrough.ts
vendored
39
packages/backend-core/src/cache/writethrough.ts
vendored
|
@ -1,7 +1,8 @@
|
||||||
import BaseCache from "./base"
|
import BaseCache from "./base"
|
||||||
import { getWritethroughClient } from "../redis/init"
|
import { getWritethroughClient } from "../redis/init"
|
||||||
import { logWarn } from "../logging"
|
import { logWarn } from "../logging"
|
||||||
import { Database } from "@budibase/types"
|
import { Database, Document, LockName, LockType } from "@budibase/types"
|
||||||
|
import * as locks from "../redis/redlockImpl"
|
||||||
|
|
||||||
const DEFAULT_WRITE_RATE_MS = 10000
|
const DEFAULT_WRITE_RATE_MS = 10000
|
||||||
let CACHE: BaseCache | null = null
|
let CACHE: BaseCache | null = null
|
||||||
|
@ -27,20 +28,31 @@ function makeCacheItem(doc: any, lastWrite: number | null = null): CacheItem {
|
||||||
return { doc, lastWrite: lastWrite || Date.now() }
|
return { doc, lastWrite: lastWrite || Date.now() }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function put(
|
async function put(
|
||||||
db: Database,
|
db: Database,
|
||||||
doc: any,
|
doc: Document,
|
||||||
writeRateMs: number = DEFAULT_WRITE_RATE_MS
|
writeRateMs: number = DEFAULT_WRITE_RATE_MS
|
||||||
) {
|
) {
|
||||||
const cache = await getCache()
|
const cache = await getCache()
|
||||||
const key = doc._id
|
const key = doc._id
|
||||||
let cacheItem: CacheItem | undefined = await cache.get(makeCacheKey(db, key))
|
let cacheItem: CacheItem | undefined
|
||||||
|
if (key) {
|
||||||
|
cacheItem = await cache.get(makeCacheKey(db, key))
|
||||||
|
}
|
||||||
const updateDb = !cacheItem || cacheItem.lastWrite < Date.now() - writeRateMs
|
const updateDb = !cacheItem || cacheItem.lastWrite < Date.now() - writeRateMs
|
||||||
let output = doc
|
let output = doc
|
||||||
if (updateDb) {
|
if (updateDb) {
|
||||||
|
const lockResponse = await locks.doWithLock(
|
||||||
|
{
|
||||||
|
type: LockType.TRY_ONCE,
|
||||||
|
name: LockName.PERSIST_WRITETHROUGH,
|
||||||
|
resource: key,
|
||||||
|
ttl: 1000,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
const writeDb = async (toWrite: any) => {
|
const writeDb = async (toWrite: any) => {
|
||||||
// doc should contain the _id and _rev
|
// doc should contain the _id and _rev
|
||||||
const response = await db.put(toWrite)
|
const response = await db.put(toWrite, { force: true })
|
||||||
output = {
|
output = {
|
||||||
...doc,
|
...doc,
|
||||||
_id: response.id,
|
_id: response.id,
|
||||||
|
@ -58,13 +70,20 @@ export async function put(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
if (!lockResponse.executed) {
|
||||||
|
logWarn(`Ignoring redlock conflict in write-through cache`)
|
||||||
|
}
|
||||||
|
}
|
||||||
// if we are updating the DB then need to set the lastWrite to now
|
// if we are updating the DB then need to set the lastWrite to now
|
||||||
cacheItem = makeCacheItem(output, updateDb ? null : cacheItem?.lastWrite)
|
cacheItem = makeCacheItem(output, updateDb ? null : cacheItem?.lastWrite)
|
||||||
await cache.store(makeCacheKey(db, key), cacheItem)
|
if (output._id) {
|
||||||
|
await cache.store(makeCacheKey(db, output._id), cacheItem)
|
||||||
|
}
|
||||||
return { ok: true, id: output._id, rev: output._rev }
|
return { ok: true, id: output._id, rev: output._rev }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function get(db: Database, id: string): Promise<any> {
|
async function get(db: Database, id: string): Promise<any> {
|
||||||
const cache = await getCache()
|
const cache = await getCache()
|
||||||
const cacheKey = makeCacheKey(db, id)
|
const cacheKey = makeCacheKey(db, id)
|
||||||
let cacheItem: CacheItem = await cache.get(cacheKey)
|
let cacheItem: CacheItem = await cache.get(cacheKey)
|
||||||
|
@ -76,11 +95,7 @@ export async function get(db: Database, id: string): Promise<any> {
|
||||||
return cacheItem.doc
|
return cacheItem.doc
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remove(
|
async function remove(db: Database, docOrId: any, rev?: any): Promise<void> {
|
||||||
db: Database,
|
|
||||||
docOrId: any,
|
|
||||||
rev?: any
|
|
||||||
): Promise<void> {
|
|
||||||
const cache = await getCache()
|
const cache = await getCache()
|
||||||
if (!docOrId) {
|
if (!docOrId) {
|
||||||
throw new Error("No ID/Rev provided.")
|
throw new Error("No ID/Rev provided.")
|
||||||
|
|
|
@ -42,7 +42,9 @@ export async function getConfig<T extends Config>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function save(config: Config) {
|
export async function save(
|
||||||
|
config: Config
|
||||||
|
): Promise<{ id: string; rev: string }> {
|
||||||
const db = context.getGlobalDB()
|
const db = context.getGlobalDB()
|
||||||
return db.put(config)
|
return db.put(config)
|
||||||
}
|
}
|
||||||
|
@ -54,7 +56,7 @@ export async function getSettingsConfigDoc(): Promise<SettingsConfig> {
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
config = {
|
config = {
|
||||||
_id: generateConfigID(ConfigType.GOOGLE),
|
_id: generateConfigID(ConfigType.SETTINGS),
|
||||||
type: ConfigType.SETTINGS,
|
type: ConfigType.SETTINGS,
|
||||||
config: {},
|
config: {},
|
||||||
}
|
}
|
||||||
|
|
|
@ -94,6 +94,7 @@ const environment = {
|
||||||
SMTP_HOST: process.env.SMTP_HOST,
|
SMTP_HOST: process.env.SMTP_HOST,
|
||||||
SMTP_PORT: parseInt(process.env.SMTP_PORT || ""),
|
SMTP_PORT: parseInt(process.env.SMTP_PORT || ""),
|
||||||
SMTP_FROM_ADDRESS: process.env.SMTP_FROM_ADDRESS,
|
SMTP_FROM_ADDRESS: process.env.SMTP_FROM_ADDRESS,
|
||||||
|
DISABLE_JWT_WARNING: process.env.DISABLE_JWT_WARNING,
|
||||||
/**
|
/**
|
||||||
* Enable to allow an admin user to login using a password.
|
* Enable to allow an admin user to login using a password.
|
||||||
* This can be useful to prevent lockout when configuring SSO.
|
* This can be useful to prevent lockout when configuring SSO.
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {
|
||||||
HostInfo,
|
HostInfo,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { EventProcessor } from "./types"
|
import { EventProcessor } from "./types"
|
||||||
import { getAppId } from "../../context"
|
import { getAppId, doInTenant, getTenantId } from "../../context"
|
||||||
import BullQueue from "bull"
|
import BullQueue from "bull"
|
||||||
import { createQueue, JobQueue } from "../../queue"
|
import { createQueue, JobQueue } from "../../queue"
|
||||||
import { isAudited } from "../../utils"
|
import { isAudited } from "../../utils"
|
||||||
|
@ -26,6 +26,7 @@ export default class AuditLogsProcessor implements EventProcessor {
|
||||||
JobQueue.AUDIT_LOG
|
JobQueue.AUDIT_LOG
|
||||||
)
|
)
|
||||||
return AuditLogsProcessor.auditLogQueue.process(async job => {
|
return AuditLogsProcessor.auditLogQueue.process(async job => {
|
||||||
|
return doInTenant(job.data.tenantId, async () => {
|
||||||
let properties = job.data.properties
|
let properties = job.data.properties
|
||||||
if (properties.audited) {
|
if (properties.audited) {
|
||||||
properties = {
|
properties = {
|
||||||
|
@ -50,6 +51,7 @@ export default class AuditLogsProcessor implements EventProcessor {
|
||||||
hostInfo,
|
hostInfo,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async processEvent(
|
async processEvent(
|
||||||
|
@ -72,6 +74,7 @@ export default class AuditLogsProcessor implements EventProcessor {
|
||||||
appId: getAppId(),
|
appId: getAppId(),
|
||||||
hostInfo: identity.hostInfo,
|
hostInfo: identity.hostInfo,
|
||||||
},
|
},
|
||||||
|
tenantId: getTenantId(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -154,7 +154,8 @@ export default function (
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Auth Error", err?.message || err)
|
console.error(`Auth Error: ${err.message}`)
|
||||||
|
console.error(err)
|
||||||
// invalid token, clear the cookie
|
// invalid token, clear the cookie
|
||||||
if (err && err.name === "JsonWebTokenError") {
|
if (err && err.name === "JsonWebTokenError") {
|
||||||
clearCookie(ctx, Cookie.Auth)
|
clearCookie(ctx, Cookie.Auth)
|
||||||
|
|
|
@ -87,6 +87,7 @@ export const runMigration = async (
|
||||||
const lengthStatement = length > 1 ? `[${count}/${length}]` : ""
|
const lengthStatement = length > 1 ? `[${count}/${length}]` : ""
|
||||||
|
|
||||||
const db = getDB(dbName)
|
const db = getDB(dbName)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc = await getMigrationsDoc(db)
|
const doc = await getMigrationsDoc(db)
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@ const getClient = async (type: LockType): Promise<Redlock> => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OPTIONS = {
|
const OPTIONS = {
|
||||||
TRY_ONCE: {
|
TRY_ONCE: {
|
||||||
// immediately throws an error if the lock is already held
|
// immediately throws an error if the lock is already held
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
|
@ -56,14 +56,29 @@ export const OPTIONS = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const newRedlock = async (opts: Options = {}) => {
|
const newRedlock = async (opts: Options = {}) => {
|
||||||
let options = { ...OPTIONS.DEFAULT, ...opts }
|
let options = { ...OPTIONS.DEFAULT, ...opts }
|
||||||
const redisWrapper = await getLockClient()
|
const redisWrapper = await getLockClient()
|
||||||
const client = redisWrapper.getClient()
|
const client = redisWrapper.getClient()
|
||||||
return new Redlock([client], options)
|
return new Redlock([client], options)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const doWithLock = async (opts: LockOptions, task: any) => {
|
type SuccessfulRedlockExecution<T> = {
|
||||||
|
executed: true
|
||||||
|
result: T
|
||||||
|
}
|
||||||
|
type UnsuccessfulRedlockExecution = {
|
||||||
|
executed: false
|
||||||
|
}
|
||||||
|
|
||||||
|
type RedlockExecution<T> =
|
||||||
|
| SuccessfulRedlockExecution<T>
|
||||||
|
| UnsuccessfulRedlockExecution
|
||||||
|
|
||||||
|
export const doWithLock = async <T>(
|
||||||
|
opts: LockOptions,
|
||||||
|
task: () => Promise<T>
|
||||||
|
): Promise<RedlockExecution<T>> => {
|
||||||
const redlock = await getClient(opts.type)
|
const redlock = await getClient(opts.type)
|
||||||
let lock
|
let lock
|
||||||
try {
|
try {
|
||||||
|
@ -73,8 +88,8 @@ export const doWithLock = async (opts: LockOptions, task: any) => {
|
||||||
let name: string = `lock:${prefix}_${opts.name}`
|
let name: string = `lock:${prefix}_${opts.name}`
|
||||||
|
|
||||||
// add additional unique name if required
|
// add additional unique name if required
|
||||||
if (opts.nameSuffix) {
|
if (opts.resource) {
|
||||||
name = name + `_${opts.nameSuffix}`
|
name = name + `_${opts.resource}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// create the lock
|
// create the lock
|
||||||
|
@ -83,7 +98,7 @@ export const doWithLock = async (opts: LockOptions, task: any) => {
|
||||||
// perform locked task
|
// perform locked task
|
||||||
// need to await to ensure completion before unlocking
|
// need to await to ensure completion before unlocking
|
||||||
const result = await task()
|
const result = await task()
|
||||||
return result
|
return { executed: true, result }
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.warn("lock error")
|
console.warn("lock error")
|
||||||
// lock limit exceeded
|
// lock limit exceeded
|
||||||
|
@ -92,7 +107,7 @@ export const doWithLock = async (opts: LockOptions, task: any) => {
|
||||||
// don't throw for try-once locks, they will always error
|
// don't throw for try-once locks, they will always error
|
||||||
// due to retry count (0) exceeded
|
// due to retry count (0) exceeded
|
||||||
console.warn(e)
|
console.warn(e)
|
||||||
return
|
return { executed: false }
|
||||||
} else {
|
} else {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
throw e
|
throw e
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
generateAppUserID,
|
generateAppUserID,
|
||||||
queryGlobalView,
|
queryGlobalView,
|
||||||
UNICODE_MAX,
|
UNICODE_MAX,
|
||||||
|
directCouchFind,
|
||||||
} from "./db"
|
} from "./db"
|
||||||
import { BulkDocsResponse, User } from "@budibase/types"
|
import { BulkDocsResponse, User } from "@budibase/types"
|
||||||
import { getGlobalDB } from "./context"
|
import { getGlobalDB } from "./context"
|
||||||
|
@ -101,6 +102,7 @@ export const searchGlobalUsersByApp = async (
|
||||||
})
|
})
|
||||||
params.startkey = opts && opts.startkey ? opts.startkey : params.startkey
|
params.startkey = opts && opts.startkey ? opts.startkey : params.startkey
|
||||||
let response = await queryGlobalView(ViewName.USER_BY_APP, params)
|
let response = await queryGlobalView(ViewName.USER_BY_APP, params)
|
||||||
|
|
||||||
if (!response) {
|
if (!response) {
|
||||||
response = []
|
response = []
|
||||||
}
|
}
|
||||||
|
@ -111,6 +113,45 @@ export const searchGlobalUsersByApp = async (
|
||||||
return users
|
return users
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Return any user who potentially has access to the application
|
||||||
|
Admins, developers and app users with the explicitly role.
|
||||||
|
*/
|
||||||
|
export const searchGlobalUsersByAppAccess = async (appId: any, opts: any) => {
|
||||||
|
const roleSelector = `roles.${appId}`
|
||||||
|
|
||||||
|
let orQuery: any[] = [
|
||||||
|
{
|
||||||
|
"builder.global": true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"admin.global": true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (appId) {
|
||||||
|
const roleCheck = {
|
||||||
|
[roleSelector]: {
|
||||||
|
$exists: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
orQuery.push(roleCheck)
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchOptions = {
|
||||||
|
selector: {
|
||||||
|
$or: orQuery,
|
||||||
|
_id: {
|
||||||
|
$regex: "^us_",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: opts?.limit || 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await directCouchFind(context.getGlobalDBName(), searchOptions)
|
||||||
|
return resp?.rows
|
||||||
|
}
|
||||||
|
|
||||||
export const getGlobalUserByAppPage = (appId: string, user: User) => {
|
export const getGlobalUserByAppPage = (appId: string, user: User) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return
|
return
|
||||||
|
|
|
@ -4,4 +4,6 @@ export { generator } from "./structures"
|
||||||
export * as testEnv from "./testEnv"
|
export * as testEnv from "./testEnv"
|
||||||
export * as testContainerUtils from "./testContainerUtils"
|
export * as testContainerUtils from "./testContainerUtils"
|
||||||
|
|
||||||
|
export * from "./jestUtils"
|
||||||
|
|
||||||
export { default as DBTestConfiguration } from "./DBTestConfiguration"
|
export { default as DBTestConfiguration } from "./DBTestConfiguration"
|
||||||
|
|
9
packages/backend-core/tests/utilities/jestUtils.ts
Normal file
9
packages/backend-core/tests/utilities/jestUtils.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export function expectFunctionWasCalledTimesWith(
|
||||||
|
jestFunction: any,
|
||||||
|
times: number,
|
||||||
|
argument: any
|
||||||
|
) {
|
||||||
|
expect(
|
||||||
|
jestFunction.mock.calls.filter((call: any) => call[0] === argument).length
|
||||||
|
).toBe(times)
|
||||||
|
}
|
|
@ -8,6 +8,8 @@ import {
|
||||||
CloudAccount,
|
CloudAccount,
|
||||||
Hosting,
|
Hosting,
|
||||||
SSOAccount,
|
SSOAccount,
|
||||||
|
CreateAccount,
|
||||||
|
CreatePassswordAccount,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import _ from "lodash"
|
import _ from "lodash"
|
||||||
|
|
||||||
|
@ -29,6 +31,10 @@ export const account = (): Account => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selfHostAccount() {
|
||||||
|
return account()
|
||||||
|
}
|
||||||
|
|
||||||
export const cloudAccount = (): CloudAccount => {
|
export const cloudAccount = (): CloudAccount => {
|
||||||
return {
|
return {
|
||||||
...account(),
|
...account(),
|
||||||
|
@ -47,9 +53,9 @@ function provider(): AccountSSOProvider {
|
||||||
return _.sample(Object.values(AccountSSOProvider)) as AccountSSOProvider
|
return _.sample(Object.values(AccountSSOProvider)) as AccountSSOProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ssoAccount(): SSOAccount {
|
export function ssoAccount(account: Account = cloudAccount()): SSOAccount {
|
||||||
return {
|
return {
|
||||||
...cloudAccount(),
|
...account,
|
||||||
authType: AuthType.SSO,
|
authType: AuthType.SSO,
|
||||||
oauth2: {
|
oauth2: {
|
||||||
accessToken: generator.string(),
|
accessToken: generator.string(),
|
||||||
|
@ -61,3 +67,49 @@ export function ssoAccount(): SSOAccount {
|
||||||
thirdPartyProfile: {},
|
thirdPartyProfile: {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const cloudCreateAccount: CreatePassswordAccount = {
|
||||||
|
email: "cloud@budibase.com",
|
||||||
|
tenantId: "cloud",
|
||||||
|
hosting: Hosting.CLOUD,
|
||||||
|
authType: AuthType.PASSWORD,
|
||||||
|
password: "Password123!",
|
||||||
|
tenantName: "cloud",
|
||||||
|
name: "Budi Armstrong",
|
||||||
|
size: "10+",
|
||||||
|
profession: "Software Engineer",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cloudSSOCreateAccount: CreateAccount = {
|
||||||
|
email: "cloud-sso@budibase.com",
|
||||||
|
tenantId: "cloud-sso",
|
||||||
|
hosting: Hosting.CLOUD,
|
||||||
|
authType: AuthType.SSO,
|
||||||
|
tenantName: "cloudsso",
|
||||||
|
name: "Budi Armstrong",
|
||||||
|
size: "10+",
|
||||||
|
profession: "Software Engineer",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selfCreateAccount: CreatePassswordAccount = {
|
||||||
|
email: "self@budibase.com",
|
||||||
|
tenantId: "self",
|
||||||
|
hosting: Hosting.SELF,
|
||||||
|
authType: AuthType.PASSWORD,
|
||||||
|
password: "Password123!",
|
||||||
|
tenantName: "self",
|
||||||
|
name: "Budi Armstrong",
|
||||||
|
size: "10+",
|
||||||
|
profession: "Software Engineer",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selfSSOCreateAccount: CreateAccount = {
|
||||||
|
email: "self-sso@budibase.com",
|
||||||
|
tenantId: "self-sso",
|
||||||
|
hosting: Hosting.SELF,
|
||||||
|
authType: AuthType.SSO,
|
||||||
|
tenantName: "selfsso",
|
||||||
|
name: "Budi Armstrong",
|
||||||
|
size: "10+",
|
||||||
|
profession: "Software Engineer",
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,12 @@
|
||||||
|
import { structures } from ".."
|
||||||
import { newid } from "../../../src/newid"
|
import { newid } from "../../../src/newid"
|
||||||
|
|
||||||
export function id() {
|
export function id() {
|
||||||
return `db_${newid()}`
|
return `db_${newid()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function rev() {
|
||||||
|
return `${structures.generator.character({
|
||||||
|
numeric: true,
|
||||||
|
})}-${structures.uuid().replace(/-/, "")}`
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
GoogleInnerConfig,
|
GoogleInnerConfig,
|
||||||
JwtClaims,
|
JwtClaims,
|
||||||
|
OAuth2,
|
||||||
OIDCInnerConfig,
|
OIDCInnerConfig,
|
||||||
OIDCWellKnownConfig,
|
OIDCWellKnownConfig,
|
||||||
SSOAuthDetails,
|
SSOAuthDetails,
|
||||||
|
@ -14,6 +15,13 @@ import * as shared from "./shared"
|
||||||
import _ from "lodash"
|
import _ from "lodash"
|
||||||
import { user } from "./shared"
|
import { user } from "./shared"
|
||||||
|
|
||||||
|
export function OAuth(): OAuth2 {
|
||||||
|
return {
|
||||||
|
refreshToken: generator.string(),
|
||||||
|
accessToken: generator.string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function authDetails(userDoc?: User): SSOAuthDetails {
|
export function authDetails(userDoc?: User): SSOAuthDetails {
|
||||||
if (!userDoc) {
|
if (!userDoc) {
|
||||||
userDoc = user()
|
userDoc = user()
|
||||||
|
@ -28,10 +36,7 @@ export function authDetails(userDoc?: User): SSOAuthDetails {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
email: userDoc.email,
|
email: userDoc.email,
|
||||||
oauth2: {
|
oauth2: OAuth(),
|
||||||
refreshToken: generator.string(),
|
|
||||||
accessToken: generator.string(),
|
|
||||||
},
|
|
||||||
profile,
|
profile,
|
||||||
provider,
|
provider,
|
||||||
providerType: providerType(),
|
providerType: providerType(),
|
||||||
|
|
|
@ -475,10 +475,10 @@
|
||||||
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
|
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
|
||||||
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
|
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
|
||||||
|
|
||||||
"@budibase/nano@10.1.1":
|
"@budibase/nano@10.1.2":
|
||||||
version "10.1.1"
|
version "10.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/@budibase/nano/-/nano-10.1.1.tgz#36ccda4d9bb64b5ee14dd2b27a295b40739b1038"
|
resolved "https://registry.yarnpkg.com/@budibase/nano/-/nano-10.1.2.tgz#10fae5a1ab39be6a81261f40e7b7ec6d21cbdd4a"
|
||||||
integrity sha512-kbMIzMkjVtl+xI0UPwVU0/pn8/ccxTyfzwBz6Z+ZiN2oUSb0fJCe0qwA6o8dxwSa8nZu4MbGAeMJl3CJndmWtA==
|
integrity sha512-1w+YN2n/M5aZ9hBKCP4NEjdQbT8BfCLRizkdvm0Je665eEHw3aE1hvo8mon9Ro9QuDdxj1DfDMMFnym6/QUwpQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/tough-cookie" "^4.0.2"
|
"@types/tough-cookie" "^4.0.2"
|
||||||
axios "^1.1.3"
|
axios "^1.1.3"
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@budibase/bbui",
|
"name": "@budibase/bbui",
|
||||||
"description": "A UI solution used in the different Budibase projects.",
|
"description": "A UI solution used in the different Budibase projects.",
|
||||||
"version": "2.3.18-alpha.15",
|
"version": "2.4.12-alpha.0",
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"svelte": "src/index.js",
|
"svelte": "src/index.js",
|
||||||
"module": "dist/bbui.es.js",
|
"module": "dist/bbui.es.js",
|
||||||
|
@ -38,7 +38,8 @@
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@adobe/spectrum-css-workflow-icons": "1.2.1",
|
"@adobe/spectrum-css-workflow-icons": "1.2.1",
|
||||||
"@budibase/string-templates": "2.3.18-alpha.15",
|
"@budibase/shared-core": "2.4.12-alpha.0",
|
||||||
|
"@budibase/string-templates": "2.4.12-alpha.0",
|
||||||
"@spectrum-css/accordion": "3.0.24",
|
"@spectrum-css/accordion": "3.0.24",
|
||||||
"@spectrum-css/actionbutton": "1.0.1",
|
"@spectrum-css/actionbutton": "1.0.1",
|
||||||
"@spectrum-css/actiongroup": "1.0.1",
|
"@spectrum-css/actiongroup": "1.0.1",
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
<script>
|
<script>
|
||||||
import "@spectrum-css/actionbutton/dist/index-vars.css"
|
import "@spectrum-css/actionbutton/dist/index-vars.css"
|
||||||
import { createEventDispatcher } from "svelte"
|
import { createEventDispatcher } from "svelte"
|
||||||
|
import Tooltip from "../Tooltip/Tooltip.svelte"
|
||||||
|
import { fade } from "svelte/transition"
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
export let quiet = false
|
export let quiet = false
|
||||||
|
@ -13,6 +16,9 @@
|
||||||
export let active = false
|
export let active = false
|
||||||
export let fullWidth = false
|
export let fullWidth = false
|
||||||
export let noPadding = false
|
export let noPadding = false
|
||||||
|
export let tooltip = ""
|
||||||
|
|
||||||
|
let showTooltip = false
|
||||||
|
|
||||||
function longPress(element) {
|
function longPress(element) {
|
||||||
if (!longPressable) return
|
if (!longPressable) return
|
||||||
|
@ -35,6 +41,12 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="btn-wrap"
|
||||||
|
on:mouseover={() => (showTooltip = true)}
|
||||||
|
on:mouseleave={() => (showTooltip = false)}
|
||||||
|
on:focus={() => (showTooltip = true)}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
use:longPress
|
use:longPress
|
||||||
class:spectrum-ActionButton--quiet={quiet}
|
class:spectrum-ActionButton--quiet={quiet}
|
||||||
|
@ -70,7 +82,13 @@
|
||||||
{#if $$slots}
|
{#if $$slots}
|
||||||
<span class="spectrum-ActionButton-label"><slot /></span>
|
<span class="spectrum-ActionButton-label"><slot /></span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if tooltip && showTooltip}
|
||||||
|
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}>
|
||||||
|
<Tooltip textWrapping direction="bottom" text={tooltip} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
</span>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.fullWidth {
|
.fullWidth {
|
||||||
|
@ -95,7 +113,20 @@
|
||||||
.spectrum-ActionButton--quiet {
|
.spectrum-ActionButton--quiet {
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
|
.spectrum-ActionButton--quiet.is-selected {
|
||||||
|
color: var(--spectrum-global-color-gray-900);
|
||||||
|
}
|
||||||
.is-selected:not(.emphasized) .spectrum-Icon {
|
.is-selected:not(.emphasized) .spectrum-Icon {
|
||||||
color: var(--spectrum-global-color-gray-900);
|
color: var(--spectrum-global-color-gray-900);
|
||||||
}
|
}
|
||||||
|
.tooltip {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
left: 50%;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
width: 100vw;
|
||||||
|
max-width: 150px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -31,6 +31,7 @@ export default function positionDropdown(element, opts) {
|
||||||
styles.top = anchorBounds.top
|
styles.top = anchorBounds.top
|
||||||
} else if (window.innerHeight - anchorBounds.bottom < 100) {
|
} else if (window.innerHeight - anchorBounds.bottom < 100) {
|
||||||
styles.top = anchorBounds.top - elementBounds.height - offset
|
styles.top = anchorBounds.top - elementBounds.height - offset
|
||||||
|
styles.maxHeight = 240
|
||||||
} else {
|
} else {
|
||||||
styles.top = anchorBounds.bottom + offset
|
styles.top = anchorBounds.bottom + offset
|
||||||
styles.maxHeight = window.innerHeight - anchorBounds.bottom - 20
|
styles.maxHeight = window.innerHeight - anchorBounds.bottom - 20
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
export let sort = false
|
export let sort = false
|
||||||
export let autoWidth = false
|
export let autoWidth = false
|
||||||
export let fetchTerm = null
|
export let fetchTerm = null
|
||||||
|
export let useFetch = false
|
||||||
export let customPopoverHeight
|
export let customPopoverHeight
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
@ -87,6 +88,7 @@
|
||||||
isPlaceholder={!arrayValue.length}
|
isPlaceholder={!arrayValue.length}
|
||||||
{autocomplete}
|
{autocomplete}
|
||||||
bind:fetchTerm
|
bind:fetchTerm
|
||||||
|
{useFetch}
|
||||||
{isOptionSelected}
|
{isOptionSelected}
|
||||||
{getOptionLabel}
|
{getOptionLabel}
|
||||||
{getOptionValue}
|
{getOptionValue}
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
export let getOptionLabel = option => option
|
export let getOptionLabel = option => option
|
||||||
export let getOptionValue = option => option
|
export let getOptionValue = option => option
|
||||||
export let getOptionIcon = () => null
|
export let getOptionIcon = () => null
|
||||||
|
export let useOptionIconImage = false
|
||||||
export let getOptionColour = () => null
|
export let getOptionColour = () => null
|
||||||
export let open = false
|
export let open = false
|
||||||
export let readonly = false
|
export let readonly = false
|
||||||
|
@ -32,7 +33,11 @@
|
||||||
export let autocomplete = false
|
export let autocomplete = false
|
||||||
export let sort = false
|
export let sort = false
|
||||||
export let fetchTerm = null
|
export let fetchTerm = null
|
||||||
|
export let useFetch = false
|
||||||
export let customPopoverHeight
|
export let customPopoverHeight
|
||||||
|
export let align = "left"
|
||||||
|
export let footer = null
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
let searchTerm = null
|
let searchTerm = null
|
||||||
|
@ -131,7 +136,7 @@
|
||||||
|
|
||||||
<Popover
|
<Popover
|
||||||
anchor={button}
|
anchor={button}
|
||||||
align="left"
|
align={align || "left"}
|
||||||
bind:this={popover}
|
bind:this={popover}
|
||||||
{open}
|
{open}
|
||||||
on:close={() => (open = false)}
|
on:close={() => (open = false)}
|
||||||
|
@ -146,9 +151,9 @@
|
||||||
>
|
>
|
||||||
{#if autocomplete}
|
{#if autocomplete}
|
||||||
<Search
|
<Search
|
||||||
value={fetchTerm ? fetchTerm : searchTerm}
|
value={useFetch ? fetchTerm : searchTerm}
|
||||||
on:change={event =>
|
on:change={event =>
|
||||||
fetchTerm ? (fetchTerm = event.detail) : (searchTerm = event.detail)}
|
useFetch ? (fetchTerm = event.detail) : (searchTerm = event.detail)}
|
||||||
{disabled}
|
{disabled}
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
/>
|
/>
|
||||||
|
@ -186,7 +191,16 @@
|
||||||
>
|
>
|
||||||
{#if getOptionIcon(option, idx)}
|
{#if getOptionIcon(option, idx)}
|
||||||
<span class="option-extra icon">
|
<span class="option-extra icon">
|
||||||
|
{#if useOptionIconImage}
|
||||||
|
<img
|
||||||
|
src={getOptionIcon(option, idx)}
|
||||||
|
alt="icon"
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
<Icon size="S" name={getOptionIcon(option, idx)} />
|
<Icon size="S" name={getOptionIcon(option, idx)} />
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if getOptionColour(option, idx)}
|
{#if getOptionColour(option, idx)}
|
||||||
|
@ -208,6 +222,12 @@
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
{#if footer}
|
||||||
|
<div class="footer">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
|
@ -284,4 +304,11 @@
|
||||||
.popover-content :global(.spectrum-Search .spectrum-Textfield-icon) {
|
.popover-content :global(.spectrum-Search .spectrum-Textfield-icon) {
|
||||||
top: 9px;
|
top: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 4px 12px 12px 12px;
|
||||||
|
font-style: italic;
|
||||||
|
max-width: 170px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
export let getOptionLabel = option => option
|
export let getOptionLabel = option => option
|
||||||
export let getOptionValue = option => option
|
export let getOptionValue = option => option
|
||||||
export let getOptionIcon = () => null
|
export let getOptionIcon = () => null
|
||||||
|
export let useOptionIconImage = false
|
||||||
export let getOptionColour = () => null
|
export let getOptionColour = () => null
|
||||||
export let isOptionEnabled
|
export let isOptionEnabled
|
||||||
export let readonly = false
|
export let readonly = false
|
||||||
|
@ -18,6 +19,8 @@
|
||||||
export let autoWidth = false
|
export let autoWidth = false
|
||||||
export let autocomplete = false
|
export let autocomplete = false
|
||||||
export let sort = false
|
export let sort = false
|
||||||
|
export let align
|
||||||
|
export let footer = null
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
@ -41,7 +44,7 @@
|
||||||
const getFieldText = (value, options, placeholder) => {
|
const getFieldText = (value, options, placeholder) => {
|
||||||
// Always use placeholder if no value
|
// Always use placeholder if no value
|
||||||
if (value == null || value === "") {
|
if (value == null || value === "") {
|
||||||
return placeholder || "Choose an option"
|
return placeholder !== false ? "Choose an option" : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return getFieldAttribute(getOptionLabel, value, options)
|
return getFieldAttribute(getOptionLabel, value, options)
|
||||||
|
@ -66,15 +69,18 @@
|
||||||
{fieldColour}
|
{fieldColour}
|
||||||
{options}
|
{options}
|
||||||
{autoWidth}
|
{autoWidth}
|
||||||
|
{align}
|
||||||
|
{footer}
|
||||||
{getOptionLabel}
|
{getOptionLabel}
|
||||||
{getOptionValue}
|
{getOptionValue}
|
||||||
{getOptionIcon}
|
{getOptionIcon}
|
||||||
|
{useOptionIconImage}
|
||||||
{getOptionColour}
|
{getOptionColour}
|
||||||
{isOptionEnabled}
|
{isOptionEnabled}
|
||||||
{autocomplete}
|
{autocomplete}
|
||||||
{sort}
|
{sort}
|
||||||
isPlaceholder={value == null || value === ""}
|
isPlaceholder={value == null || value === ""}
|
||||||
placeholderOption={placeholder}
|
placeholderOption={placeholder === false ? null : placeholder}
|
||||||
isOptionSelected={option => option === value}
|
isOptionSelected={option => option === value}
|
||||||
onSelectOption={selectOption}
|
onSelectOption={selectOption}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
export let autoWidth = false
|
export let autoWidth = false
|
||||||
export let autocomplete = false
|
export let autocomplete = false
|
||||||
export let fetchTerm = null
|
export let fetchTerm = null
|
||||||
|
export let useFetch = false
|
||||||
export let customPopoverHeight
|
export let customPopoverHeight
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
@ -41,6 +42,7 @@
|
||||||
{autocomplete}
|
{autocomplete}
|
||||||
{customPopoverHeight}
|
{customPopoverHeight}
|
||||||
bind:fetchTerm
|
bind:fetchTerm
|
||||||
|
{useFetch}
|
||||||
on:change={onChange}
|
on:change={onChange}
|
||||||
on:click
|
on:click
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
export let getOptionLabel = option => extractProperty(option, "label")
|
export let getOptionLabel = option => extractProperty(option, "label")
|
||||||
export let getOptionValue = option => extractProperty(option, "value")
|
export let getOptionValue = option => extractProperty(option, "value")
|
||||||
export let getOptionIcon = option => option?.icon
|
export let getOptionIcon = option => option?.icon
|
||||||
|
export let useOptionIconImage = false
|
||||||
export let getOptionColour = option => option?.colour
|
export let getOptionColour = option => option?.colour
|
||||||
export let isOptionEnabled
|
export let isOptionEnabled
|
||||||
export let quiet = false
|
export let quiet = false
|
||||||
|
@ -22,6 +23,8 @@
|
||||||
export let tooltip = ""
|
export let tooltip = ""
|
||||||
export let autocomplete = false
|
export let autocomplete = false
|
||||||
export let customPopoverHeight
|
export let customPopoverHeight
|
||||||
|
export let align
|
||||||
|
export let footer = null
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
const onChange = e => {
|
const onChange = e => {
|
||||||
|
@ -48,10 +51,13 @@
|
||||||
{placeholder}
|
{placeholder}
|
||||||
{autoWidth}
|
{autoWidth}
|
||||||
{sort}
|
{sort}
|
||||||
|
{align}
|
||||||
|
{footer}
|
||||||
{getOptionLabel}
|
{getOptionLabel}
|
||||||
{getOptionValue}
|
{getOptionValue}
|
||||||
{getOptionIcon}
|
{getOptionIcon}
|
||||||
{getOptionColour}
|
{getOptionColour}
|
||||||
|
{useOptionIconImage}
|
||||||
{isOptionEnabled}
|
{isOptionEnabled}
|
||||||
{autocomplete}
|
{autocomplete}
|
||||||
{customPopoverHeight}
|
{customPopoverHeight}
|
||||||
|
|
|
@ -29,6 +29,14 @@
|
||||||
visible = false
|
visible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toggle() {
|
||||||
|
if (visible) {
|
||||||
|
hide()
|
||||||
|
} else {
|
||||||
|
show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function cancel() {
|
export function cancel() {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
return
|
return
|
||||||
|
@ -61,7 +69,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setContext(Context.Modal, { show, hide, cancel })
|
setContext(Context.Modal, { show, hide, toggle, cancel })
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
document.addEventListener("keydown", handleKey)
|
document.addEventListener("keydown", handleKey)
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
import { helpers } from "@budibase/shared-core"
|
||||||
|
export const deepGet = helpers.deepGet
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a DOM safe UUID.
|
* Generates a DOM safe UUID.
|
||||||
* Starting with a letter is important to make it DOM safe.
|
* Starting with a letter is important to make it DOM safe.
|
||||||
|
@ -41,30 +44,6 @@ export const hashString = string => {
|
||||||
return hash.toString()
|
return hash.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a key within an object. The key supports dot syntax for retrieving deep
|
|
||||||
* fields - e.g. "a.b.c".
|
|
||||||
* Exact matches of keys with dots in them take precedence over nested keys of
|
|
||||||
* the same path - e.g. getting "a.b" from { "a.b": "foo", a: { b: "bar" } }
|
|
||||||
* will return "foo" over "bar".
|
|
||||||
* @param obj the object
|
|
||||||
* @param key the key
|
|
||||||
* @return {*|null} the value or null if a value was not found for this key
|
|
||||||
*/
|
|
||||||
export const deepGet = (obj, key) => {
|
|
||||||
if (!obj || !key) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
||||||
return obj[key]
|
|
||||||
}
|
|
||||||
const split = key.split(".")
|
|
||||||
for (let i = 0; i < split.length; i++) {
|
|
||||||
obj = obj?.[split[i]]
|
|
||||||
}
|
|
||||||
return obj
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets a key within an object. The key supports dot syntax for retrieving deep
|
* Sets a key within an object. The key supports dot syntax for retrieving deep
|
||||||
* fields - e.g. "a.b.c".
|
* fields - e.g. "a.b.c".
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@budibase/builder",
|
"name": "@budibase/builder",
|
||||||
"version": "2.3.18-alpha.15",
|
"version": "2.4.12-alpha.0",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -58,10 +58,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@budibase/bbui": "2.3.18-alpha.15",
|
"@budibase/bbui": "2.4.12-alpha.0",
|
||||||
"@budibase/client": "2.3.18-alpha.15",
|
"@budibase/client": "2.4.12-alpha.0",
|
||||||
"@budibase/frontend-core": "2.3.18-alpha.15",
|
"@budibase/frontend-core": "2.4.12-alpha.0",
|
||||||
"@budibase/string-templates": "2.3.18-alpha.15",
|
"@budibase/shared-core": "2.4.12-alpha.0",
|
||||||
|
"@budibase/string-templates": "2.4.12-alpha.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.2.1",
|
"@fortawesome/fontawesome-svg-core": "^6.2.1",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.2.1",
|
"@fortawesome/free-brands-svg-icons": "^6.2.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||||
|
|
|
@ -72,6 +72,8 @@ const INITIAL_FRONTEND_STATE = {
|
||||||
// onboarding
|
// onboarding
|
||||||
onboarding: false,
|
onboarding: false,
|
||||||
tourNodes: null,
|
tourNodes: null,
|
||||||
|
|
||||||
|
builderSidePanel: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFrontendStore = () => {
|
export const getFrontendStore = () => {
|
||||||
|
|
|
@ -73,14 +73,14 @@
|
||||||
<Tabs noHorizPadding selected="Input">
|
<Tabs noHorizPadding selected="Input">
|
||||||
<Tab title="Input">
|
<Tab title="Input">
|
||||||
<TextArea
|
<TextArea
|
||||||
minHeight="80px"
|
minHeight="160px"
|
||||||
disabled
|
disabled
|
||||||
value={textArea(filteredResults?.[idx]?.inputs, "No input")}
|
value={textArea(filteredResults?.[idx]?.inputs, "No input")}
|
||||||
/>
|
/>
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab title="Output">
|
<Tab title="Output">
|
||||||
<TextArea
|
<TextArea
|
||||||
minHeight="100px"
|
minHeight="160px"
|
||||||
disabled
|
disabled
|
||||||
value={textArea(filteredResults?.[idx]?.outputs, "No output")}
|
value={textArea(filteredResults?.[idx]?.outputs, "No output")}
|
||||||
/>
|
/>
|
||||||
|
@ -98,8 +98,9 @@
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.container {
|
.container {
|
||||||
padding: 0 30px 0 30px;
|
padding: 0 30px 30px 30px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
|
|
|
@ -0,0 +1,333 @@
|
||||||
|
<script>
|
||||||
|
import {
|
||||||
|
Context,
|
||||||
|
Icon,
|
||||||
|
Input,
|
||||||
|
ModalContent,
|
||||||
|
Detail,
|
||||||
|
notifications,
|
||||||
|
} from "@budibase/bbui"
|
||||||
|
import { API } from "api"
|
||||||
|
import { goto } from "@roxi/routify"
|
||||||
|
import {
|
||||||
|
store,
|
||||||
|
sortedScreens,
|
||||||
|
automationStore,
|
||||||
|
themeStore,
|
||||||
|
} from "builderStore"
|
||||||
|
import { datasources, queries, tables, views } from "stores/backend"
|
||||||
|
import { getContext } from "svelte"
|
||||||
|
import { Constants } from "@budibase/frontend-core"
|
||||||
|
|
||||||
|
const modalContext = getContext(Context.Modal)
|
||||||
|
const commands = [
|
||||||
|
{
|
||||||
|
type: "Access",
|
||||||
|
name: "Invite users and manage app access",
|
||||||
|
description: "",
|
||||||
|
icon: "User",
|
||||||
|
action: () =>
|
||||||
|
store.update(state => ({ ...state, builderSidePanel: true })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Navigate",
|
||||||
|
name: "Portal",
|
||||||
|
description: "",
|
||||||
|
icon: "Compass",
|
||||||
|
action: () => $goto("../../portal"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Navigate",
|
||||||
|
name: "Data",
|
||||||
|
description: "",
|
||||||
|
icon: "Compass",
|
||||||
|
action: () => $goto("./data"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Navigate",
|
||||||
|
name: "Design",
|
||||||
|
description: "",
|
||||||
|
icon: "Compass",
|
||||||
|
action: () => $goto("./design"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Navigate",
|
||||||
|
name: "Automations",
|
||||||
|
description: "",
|
||||||
|
icon: "Compass",
|
||||||
|
action: () => $goto("./automate"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Publish",
|
||||||
|
name: "App",
|
||||||
|
description: "Deploy your application",
|
||||||
|
icon: "Box",
|
||||||
|
action: deployApp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Preview",
|
||||||
|
name: "App",
|
||||||
|
description: "",
|
||||||
|
icon: "Play",
|
||||||
|
action: () => window.open(`/${$store.appId}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Preview",
|
||||||
|
name: "Published App",
|
||||||
|
icon: "Play",
|
||||||
|
action: () => window.open(`/app${$store.url}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Support",
|
||||||
|
name: "Raise Github Discussion",
|
||||||
|
icon: "Help",
|
||||||
|
action: () =>
|
||||||
|
window.open(`https://github.com/Budibase/budibase/discussions/new`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "Support",
|
||||||
|
name: "Raise A Bug",
|
||||||
|
icon: "Bug",
|
||||||
|
action: () =>
|
||||||
|
window.open(
|
||||||
|
`https://github.com/Budibase/budibase/issues/new?assignees=&labels=bug&template=bug_report.md&title=`
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...$datasources?.list.map(datasource => ({
|
||||||
|
type: "Datasource",
|
||||||
|
name: `${datasource.name}`,
|
||||||
|
icon: "Data",
|
||||||
|
action: () => $goto(`./data/datasource/${datasource._id}`),
|
||||||
|
})),
|
||||||
|
...$tables?.list.map(table => ({
|
||||||
|
type: "Table",
|
||||||
|
name: table.name,
|
||||||
|
icon: "Table",
|
||||||
|
action: () => $goto(`./data/table/${table._id}`),
|
||||||
|
})),
|
||||||
|
...$views?.list.map(view => ({
|
||||||
|
type: "View",
|
||||||
|
name: view.name,
|
||||||
|
icon: "Remove",
|
||||||
|
action: () => $goto(`./data/view/${view.name}`),
|
||||||
|
})),
|
||||||
|
...$queries?.list.map(query => ({
|
||||||
|
type: "Query",
|
||||||
|
name: query.name,
|
||||||
|
icon: "SQLQuery",
|
||||||
|
action: () => $goto(`./data/query/${query._id}`),
|
||||||
|
})),
|
||||||
|
...$sortedScreens.map(screen => ({
|
||||||
|
type: "Screen",
|
||||||
|
name: screen.routing.route,
|
||||||
|
icon: "WebPage",
|
||||||
|
action: () => $goto(`./design/${screen._id}/components`),
|
||||||
|
})),
|
||||||
|
...$automationStore?.automations.map(automation => ({
|
||||||
|
type: "Automation",
|
||||||
|
name: automation.name,
|
||||||
|
icon: "ShareAndroid",
|
||||||
|
action: () => $goto(`./automate/${automation._id}`),
|
||||||
|
})),
|
||||||
|
...Constants.Themes.map(theme => ({
|
||||||
|
type: "Change Builder Theme",
|
||||||
|
name: theme.name,
|
||||||
|
icon: "ColorPalette",
|
||||||
|
action: () =>
|
||||||
|
themeStore.update(state => {
|
||||||
|
state.theme = theme.class
|
||||||
|
return state
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
let search
|
||||||
|
let selected = null
|
||||||
|
|
||||||
|
$: enrichedCommands = commands.map(cmd => ({
|
||||||
|
...cmd,
|
||||||
|
searchValue: `${cmd.type} ${cmd.name}`.toLowerCase(),
|
||||||
|
}))
|
||||||
|
$: results = filterResults(enrichedCommands, search)
|
||||||
|
$: categories = groupResults(results)
|
||||||
|
|
||||||
|
const filterResults = (commands, search) => {
|
||||||
|
if (!search) {
|
||||||
|
selected = null
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
selected = 0
|
||||||
|
search = search.toLowerCase()
|
||||||
|
return commands
|
||||||
|
.filter(cmd => cmd.searchValue.includes(search))
|
||||||
|
.map((cmd, idx) => ({
|
||||||
|
...cmd,
|
||||||
|
idx,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupResults = results => {
|
||||||
|
let categories = {}
|
||||||
|
results?.forEach(result => {
|
||||||
|
if (!categories[result.type]) {
|
||||||
|
categories[result.type] = []
|
||||||
|
}
|
||||||
|
categories[result.type].push(result)
|
||||||
|
})
|
||||||
|
return Object.entries(categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = e => {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
if (selected === null) {
|
||||||
|
selected = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selected < results.length - 1) {
|
||||||
|
selected += 1
|
||||||
|
}
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault()
|
||||||
|
if (selected === null) {
|
||||||
|
selected = results.length - 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selected > 0) {
|
||||||
|
selected -= 1
|
||||||
|
}
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
if (selected == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runAction(results[selected])
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
modalContext.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deployApp() {
|
||||||
|
try {
|
||||||
|
await API.deployAppChanges()
|
||||||
|
notifications.success("Application published successfully")
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error("Error publishing app")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runAction = command => {
|
||||||
|
if (!command) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
command.action()
|
||||||
|
modalContext.hide()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={onKeyDown} />
|
||||||
|
<ModalContent
|
||||||
|
size="L"
|
||||||
|
showCancelButton={false}
|
||||||
|
showConfirmButton={false}
|
||||||
|
showCloseIcon={false}
|
||||||
|
>
|
||||||
|
<div class="content">
|
||||||
|
<div class="title">
|
||||||
|
<Icon size="XL" name="Search" />
|
||||||
|
<Input bind:value={search} quiet placeholder="Search for command" />
|
||||||
|
</div>
|
||||||
|
<div class="commands">
|
||||||
|
{#each categories as [name, results], catIdx}
|
||||||
|
<div class="category">
|
||||||
|
<Detail>{name}</Detail>
|
||||||
|
<div class="options">
|
||||||
|
{#each results as command, cmdIdx}
|
||||||
|
<div
|
||||||
|
class="command"
|
||||||
|
on:click={() => runAction(command)}
|
||||||
|
class:selected={command.idx === selected}
|
||||||
|
>
|
||||||
|
<Icon size="M" name={command.icon} />
|
||||||
|
<strong>{command.type}: </strong>
|
||||||
|
<div class="name">
|
||||||
|
{command.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.content {
|
||||||
|
margin: -40px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-xl) var(--spacing-xl) var(--spacing-l)
|
||||||
|
var(--spacing-xl);
|
||||||
|
border-bottom: var(--border-dark);
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
}
|
||||||
|
.title :global(.spectrum-Textfield-input) {
|
||||||
|
border-bottom: none;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commands {
|
||||||
|
height: 378px;
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category {
|
||||||
|
padding: var(--spacing-m) var(--spacing-xl);
|
||||||
|
border-bottom: var(--border-light);
|
||||||
|
}
|
||||||
|
.category:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.category :global(.spectrum-Detail) {
|
||||||
|
color: var(--spectrum-global-color-gray-600);
|
||||||
|
}
|
||||||
|
.options {
|
||||||
|
padding-top: var(--spacing-m);
|
||||||
|
margin: 0 calc(-1 * var(--spacing-xl));
|
||||||
|
}
|
||||||
|
|
||||||
|
.command {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-s) var(--spacing-xl);
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: color 130ms ease-out, background-color 130ms ease-out;
|
||||||
|
}
|
||||||
|
.command:hover,
|
||||||
|
.selected {
|
||||||
|
color: var(--spectrum-global-color-gray-900);
|
||||||
|
background-color: var(--spectrum-global-color-gray-300);
|
||||||
|
}
|
||||||
|
.command strong {
|
||||||
|
margin-left: var(--spacing-m);
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -11,16 +11,24 @@
|
||||||
export let quiet = false
|
export let quiet = false
|
||||||
export let allowPublic = true
|
export let allowPublic = true
|
||||||
export let allowRemove = false
|
export let allowRemove = false
|
||||||
|
export let disabled = false
|
||||||
|
export let align
|
||||||
|
export let footer = null
|
||||||
|
export let allowedRoles = null
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
const RemoveID = "remove"
|
const RemoveID = "remove"
|
||||||
|
|
||||||
$: options = getOptions($roles, allowPublic, allowRemove)
|
$: options = getOptions($roles, allowPublic, allowRemove, allowedRoles)
|
||||||
|
|
||||||
const getOptions = (roles, allowPublic) => {
|
const getOptions = (roles, allowPublic, allowRemove, allowedRoles) => {
|
||||||
|
if (allowedRoles?.length) {
|
||||||
|
return roles.filter(role => allowedRoles.includes(role._id))
|
||||||
|
}
|
||||||
|
let newRoles = [...roles]
|
||||||
if (allowRemove) {
|
if (allowRemove) {
|
||||||
roles = [
|
newRoles = [
|
||||||
...roles,
|
...newRoles,
|
||||||
{
|
{
|
||||||
_id: RemoveID,
|
_id: RemoveID,
|
||||||
name: "Remove",
|
name: "Remove",
|
||||||
|
@ -28,9 +36,9 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
if (allowPublic) {
|
if (allowPublic) {
|
||||||
return roles
|
return newRoles
|
||||||
}
|
}
|
||||||
return roles.filter(role => role._id !== Constants.Roles.PUBLIC)
|
return newRoles.filter(role => role._id !== Constants.Roles.PUBLIC)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getColor = role => {
|
const getColor = role => {
|
||||||
|
@ -59,6 +67,9 @@
|
||||||
<Select
|
<Select
|
||||||
{autoWidth}
|
{autoWidth}
|
||||||
{quiet}
|
{quiet}
|
||||||
|
{disabled}
|
||||||
|
{align}
|
||||||
|
{footer}
|
||||||
bind:value
|
bind:value
|
||||||
on:change={onChange}
|
on:change={onChange}
|
||||||
{options}
|
{options}
|
||||||
|
|
|
@ -6,8 +6,10 @@
|
||||||
Heading,
|
Heading,
|
||||||
Body,
|
Body,
|
||||||
Button,
|
Button,
|
||||||
Icon,
|
ActionButton,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
|
import RevertModal from "components/deploy/RevertModal.svelte"
|
||||||
|
import VersionModal from "components/deploy/VersionModal.svelte"
|
||||||
import { processStringSync } from "@budibase/string-templates"
|
import { processStringSync } from "@budibase/string-templates"
|
||||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||||
import analytics, { Events, EventSource } from "analytics"
|
import analytics, { Events, EventSource } from "analytics"
|
||||||
|
@ -16,6 +18,9 @@
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
import DeployModal from "components/deploy/DeployModal.svelte"
|
import DeployModal from "components/deploy/DeployModal.svelte"
|
||||||
import { apps } from "stores/portal"
|
import { apps } from "stores/portal"
|
||||||
|
import { store } from "builderStore"
|
||||||
|
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
|
||||||
|
import { TOUR_STEP_KEYS } from "components/portal/onboarding/tours.js"
|
||||||
|
|
||||||
export let application
|
export let application
|
||||||
|
|
||||||
|
@ -108,14 +113,20 @@
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="deployment-top-nav">
|
<div class="action-top-nav">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<div class="version">
|
||||||
|
<VersionModal />
|
||||||
|
</div>
|
||||||
|
<RevertModal />
|
||||||
|
|
||||||
{#if isPublished}
|
{#if isPublished}
|
||||||
<div class="publish-popover">
|
<div class="publish-popover">
|
||||||
<div bind:this={publishPopoverAnchor}>
|
<div bind:this={publishPopoverAnchor}>
|
||||||
<Icon
|
<ActionButton
|
||||||
|
quiet
|
||||||
|
icon="Globe"
|
||||||
size="M"
|
size="M"
|
||||||
hoverable
|
|
||||||
name="Globe"
|
|
||||||
tooltip="Your published app"
|
tooltip="Your published app"
|
||||||
on:click={publishPopover.show()}
|
on:click={publishPopover.show()}
|
||||||
/>
|
/>
|
||||||
|
@ -160,14 +171,39 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !isPublished}
|
{#if !isPublished}
|
||||||
<Icon
|
<ActionButton
|
||||||
|
quiet
|
||||||
|
icon="GlobeStrike"
|
||||||
size="M"
|
size="M"
|
||||||
name="GlobeStrike"
|
|
||||||
disabled
|
|
||||||
tooltip="Your app has not been published yet"
|
tooltip="Your app has not been published yet"
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<TourWrap
|
||||||
|
tourStepKey={$store.onboarding
|
||||||
|
? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT
|
||||||
|
: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT}
|
||||||
|
>
|
||||||
|
<span id="builder-app-users-button">
|
||||||
|
<ActionButton
|
||||||
|
quiet
|
||||||
|
icon="UserGroup"
|
||||||
|
size="M"
|
||||||
|
on:click={() => {
|
||||||
|
store.update(state => {
|
||||||
|
state.builderSidePanel = true
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Users
|
||||||
|
</ActionButton>
|
||||||
|
</span>
|
||||||
|
</TourWrap>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
bind:this={unpublishModal}
|
bind:this={unpublishModal}
|
||||||
title="Confirm unpublish"
|
title="Confirm unpublish"
|
||||||
|
@ -183,6 +219,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* .banner-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-s);
|
||||||
|
} */
|
||||||
.popover-content {
|
.popover-content {
|
||||||
padding: var(--spacing-xl);
|
padding: var(--spacing-xl);
|
||||||
}
|
}
|
||||||
|
@ -191,6 +232,22 @@
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-m);
|
gap: var(--spacing-l);
|
||||||
|
}
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
/* gap: var(--spacing-s); */
|
||||||
|
}
|
||||||
|
.version {
|
||||||
|
margin-right: var(--spacing-s);
|
||||||
|
}
|
||||||
|
.action-top-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,10 +1,10 @@
|
||||||
<script>
|
<script>
|
||||||
import {
|
import {
|
||||||
Icon,
|
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
notifications,
|
notifications,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
|
ActionButton,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import { store } from "builderStore"
|
import { store } from "builderStore"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
|
@ -28,12 +28,14 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Icon
|
<ActionButton
|
||||||
name="Revert"
|
quiet
|
||||||
hoverable
|
icon="Revert"
|
||||||
on:click={revertModal.show}
|
size="M"
|
||||||
tooltip="Revert changes"
|
tooltip="Revert changes"
|
||||||
|
on:click={revertModal.show}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal bind:this={revertModal}>
|
<Modal bind:this={revertModal}>
|
||||||
<ModalContent
|
<ModalContent
|
||||||
title="Revert Changes"
|
title="Revert Changes"
|
||||||
|
|
|
@ -24,7 +24,10 @@
|
||||||
let updateModal
|
let updateModal
|
||||||
|
|
||||||
$: appId = $store.appId
|
$: appId = $store.appId
|
||||||
$: updateAvailable = clientPackage.version !== $store.version
|
$: updateAvailable =
|
||||||
|
clientPackage.version &&
|
||||||
|
$store.version &&
|
||||||
|
clientPackage.version !== $store.version
|
||||||
$: revertAvailable = $store.revertableVersion != null
|
$: revertAvailable = $store.revertableVersion != null
|
||||||
|
|
||||||
const refreshAppPackage = async () => {
|
const refreshAppPackage = async () => {
|
||||||
|
|
|
@ -14,10 +14,11 @@
|
||||||
export let borderRight = false
|
export let borderRight = false
|
||||||
|
|
||||||
let wide = false
|
let wide = false
|
||||||
|
$: customHeaderContent = $$slots["panel-header-content"]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="panel" class:wide class:borderLeft class:borderRight>
|
<div class="panel" class:wide class:borderLeft class:borderRight>
|
||||||
<div class="header">
|
<div class="header" class:custom={customHeaderContent}>
|
||||||
{#if showBackButton}
|
{#if showBackButton}
|
||||||
<Icon name="ArrowLeft" hoverable on:click={onClickBackButton} />
|
<Icon name="ArrowLeft" hoverable on:click={onClickBackButton} />
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -43,6 +44,13 @@
|
||||||
<Icon name="Close" hoverable on:click={onClickCloseButton} />
|
<Icon name="Close" hoverable on:click={onClickCloseButton} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if customHeaderContent}
|
||||||
|
<span class="custom-content-wrap">
|
||||||
|
<slot name="panel-header-content" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
@ -116,4 +124,10 @@
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
.header.custom {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.custom-content-wrap {
|
||||||
|
border-bottom: var(--border-light);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -20,6 +20,12 @@
|
||||||
x =>
|
x =>
|
||||||
x._id !== BUDIBASE_INTERNAL_DB_ID && x.type !== BUDIBASE_DATASOURCE_TYPE
|
x._id !== BUDIBASE_INTERNAL_DB_ID && x.type !== BUDIBASE_DATASOURCE_TYPE
|
||||||
)
|
)
|
||||||
|
// Ensure query params exist so they can be bound
|
||||||
|
$: {
|
||||||
|
if (!parameters.queryParams) {
|
||||||
|
parameters.queryParams = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fetchQueryDefinition(query) {
|
function fetchQueryDefinition(query) {
|
||||||
const source = $datasources.list.find(
|
const source = $datasources.list.find(
|
||||||
|
|
|
@ -1,22 +1,66 @@
|
||||||
<script>
|
<script>
|
||||||
import { Label, Checkbox } from "@budibase/bbui"
|
import { store } from "builderStore"
|
||||||
|
import { onMount } from "svelte"
|
||||||
|
import { Label, Checkbox, Select } from "@budibase/bbui"
|
||||||
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
|
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
|
||||||
|
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
|
||||||
|
|
||||||
export let parameters
|
export let parameters
|
||||||
export let bindings = []
|
export let bindings = []
|
||||||
|
|
||||||
|
$: urlOptions = $store.screens
|
||||||
|
.map(screen => screen.routing?.route)
|
||||||
|
.filter(x => x != null)
|
||||||
|
|
||||||
|
const typeOptions = [
|
||||||
|
{
|
||||||
|
label: "Screen",
|
||||||
|
value: "screen",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "URL",
|
||||||
|
value: "url",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!parameters.type) {
|
||||||
|
parameters.type = "screen"
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root">
|
<div class="root">
|
||||||
<Label small>Screen</Label>
|
<Label small>Destination</Label>
|
||||||
<DrawerBindableInput
|
<Select
|
||||||
title="Destination URL"
|
placeholder={null}
|
||||||
|
bind:value={parameters.type}
|
||||||
|
options={typeOptions}
|
||||||
|
on:change={() => (parameters.url = "")}
|
||||||
|
/>
|
||||||
|
{#if parameters.type === "screen"}
|
||||||
|
<DrawerBindableCombobox
|
||||||
|
title="Destination"
|
||||||
placeholder="/screen"
|
placeholder="/screen"
|
||||||
value={parameters.url}
|
value={parameters.url}
|
||||||
on:change={value => (parameters.url = value.detail)}
|
on:change={value => (parameters.url = value.detail)}
|
||||||
{bindings}
|
{bindings}
|
||||||
|
options={urlOptions}
|
||||||
|
appendBindingsAsOptions={false}
|
||||||
|
/>
|
||||||
|
<div />
|
||||||
|
<Checkbox text="Open screen in modal" bind:value={parameters.peek} />
|
||||||
|
{:else}
|
||||||
|
<DrawerBindableInput
|
||||||
|
title="Destination"
|
||||||
|
placeholder="/url"
|
||||||
|
value={parameters.url}
|
||||||
|
on:change={value => (parameters.url = value.detail)}
|
||||||
|
{bindings}
|
||||||
/>
|
/>
|
||||||
<div />
|
<div />
|
||||||
<Checkbox text="Open screen in modal" bind:value={parameters.peek} />
|
<Checkbox text="New Tab" bind:value={parameters.externalNewTab} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
@ -24,7 +68,7 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-m);
|
gap: var(--spacing-m);
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
getSchemaForDatasource,
|
getSchemaForDatasource,
|
||||||
} from "builderStore/dataBinding"
|
} from "builderStore/dataBinding"
|
||||||
import { currentAsset } from "builderStore"
|
import { currentAsset } from "builderStore"
|
||||||
|
import { getFields } from "helpers/searchFields"
|
||||||
|
|
||||||
export let componentInstance
|
export let componentInstance
|
||||||
export let value = []
|
export let value = []
|
||||||
|
@ -21,9 +22,14 @@
|
||||||
|
|
||||||
$: datasource = getDatasourceForProvider($currentAsset, componentInstance)
|
$: datasource = getDatasourceForProvider($currentAsset, componentInstance)
|
||||||
$: schema = getSchema($currentAsset, datasource)
|
$: schema = getSchema($currentAsset, datasource)
|
||||||
$: options = Object.keys(schema || {})
|
$: options = allowCellEditing
|
||||||
|
? Object.keys(schema || {})
|
||||||
|
: enrichedSchemaFields?.map(field => field.name)
|
||||||
$: sanitisedValue = getValidColumns(value, options)
|
$: sanitisedValue = getValidColumns(value, options)
|
||||||
$: updateBoundValue(sanitisedValue)
|
$: updateBoundValue(sanitisedValue)
|
||||||
|
$: enrichedSchemaFields = getFields(Object.values(schema) || [], {
|
||||||
|
allowLinks: true,
|
||||||
|
})
|
||||||
|
|
||||||
const getSchema = (asset, datasource) => {
|
const getSchema = (asset, datasource) => {
|
||||||
const schema = getSchemaForDatasource(asset, datasource).schema
|
const schema = getSchemaForDatasource(asset, datasource).schema
|
||||||
|
|
|
@ -35,6 +35,7 @@
|
||||||
let parameters
|
let parameters
|
||||||
let data = []
|
let data = []
|
||||||
let saveId
|
let saveId
|
||||||
|
let currentTab = "JSON"
|
||||||
|
|
||||||
$: datasource = $datasources.list.find(ds => ds._id === query.datasourceId)
|
$: datasource = $datasources.list.find(ds => ds._id === query.datasourceId)
|
||||||
$: query.schema = fieldsToSchema(fields)
|
$: query.schema = fieldsToSchema(fields)
|
||||||
|
@ -84,7 +85,16 @@
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data = response.rows
|
data = response.rows
|
||||||
|
// need to merge fields that already exist/might have changed
|
||||||
|
if (fields) {
|
||||||
|
for (let key of Object.keys(response.schema)) {
|
||||||
|
if (fields[key]) {
|
||||||
|
response.schema[key] = fields[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
fields = response.schema
|
fields = response.schema
|
||||||
|
currentTab = "JSON"
|
||||||
notifications.success("Query executed successfully")
|
notifications.success("Query executed successfully")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error(`Query Error: ${error.message}`)
|
notifications.error(`Query Error: ${error.message}`)
|
||||||
|
@ -205,7 +215,7 @@
|
||||||
</Body>
|
</Body>
|
||||||
<section class="viewer">
|
<section class="viewer">
|
||||||
{#if data}
|
{#if data}
|
||||||
<Tabs selected="JSON">
|
<Tabs bind:selected={currentTab}>
|
||||||
<Tab title="JSON">
|
<Tab title="JSON">
|
||||||
<JSONPreview data={data[0]} minHeight="120" />
|
<JSONPreview data={data[0]} minHeight="120" />
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|
|
@ -120,7 +120,7 @@
|
||||||
|
|
||||||
const cleanUrl = inputUrl =>
|
const cleanUrl = inputUrl =>
|
||||||
url
|
url
|
||||||
?.replace(/(http)|(https)|[{}:]/g, "")
|
?.replace(/(https)|(http)|[{}:]/g, "")
|
||||||
?.replaceAll(".", "_")
|
?.replaceAll(".", "_")
|
||||||
?.replaceAll("/", " ")
|
?.replaceAll("/", " ")
|
||||||
?.trim() || inputUrl
|
?.trim() || inputUrl
|
||||||
|
|
|
@ -122,7 +122,9 @@
|
||||||
<Layout noPadding gap="M">
|
<Layout noPadding gap="M">
|
||||||
<div class="tour-header">
|
<div class="tour-header">
|
||||||
<Heading size="XS">{tourStep?.title || "-"}</Heading>
|
<Heading size="XS">{tourStep?.title || "-"}</Heading>
|
||||||
|
{#if tourSteps?.length > 1}
|
||||||
<div>{`${tourStepIdx + 1}/${tourSteps?.length}`}</div>
|
<div>{`${tourStepIdx + 1}/${tourSteps?.length}`}</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<Body size="S">
|
<Body size="S">
|
||||||
<span class="tour-body">
|
<span class="tour-body">
|
||||||
|
|
|
@ -6,16 +6,19 @@
|
||||||
|
|
||||||
export let tourStepKey
|
export let tourStepKey
|
||||||
|
|
||||||
let currentTour
|
let currentTourStep
|
||||||
let ready = false
|
let ready = false
|
||||||
let handler
|
let handler
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!$store.tourKey) return
|
if (!$store.tourKey) return
|
||||||
|
|
||||||
currentTour = TOURS[$store.tourKey].find(step => step.id === tourStepKey)
|
currentTourStep = TOURS[$store.tourKey].find(
|
||||||
|
step => step.id === tourStepKey
|
||||||
|
)
|
||||||
|
if (!currentTourStep) return
|
||||||
|
|
||||||
const elem = document.querySelector(currentTour.query)
|
const elem = document.querySelector(currentTourStep.query)
|
||||||
handler = tourHandler(elem, tourStepKey)
|
handler = tourHandler(elem, tourStepKey)
|
||||||
ready = true
|
ready = true
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,19 +1,23 @@
|
||||||
import { get } from "svelte/store"
|
import { get } from "svelte/store"
|
||||||
import { store } from "builderStore"
|
import { store } from "builderStore"
|
||||||
import { users, auth } from "stores/portal"
|
import { auth } from "stores/portal"
|
||||||
import analytics from "analytics"
|
import analytics from "analytics"
|
||||||
import { OnboardingData, OnboardingDesign, OnboardingPublish } from "./steps"
|
import { OnboardingData, OnboardingDesign, OnboardingPublish } from "./steps"
|
||||||
|
import { API } from "api"
|
||||||
const ONBOARDING_EVENT_PREFIX = "onboarding"
|
const ONBOARDING_EVENT_PREFIX = "onboarding"
|
||||||
|
|
||||||
export const TOUR_STEP_KEYS = {
|
export const TOUR_STEP_KEYS = {
|
||||||
BUILDER_APP_PUBLISH: "builder-app-publish",
|
BUILDER_APP_PUBLISH: "builder-app-publish",
|
||||||
BUILDER_DATA_SECTION: "builder-data-section",
|
BUILDER_DATA_SECTION: "builder-data-section",
|
||||||
BUILDER_DESIGN_SECTION: "builder-design-section",
|
BUILDER_DESIGN_SECTION: "builder-design-section",
|
||||||
|
BUILDER_USER_MANAGEMENT: "builder-user-management",
|
||||||
BUILDER_AUTOMATE_SECTION: "builder-automate-section",
|
BUILDER_AUTOMATE_SECTION: "builder-automate-section",
|
||||||
|
FEATURE_USER_MANAGEMENT: "feature-user-management",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TOUR_KEYS = {
|
export const TOUR_KEYS = {
|
||||||
TOUR_BUILDER_ONBOARDING: "builder-onboarding",
|
TOUR_BUILDER_ONBOARDING: "builder-onboarding",
|
||||||
|
FEATURE_ONBOARDING: "feature-onboarding",
|
||||||
}
|
}
|
||||||
|
|
||||||
const tourEvent = eventKey => {
|
const tourEvent = eventKey => {
|
||||||
|
@ -58,6 +62,15 @@ const getTours = () => {
|
||||||
},
|
},
|
||||||
align: "left",
|
align: "left",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT,
|
||||||
|
title: "Users",
|
||||||
|
query: ".toprightnav #builder-app-users-button",
|
||||||
|
body: "Add users to your app and control what level of access they have.",
|
||||||
|
onLoad: () => {
|
||||||
|
tourEvent(TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: TOUR_STEP_KEYS.BUILDER_APP_PUBLISH,
|
id: TOUR_STEP_KEYS.BUILDER_APP_PUBLISH,
|
||||||
title: "Publish",
|
title: "Publish",
|
||||||
|
@ -71,8 +84,37 @@ const getTours = () => {
|
||||||
// Mark the users onboarding as complete
|
// Mark the users onboarding as complete
|
||||||
// Clear all tour related state
|
// Clear all tour related state
|
||||||
if (get(auth).user) {
|
if (get(auth).user) {
|
||||||
await users.save({
|
await API.updateSelf({
|
||||||
...get(auth).user,
|
onboardedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update the cached user
|
||||||
|
await auth.getSelf()
|
||||||
|
|
||||||
|
store.update(state => ({
|
||||||
|
...state,
|
||||||
|
tourNodes: undefined,
|
||||||
|
tourKey: undefined,
|
||||||
|
tourKeyStep: undefined,
|
||||||
|
onboarding: false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[TOUR_KEYS.FEATURE_ONBOARDING]: [
|
||||||
|
{
|
||||||
|
id: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT,
|
||||||
|
title: "Users",
|
||||||
|
query: ".toprightnav #builder-app-users-button",
|
||||||
|
body: "Add users to your app and control what level of access they have.",
|
||||||
|
onLoad: () => {
|
||||||
|
tourEvent(TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT)
|
||||||
|
},
|
||||||
|
onComplete: async () => {
|
||||||
|
// Push the onboarding forward
|
||||||
|
if (get(auth).user) {
|
||||||
|
await API.updateSelf({
|
||||||
onboardedAt: new Date().toISOString(),
|
onboardedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
await auth.updateSelf($values)
|
await auth.updateSelf($values)
|
||||||
notifications.success("Information updated successfully")
|
notifications.success("Information updated successfully")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
notifications.error("Failed to update information")
|
notifications.error("Failed to update information")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,7 @@
|
||||||
return list.map(item => {
|
return list.map(item => {
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
selected: selected.find(x => x === item._id) != null,
|
selected: selected?.find(x => x === item._id) != null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,3 +62,8 @@ export const PluginSource = {
|
||||||
GITHUB: "Github",
|
GITHUB: "Github",
|
||||||
FILE: "File Upload",
|
FILE: "File Upload",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const OnboardingType = {
|
||||||
|
EMAIL: "email",
|
||||||
|
PASSWORD: "password",
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,774 @@
|
||||||
|
<script>
|
||||||
|
import {
|
||||||
|
Icon,
|
||||||
|
Heading,
|
||||||
|
Layout,
|
||||||
|
Input,
|
||||||
|
clickOutside,
|
||||||
|
notifications,
|
||||||
|
ActionButton,
|
||||||
|
} from "@budibase/bbui"
|
||||||
|
import { store } from "builderStore"
|
||||||
|
import { groups, licensing, apps, users } from "stores/portal"
|
||||||
|
import { fetchData } from "@budibase/frontend-core"
|
||||||
|
import { API } from "api"
|
||||||
|
import { onMount } from "svelte"
|
||||||
|
import GroupIcon from "../../../portal/users/groups/_components/GroupIcon.svelte"
|
||||||
|
import RoleSelect from "components/common/RoleSelect.svelte"
|
||||||
|
import { Constants, Utils } from "@budibase/frontend-core"
|
||||||
|
import { emailValidator } from "helpers/validation"
|
||||||
|
import CopyInput from "components/common/inputs/CopyInput.svelte"
|
||||||
|
import { roles } from "stores/backend"
|
||||||
|
|
||||||
|
let query = null
|
||||||
|
let loaded = false
|
||||||
|
let rendered = false
|
||||||
|
let inviting = false
|
||||||
|
let searchFocus = false
|
||||||
|
|
||||||
|
let appInvites = []
|
||||||
|
let filteredInvites = []
|
||||||
|
let filteredUsers = []
|
||||||
|
let filteredGroups = []
|
||||||
|
let selectedGroup
|
||||||
|
let userOnboardResponse = null
|
||||||
|
|
||||||
|
$: queryIsEmail = emailValidator(query) === true
|
||||||
|
$: prodAppId = apps.getProdAppID($store.appId)
|
||||||
|
$: promptInvite = showInvite(
|
||||||
|
filteredInvites,
|
||||||
|
filteredUsers,
|
||||||
|
filteredGroups,
|
||||||
|
query
|
||||||
|
)
|
||||||
|
|
||||||
|
const showInvite = (invites, users, groups, query) => {
|
||||||
|
return !invites?.length && !users?.length && !groups?.length && query
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterInvites = async query => {
|
||||||
|
appInvites = await getInvites()
|
||||||
|
if (!query || query == "") {
|
||||||
|
filteredInvites = appInvites
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filteredInvites = appInvites.filter(invite => invite.email.includes(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
$: filterInvites(query)
|
||||||
|
|
||||||
|
const usersFetch = fetchData({
|
||||||
|
API,
|
||||||
|
datasource: {
|
||||||
|
type: "user",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchUsers = async (query, sidePaneOpen, loaded) => {
|
||||||
|
if (!sidePaneOpen || !loaded) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!prodAppId) {
|
||||||
|
console.log("Application id required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await usersFetch.update({
|
||||||
|
query: {
|
||||||
|
appId: query ? null : prodAppId,
|
||||||
|
email: query,
|
||||||
|
paginated: query ? null : false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await usersFetch.refresh()
|
||||||
|
|
||||||
|
filteredUsers = $usersFetch.rows.map(user => {
|
||||||
|
const isBuilderOrAdmin = user.admin?.global || user.builder?.global
|
||||||
|
let role = undefined
|
||||||
|
if (isBuilderOrAdmin) {
|
||||||
|
role = Constants.Roles.ADMIN
|
||||||
|
} else {
|
||||||
|
const appRole = Object.keys(user.roles).find(x => x === prodAppId)
|
||||||
|
if (appRole) {
|
||||||
|
role = user.roles[appRole]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
role,
|
||||||
|
isBuilderOrAdmin,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedUpdateFetch = Utils.debounce(searchUsers, 250)
|
||||||
|
$: debouncedUpdateFetch(query, $store.builderSidePanel, loaded)
|
||||||
|
|
||||||
|
const updateAppUser = async (user, role) => {
|
||||||
|
if (!prodAppId) {
|
||||||
|
notifications.error("Application id must be specified")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const update = await users.get(user._id)
|
||||||
|
await users.save({
|
||||||
|
...update,
|
||||||
|
roles: {
|
||||||
|
...update.roles,
|
||||||
|
[prodAppId]: role,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await searchUsers(query, $store.builderSidePanel, loaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUpdateUser = async (user, role) => {
|
||||||
|
if (!user) {
|
||||||
|
notifications.error("A user must be specified")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (user.role === role) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await updateAppUser(user, role)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
notifications.error("User could not be updated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAppGroup = async (target, role) => {
|
||||||
|
if (!prodAppId) {
|
||||||
|
notifications.error("Application id must be specified")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
await groups.actions.removeApp(target._id, prodAppId)
|
||||||
|
} else {
|
||||||
|
await groups.actions.addApp(target._id, prodAppId, role)
|
||||||
|
}
|
||||||
|
|
||||||
|
await usersFetch.refresh()
|
||||||
|
await groups.actions.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUpdateGroup = async (group, role) => {
|
||||||
|
if (!group) {
|
||||||
|
notifications.error("A group must be specified")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateAppGroup(group, role)
|
||||||
|
} catch {
|
||||||
|
notifications.error("Group update failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAppGroups = (allGroups, appId) => {
|
||||||
|
if (!allGroups) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return allGroups.filter(group => {
|
||||||
|
if (!group.roles) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return groups.actions.getGroupAppIds(group).includes(appId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchGroups = (userGroups, query) => {
|
||||||
|
let filterGroups = query?.length
|
||||||
|
? userGroups
|
||||||
|
: getAppGroups(userGroups, prodAppId)
|
||||||
|
return filterGroups
|
||||||
|
.filter(group => {
|
||||||
|
if (!query?.length) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
//Group Name only.
|
||||||
|
const nameMatch = group.name
|
||||||
|
?.toLowerCase()
|
||||||
|
.includes(query?.toLowerCase())
|
||||||
|
|
||||||
|
return nameMatch
|
||||||
|
})
|
||||||
|
.map(enrichGroupRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrichGroupRole = group => {
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
role: group.roles?.[
|
||||||
|
groups.actions.getGroupAppIds(group).find(x => x === prodAppId)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEnrichedGroups = groups => {
|
||||||
|
return groups.map(enrichGroupRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds the 'role' attribute and sets it to the current app.
|
||||||
|
$: enrichedGroups = getEnrichedGroups($groups)
|
||||||
|
$: filteredGroups = searchGroups(enrichedGroups, query)
|
||||||
|
$: groupUsers = buildGroupUsers(filteredGroups, filteredUsers)
|
||||||
|
$: allUsers = [...filteredUsers, ...groupUsers]
|
||||||
|
|
||||||
|
/*
|
||||||
|
Create pseudo users from the "users" attribute on app groups.
|
||||||
|
These users will appear muted in the UI and show the ROLE
|
||||||
|
inherited from their parent group. The users allow assigning of user
|
||||||
|
specific roles for the app.
|
||||||
|
*/
|
||||||
|
const buildGroupUsers = (userGroups, filteredUsers) => {
|
||||||
|
if (query) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
// Must exclude users who have explicit privileges
|
||||||
|
const userByEmail = filteredUsers.reduce((acc, user) => {
|
||||||
|
if (user.role || user.admin?.global || user.builder?.global) {
|
||||||
|
acc.push(user.email)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const indexedUsers = userGroups.reduce((acc, group) => {
|
||||||
|
group.users.forEach(user => {
|
||||||
|
if (userByEmail.indexOf(user.email) == -1) {
|
||||||
|
acc[user._id] = {
|
||||||
|
_id: user._id,
|
||||||
|
email: user.email,
|
||||||
|
role: group.role,
|
||||||
|
group: group.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
return Object.values(indexedUsers)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInvites = async () => {
|
||||||
|
try {
|
||||||
|
const invites = await users.getInvites()
|
||||||
|
return invites
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error(error.message)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inviteUser() {
|
||||||
|
if (!queryIsEmail) {
|
||||||
|
notifications.error("Email is not valid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const newUserEmail = query + ""
|
||||||
|
inviting = true
|
||||||
|
|
||||||
|
const payload = [
|
||||||
|
{
|
||||||
|
email: newUserEmail,
|
||||||
|
builder: false,
|
||||||
|
admin: false,
|
||||||
|
apps: { [prodAppId]: Constants.Roles.BASIC },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
let userInviteResponse
|
||||||
|
try {
|
||||||
|
userInviteResponse = await users.onboard(payload)
|
||||||
|
|
||||||
|
const newUser = userInviteResponse?.successful.find(
|
||||||
|
user => user.email === newUserEmail
|
||||||
|
)
|
||||||
|
if (newUser) {
|
||||||
|
notifications.success(
|
||||||
|
userInviteResponse.created
|
||||||
|
? "User created successfully"
|
||||||
|
: "User invite successful"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
throw new Error("User invite failed")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message)
|
||||||
|
notifications.error("Error inviting user")
|
||||||
|
}
|
||||||
|
inviting = false
|
||||||
|
return userInviteResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
const onInviteUser = async () => {
|
||||||
|
userOnboardResponse = await inviteUser()
|
||||||
|
|
||||||
|
const userInviteSuccess = userOnboardResponse?.successful
|
||||||
|
if (userInviteSuccess && userInviteSuccess[0].email === query) {
|
||||||
|
query = null
|
||||||
|
query = userInviteSuccess[0].email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUpdateUserInvite = async (invite, role) => {
|
||||||
|
await users.updateInvite({
|
||||||
|
code: invite.code,
|
||||||
|
apps: {
|
||||||
|
...invite.apps,
|
||||||
|
[prodAppId]: role,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await filterInvites()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUninviteAppUser = async invite => {
|
||||||
|
await uninviteAppUser(invite)
|
||||||
|
await filterInvites()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge only the app from the invite or recind the invite if only 1 app remains?
|
||||||
|
const uninviteAppUser = async invite => {
|
||||||
|
let updated = { ...invite }
|
||||||
|
delete updated.info.apps[prodAppId]
|
||||||
|
|
||||||
|
return await users.updateInvite({
|
||||||
|
code: updated.code,
|
||||||
|
apps: updated.apps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const initSidePanel = async sidePaneOpen => {
|
||||||
|
if (sidePaneOpen === true) {
|
||||||
|
await groups.actions.init()
|
||||||
|
}
|
||||||
|
loaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
$: initSidePanel($store.builderSidePanel)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
rendered = true
|
||||||
|
searchFocus = true
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleKeyDown(evt) {
|
||||||
|
if (evt.key === "Enter" && queryIsEmail && !inviting) {
|
||||||
|
onInviteUser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userTitle = user => {
|
||||||
|
if (user.admin?.global) {
|
||||||
|
return "Admin"
|
||||||
|
} else if (user.builder?.global) {
|
||||||
|
return "Developer"
|
||||||
|
} else {
|
||||||
|
return "App user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRoleFooter = user => {
|
||||||
|
if (user.group) {
|
||||||
|
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) {
|
||||||
|
return "This user's role grants admin access to all apps"
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={handleKeyDown} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="builder-side-panel-container"
|
||||||
|
class:open={$store.builderSidePanel}
|
||||||
|
use:clickOutside={$store.builderSidePanel
|
||||||
|
? () => {
|
||||||
|
store.update(state => {
|
||||||
|
state.builderSidePanel = false
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
}
|
||||||
|
: () => {}}
|
||||||
|
>
|
||||||
|
<div class="builder-side-panel-header">
|
||||||
|
<Heading size="S">Users</Heading>
|
||||||
|
<Icon
|
||||||
|
color="var(--spectrum-global-color-gray-600)"
|
||||||
|
name="RailRightClose"
|
||||||
|
hoverable
|
||||||
|
on:click={() => {
|
||||||
|
store.update(state => {
|
||||||
|
state.builderSidePanel = false
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="search" class:focused={searchFocus}>
|
||||||
|
<span class="search-input">
|
||||||
|
<Input
|
||||||
|
placeholder={"Add users and groups to your app"}
|
||||||
|
autocomplete="off"
|
||||||
|
disabled={inviting}
|
||||||
|
value={query}
|
||||||
|
autofocus
|
||||||
|
on:input={e => {
|
||||||
|
query = e.target.value.trim()
|
||||||
|
}}
|
||||||
|
on:focus={() => (searchFocus = true)}
|
||||||
|
on:blur={() => (searchFocus = false)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="search-input-icon"
|
||||||
|
class:searching={query}
|
||||||
|
on:click={() => {
|
||||||
|
if (!query) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query = null
|
||||||
|
userOnboardResponse = null
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={query ? "Close" : "Search"} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
{#if promptInvite && !userOnboardResponse}
|
||||||
|
<Layout gap="S" paddingX="XL">
|
||||||
|
<div class="invite-header">
|
||||||
|
<Heading size="XS">No user found</Heading>
|
||||||
|
<div class="invite-directions">
|
||||||
|
Add a valid email to invite a new user
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="invite-form">
|
||||||
|
<span>{query || ""}</span>
|
||||||
|
<ActionButton
|
||||||
|
icon="UserAdd"
|
||||||
|
disabled={!queryIsEmail || inviting}
|
||||||
|
on:click={onInviteUser}
|
||||||
|
>
|
||||||
|
Add user
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !promptInvite}
|
||||||
|
<Layout gap="L" noPadding>
|
||||||
|
{#if filteredInvites?.length}
|
||||||
|
<Layout noPadding gap="XS">
|
||||||
|
<div class="auth-entity-header">
|
||||||
|
<div class="auth-entity-title">Pending invites</div>
|
||||||
|
<div class="auth-entity-access-title">Access</div>
|
||||||
|
</div>
|
||||||
|
{#each filteredInvites as invite}
|
||||||
|
<div class="auth-entity">
|
||||||
|
<div class="details">
|
||||||
|
<div class="user-email" title={invite.email}>
|
||||||
|
{invite.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="auth-entity-access">
|
||||||
|
<RoleSelect
|
||||||
|
placeholder={false}
|
||||||
|
value={invite.info.apps?.[prodAppId]}
|
||||||
|
allowRemove={invite.info.apps?.[prodAppId]}
|
||||||
|
allowPublic={false}
|
||||||
|
quiet={true}
|
||||||
|
on:change={e => {
|
||||||
|
onUpdateUserInvite(invite, e.detail)
|
||||||
|
}}
|
||||||
|
on:remove={() => {
|
||||||
|
onUninviteAppUser(invite)
|
||||||
|
}}
|
||||||
|
autoWidth
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</Layout>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $licensing.groupsEnabled && filteredGroups?.length}
|
||||||
|
<Layout noPadding gap="XS">
|
||||||
|
<div class="auth-entity-header">
|
||||||
|
<div class="auth-entity-title">Groups</div>
|
||||||
|
<div class="auth-entity-access-title">Access</div>
|
||||||
|
</div>
|
||||||
|
{#each filteredGroups as group}
|
||||||
|
<div
|
||||||
|
class="auth-entity group"
|
||||||
|
on:click={() => {
|
||||||
|
if (selectedGroup != group._id) {
|
||||||
|
selectedGroup = group._id
|
||||||
|
} else {
|
||||||
|
selectedGroup = null
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
on:keydown={() => {}}
|
||||||
|
>
|
||||||
|
<div class="details">
|
||||||
|
<GroupIcon {group} size="S" />
|
||||||
|
<div>
|
||||||
|
{group.name}
|
||||||
|
</div>
|
||||||
|
<div class="auth-entity-meta">
|
||||||
|
{`${group.users?.length} user${
|
||||||
|
group.users?.length != 1 ? "s" : ""
|
||||||
|
}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="auth-entity-access">
|
||||||
|
<RoleSelect
|
||||||
|
placeholder={false}
|
||||||
|
value={group.role}
|
||||||
|
allowRemove={group.role}
|
||||||
|
allowPublic={false}
|
||||||
|
quiet={true}
|
||||||
|
on:change={e => {
|
||||||
|
onUpdateGroup(group, e.detail)
|
||||||
|
}}
|
||||||
|
on:remove={() => {
|
||||||
|
onUpdateGroup(group)
|
||||||
|
}}
|
||||||
|
autoWidth
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</Layout>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if filteredUsers?.length}
|
||||||
|
<div class="auth-entity-section">
|
||||||
|
<div class="auth-entity-header ">
|
||||||
|
<div class="auth-entity-title">Users</div>
|
||||||
|
<div class="auth-entity-access-title">Access</div>
|
||||||
|
</div>
|
||||||
|
{#each allUsers as user}
|
||||||
|
<div class="auth-entity">
|
||||||
|
<div class="details">
|
||||||
|
<div class="user-email" title={user.email}>
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
<div class="auth-entity-meta">
|
||||||
|
{userTitle(user)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="auth-entity-access" class:muted={user.group}>
|
||||||
|
<RoleSelect
|
||||||
|
footer={getRoleFooter(user)}
|
||||||
|
placeholder={false}
|
||||||
|
value={user.role}
|
||||||
|
allowRemove={user.role && !user.group}
|
||||||
|
allowPublic={false}
|
||||||
|
quiet={true}
|
||||||
|
on:change={e => {
|
||||||
|
onUpdateUser(user, e.detail)
|
||||||
|
}}
|
||||||
|
on:remove={() => {
|
||||||
|
onUpdateUser(user)
|
||||||
|
}}
|
||||||
|
autoWidth
|
||||||
|
align="right"
|
||||||
|
allowedRoles={user.isBuilderOrAdmin
|
||||||
|
? [Constants.Roles.ADMIN]
|
||||||
|
: null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Layout>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if userOnboardResponse?.created}
|
||||||
|
<Layout gap="S" paddingX="XL">
|
||||||
|
<div class="invite-header">
|
||||||
|
<Heading size="XS">User added!</Heading>
|
||||||
|
<div class="invite-directions">
|
||||||
|
Email invites are not available without SMTP configuration. Here is
|
||||||
|
the password that has been generated for this user.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CopyInput
|
||||||
|
value={userOnboardResponse.successful[0]?.password}
|
||||||
|
label="Password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.search :global(input) {
|
||||||
|
padding-left: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-icon.searching {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-entity-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-s);
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-entity-meta {
|
||||||
|
color: var(--spectrum-global-color-gray-600);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-entity-access {
|
||||||
|
margin-right: var(--spacing-m);
|
||||||
|
}
|
||||||
|
.auth-entity-access.muted :global(.spectrum-Picker-label),
|
||||||
|
.auth-entity-access.muted :global(.spectrum-StatusLight) {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-entity-header {
|
||||||
|
color: var(--spectrum-global-color-gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-entity,
|
||||||
|
.auth-entity-header {
|
||||||
|
padding: 0px var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-entity,
|
||||||
|
.auth-entity-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 110px;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-entity .details {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
color: var(--spectrum-global-color-gray-900);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-entity .user-email {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--spectrum-global-color-gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
#builder-side-panel-container {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: calc(100vw - 40px);
|
||||||
|
background: var(--background);
|
||||||
|
border-left: var(--border-light);
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
transition: transform 130ms ease-out;
|
||||||
|
position: absolute;
|
||||||
|
width: 400px;
|
||||||
|
right: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-side-panel-header,
|
||||||
|
#builder-side-panel-container .search {
|
||||||
|
padding: 0px var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
#builder-side-panel-container .auth-entity .details {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
#builder-side-panel-container .search {
|
||||||
|
padding-top: var(--spacing-m);
|
||||||
|
padding-bottom: var(--spacing-m);
|
||||||
|
border-top: var(--border-light);
|
||||||
|
border-bottom: var(--border-light);
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
border-right: 2px solid transparent;
|
||||||
|
margin-right: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#builder-side-panel-container .search :global(input) {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0px;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#builder-side-panel-container .search :global(input) {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#builder-side-panel-container .search.focused {
|
||||||
|
border-color: var(
|
||||||
|
--spectrum-textfield-m-border-color-down,
|
||||||
|
var(--spectrum-alias-border-color-mouse-focus)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#builder-side-panel-container .search :global(input::placeholder) {
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
#builder-side-panel-container.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
box-shadow: 0 0 40px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-side-panel-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-header {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-s);
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
padding: var(--spacing-xl) 0;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -10,28 +10,26 @@
|
||||||
Tabs,
|
Tabs,
|
||||||
Tab,
|
Tab,
|
||||||
Heading,
|
Heading,
|
||||||
|
Modal,
|
||||||
notifications,
|
notifications,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
|
|
||||||
import RevertModal from "components/deploy/RevertModal.svelte"
|
import AppActions from "components/deploy/AppActions.svelte"
|
||||||
import VersionModal from "components/deploy/VersionModal.svelte"
|
|
||||||
import DeployNavigation from "components/deploy/DeployNavigation.svelte"
|
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
import { isActive, goto, layout, redirect } from "@roxi/routify"
|
import { isActive, goto, layout, redirect } from "@roxi/routify"
|
||||||
import { capitalise } from "helpers"
|
import { capitalise } from "helpers"
|
||||||
import { onMount, onDestroy } from "svelte"
|
import { onMount, onDestroy } from "svelte"
|
||||||
|
import CommandPalette from "components/commandPalette/CommandPalette.svelte"
|
||||||
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
|
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
|
||||||
import TourPopover from "components/portal/onboarding/TourPopover.svelte"
|
import TourPopover from "components/portal/onboarding/TourPopover.svelte"
|
||||||
|
import BuilderSidePanel from "./_components/BuilderSidePanel.svelte"
|
||||||
import { TOUR_KEYS, TOURS } from "components/portal/onboarding/tours.js"
|
import { TOUR_KEYS, TOURS } from "components/portal/onboarding/tours.js"
|
||||||
|
|
||||||
export let application
|
export let application
|
||||||
|
|
||||||
// Get Package and set store
|
|
||||||
let promise = getPackage()
|
let promise = getPackage()
|
||||||
// let betaAccess = false
|
|
||||||
|
|
||||||
// Sync once when you load the app
|
|
||||||
let hasSynced = false
|
let hasSynced = false
|
||||||
|
let commandPaletteModal
|
||||||
|
|
||||||
$: selected = capitalise(
|
$: selected = capitalise(
|
||||||
$layout.children.find(layout => $isActive(layout.path))?.title ?? "data"
|
$layout.children.find(layout => $isActive(layout.path))?.title ?? "data"
|
||||||
|
@ -51,7 +49,6 @@
|
||||||
$redirect("../../")
|
$redirect("../../")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handles navigation between frontend, backend, automation.
|
// Handles navigation between frontend, backend, automation.
|
||||||
// This remembers your last place on each of the sections
|
// This remembers your last place on each of the sections
|
||||||
// e.g. if one of your screens is selected on front end, then
|
// e.g. if one of your screens is selected on front end, then
|
||||||
|
@ -68,11 +65,18 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Event handler for the command palette
|
||||||
|
const handleKeyDown = e => {
|
||||||
|
if (e.key === "k" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
commandPaletteModal.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const initTour = async () => {
|
const initTour = async () => {
|
||||||
if (
|
// Check if onboarding is enabled.
|
||||||
!$auth.user?.onboardedAt &&
|
if (isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)) {
|
||||||
isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)
|
if (!$auth.user?.onboardedAt) {
|
||||||
) {
|
|
||||||
// Determine the correct step
|
// Determine the correct step
|
||||||
const activeNav = $layout.children.find(c => $isActive(c.path))
|
const activeNav = $layout.children.find(c => $isActive(c.path))
|
||||||
const onboardingTour = TOURS[TOUR_KEYS.TOUR_BUILDER_ONBOARDING]
|
const onboardingTour = TOURS[TOUR_KEYS.TOUR_BUILDER_ONBOARDING]
|
||||||
|
@ -85,6 +89,17 @@
|
||||||
tourKey: TOUR_KEYS.TOUR_BUILDER_ONBOARDING,
|
tourKey: TOUR_KEYS.TOUR_BUILDER_ONBOARDING,
|
||||||
tourStepKey: targetStep?.id,
|
tourStepKey: targetStep?.id,
|
||||||
}))
|
}))
|
||||||
|
} else {
|
||||||
|
// Feature tour date
|
||||||
|
const release_date = new Date("2023-03-01T00:00:00.000Z")
|
||||||
|
const onboarded = new Date($auth.user?.onboardedAt)
|
||||||
|
if (onboarded < release_date) {
|
||||||
|
await store.update(state => ({
|
||||||
|
...state,
|
||||||
|
tourKey: TOUR_KEYS.FEATURE_ONBOARDING,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,11 +127,12 @@
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#await promise}
|
|
||||||
<!-- This should probably be some kind of loading state? -->
|
|
||||||
<div class="loading" />
|
|
||||||
{:then _}
|
|
||||||
<TourPopover />
|
<TourPopover />
|
||||||
|
|
||||||
|
{#if $store.builderSidePanel}
|
||||||
|
<BuilderSidePanel />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="root">
|
<div class="root">
|
||||||
<div class="top-nav">
|
<div class="top-nav">
|
||||||
<div class="topleftnav">
|
<div class="topleftnav">
|
||||||
|
@ -133,8 +149,7 @@
|
||||||
Overview
|
Overview
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
on:click={() =>
|
on:click={() => $goto(`../../portal/overview/${application}/access`)}
|
||||||
$goto(`../../portal/overview/${application}/access`)}
|
|
||||||
>
|
>
|
||||||
Access
|
Access
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
@ -145,8 +160,7 @@
|
||||||
Automation history
|
Automation history
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
on:click={() =>
|
on:click={() => $goto(`../../portal/overview/${application}/backups`)}
|
||||||
$goto(`../../portal/overview/${application}/backups`)}
|
|
||||||
>
|
>
|
||||||
Backups
|
Backups
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
@ -158,13 +172,12 @@
|
||||||
Name and URL
|
Name and URL
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
on:click={() =>
|
on:click={() => $goto(`../../portal/overview/${application}/version`)}
|
||||||
$goto(`../../portal/overview/${application}/version`)}
|
|
||||||
>
|
>
|
||||||
Version
|
Version
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</ActionMenu>
|
</ActionMenu>
|
||||||
<Heading size="XS">{$store.name || "App"}</Heading>
|
<Heading size="XS">{$store.name}</Heading>
|
||||||
</div>
|
</div>
|
||||||
<div class="topcenternav">
|
<div class="topcenternav">
|
||||||
<Tabs {selected} size="M">
|
<Tabs {selected} size="M">
|
||||||
|
@ -182,18 +195,23 @@
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
<div class="toprightnav">
|
<div class="toprightnav">
|
||||||
<div class="version">
|
<AppActions {application} />
|
||||||
<VersionModal />
|
|
||||||
</div>
|
|
||||||
<RevertModal />
|
|
||||||
<DeployNavigation {application} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{#await promise}
|
||||||
|
<!-- This should probably be some kind of loading state? -->
|
||||||
|
<div class="loading" />
|
||||||
|
{:then _}
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
|
||||||
{:catch error}
|
{:catch error}
|
||||||
<p>Something went wrong: {error.message}</p>
|
<p>Something went wrong: {error.message}</p>
|
||||||
{/await}
|
{/await}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={handleKeyDown} />
|
||||||
|
<Modal bind:this={commandPaletteModal}>
|
||||||
|
<CommandPalette />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.loading {
|
.loading {
|
||||||
|
@ -251,10 +269,6 @@
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-xl);
|
gap: var(--spacing-l);
|
||||||
}
|
|
||||||
|
|
||||||
.version {
|
|
||||||
margin-right: var(--spacing-s);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -10,6 +10,8 @@
|
||||||
getBindableProperties,
|
getBindableProperties,
|
||||||
getComponentBindableProperties,
|
getComponentBindableProperties,
|
||||||
} from "builderStore/dataBinding"
|
} from "builderStore/dataBinding"
|
||||||
|
import { ActionButton } from "@budibase/bbui"
|
||||||
|
import { capitalise } from "helpers"
|
||||||
|
|
||||||
$: componentInstance = $selectedComponent
|
$: componentInstance = $selectedComponent
|
||||||
$: componentDefinition = store.actions.components.getDefinition(
|
$: componentDefinition = store.actions.components.getDefinition(
|
||||||
|
@ -25,11 +27,34 @@
|
||||||
)
|
)
|
||||||
$: isScreen = $selectedComponent?._id === $selectedScreen?.props._id
|
$: isScreen = $selectedComponent?._id === $selectedScreen?.props._id
|
||||||
$: title = isScreen ? "Screen" : $selectedComponent?._instanceName
|
$: title = isScreen ? "Screen" : $selectedComponent?._instanceName
|
||||||
|
|
||||||
|
let section = "settings"
|
||||||
|
const tabs = ["settings", "styles", "conditions"]
|
||||||
|
|
||||||
|
$: id = $selectedComponent?._id
|
||||||
|
$: id, (section = tabs[0])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $selectedComponent}
|
{#if $selectedComponent}
|
||||||
{#key $selectedComponent._id}
|
{#key $selectedComponent._id}
|
||||||
<Panel {title} icon={componentDefinition?.icon} borderLeft>
|
<Panel {title} icon={componentDefinition?.icon} borderLeft>
|
||||||
|
<span slot="panel-header-content">
|
||||||
|
<div class="settings-tabs">
|
||||||
|
{#each tabs as tab}
|
||||||
|
<ActionButton
|
||||||
|
size="M"
|
||||||
|
quiet
|
||||||
|
selected={section === tab}
|
||||||
|
on:click={() => {
|
||||||
|
section = tab
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{capitalise(tab)}
|
||||||
|
</ActionButton>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
{#if section == "settings"}
|
||||||
{#if componentDefinition?.info}
|
{#if componentDefinition?.info}
|
||||||
<ComponentInfoSection {componentDefinition} />
|
<ComponentInfoSection {componentDefinition} />
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -40,17 +65,31 @@
|
||||||
{componentBindings}
|
{componentBindings}
|
||||||
{isScreen}
|
{isScreen}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if section == "styles"}
|
||||||
<DesignSection {componentInstance} {componentDefinition} {bindings} />
|
<DesignSection {componentInstance} {componentDefinition} {bindings} />
|
||||||
<CustomStylesSection
|
<CustomStylesSection
|
||||||
{componentInstance}
|
{componentInstance}
|
||||||
{componentDefinition}
|
{componentDefinition}
|
||||||
{bindings}
|
{bindings}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if section == "conditions"}
|
||||||
<ConditionalUISection
|
<ConditionalUISection
|
||||||
{componentInstance}
|
{componentInstance}
|
||||||
{componentDefinition}
|
{componentDefinition}
|
||||||
{bindings}
|
{bindings}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
</Panel>
|
</Panel>
|
||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.settings-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-s);
|
||||||
|
padding: 0 var(--spacing-l);
|
||||||
|
padding-bottom: var(--spacing-l);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
let formData = {}
|
let formData = {}
|
||||||
let onboarding = false
|
let onboarding = false
|
||||||
let errors = {}
|
let errors = {}
|
||||||
|
let loaded = false
|
||||||
|
|
||||||
$: company = $organisation.company || "Budibase"
|
$: company = $organisation.company || "Budibase"
|
||||||
|
|
||||||
|
@ -39,6 +40,11 @@
|
||||||
if (invite?.email) {
|
if (invite?.email) {
|
||||||
formData.email = invite?.email
|
formData.email = invite?.email
|
||||||
}
|
}
|
||||||
|
if ($organisation.isSSOEnforced) {
|
||||||
|
// auto accept invite and redirect to login
|
||||||
|
await users.acceptInvite(inviteCode)
|
||||||
|
$goto("../auth")
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error(error.message)
|
notifications.error(error.message)
|
||||||
}
|
}
|
||||||
|
@ -61,12 +67,14 @@
|
||||||
try {
|
try {
|
||||||
await organisation.init()
|
await organisation.init()
|
||||||
await getInvite()
|
await getInvite()
|
||||||
|
loaded = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error("Error getting invite config")
|
notifications.error("Error getting invite config")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if loaded}
|
||||||
<TestimonialPage>
|
<TestimonialPage>
|
||||||
<Layout gap="M" noPadding>
|
<Layout gap="M" noPadding>
|
||||||
<img alt="logo" src={$organisation.logoUrl || Logo} />
|
<img alt="logo" src={$organisation.logoUrl || Logo} />
|
||||||
|
@ -115,6 +123,7 @@
|
||||||
}}
|
}}
|
||||||
disabled={onboarding}
|
disabled={onboarding}
|
||||||
/>
|
/>
|
||||||
|
{#if !$organisation.isSSOEnforced}
|
||||||
<FancyInput
|
<FancyInput
|
||||||
label="Password"
|
label="Password"
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
|
@ -171,6 +180,7 @@
|
||||||
error={errors.confirmationPassword}
|
error={errors.confirmationPassword}
|
||||||
disabled={onboarding}
|
disabled={onboarding}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
</FancyForm>
|
</FancyForm>
|
||||||
</Layout>
|
</Layout>
|
||||||
<div>
|
<div>
|
||||||
|
@ -185,6 +195,7 @@
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
</TestimonialPage>
|
</TestimonialPage>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
img {
|
img {
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
<script>
|
<script>
|
||||||
import { Button } from "@budibase/bbui"
|
import { Button } from "@budibase/bbui"
|
||||||
import { goto } from "@roxi/routify"
|
import { goto } from "@roxi/routify"
|
||||||
import { auth, admin } from "stores/portal"
|
import { auth, admin, licensing } from "stores/portal"
|
||||||
import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags"
|
import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags"
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING)}
|
{#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING) && !$licensing.isEnterprisePlan}
|
||||||
{#if $admin.cloud && $auth?.user?.accountPortalAccess}
|
{#if $admin.cloud && $auth?.user?.accountPortalAccess}
|
||||||
<Button
|
<Button
|
||||||
cta
|
cta
|
||||||
|
|
|
@ -12,19 +12,21 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if row?.user?.email}
|
||||||
<div
|
<div
|
||||||
class="container"
|
class="container"
|
||||||
on:mouseover={() => (showTooltip = true)}
|
on:mouseover={() => (showTooltip = true)}
|
||||||
on:focus={() => (showTooltip = true)}
|
on:focus={() => (showTooltip = true)}
|
||||||
on:mouseleave={() => (showTooltip = false)}
|
on:mouseleave={() => (showTooltip = false)}
|
||||||
>
|
>
|
||||||
<Avatar size="M" initials={getInitials(row?.user)} />
|
<Avatar size="M" initials={getInitials(row.user)} />
|
||||||
</div>
|
</div>
|
||||||
{#if showTooltip}
|
{#if showTooltip}
|
||||||
<div class="tooltip">
|
<div class="tooltip">
|
||||||
<Tooltip textWrapping text={row?.user.email} direction="bottom" />
|
<Tooltip textWrapping text={row.user.email} direction="bottom" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.container {
|
.container {
|
||||||
|
|
|
@ -257,6 +257,7 @@
|
||||||
<div class="select">
|
<div class="select">
|
||||||
<Multiselect
|
<Multiselect
|
||||||
bind:fetchTerm={userSearchTerm}
|
bind:fetchTerm={userSearchTerm}
|
||||||
|
useFetch
|
||||||
placeholder="All users"
|
placeholder="All users"
|
||||||
label="Users"
|
label="Users"
|
||||||
autocomplete
|
autocomplete
|
||||||
|
|
|
@ -131,24 +131,25 @@
|
||||||
isEqual(providers.google?.config, originalGoogleDoc?.config)
|
isEqual(providers.google?.config, originalGoogleDoc?.config)
|
||||||
? (googleSaveButtonDisabled = true)
|
? (googleSaveButtonDisabled = true)
|
||||||
: (googleSaveButtonDisabled = false)
|
: (googleSaveButtonDisabled = false)
|
||||||
|
|
||||||
|
// delete the callback url which is never saved to the oidc
|
||||||
|
// config doc, to ensure an accurate comparison
|
||||||
|
delete providers.oidc?.config.configs[0].callbackURL
|
||||||
|
|
||||||
isEqual(providers.oidc?.config, originalOidcDoc?.config)
|
isEqual(providers.oidc?.config, originalOidcDoc?.config)
|
||||||
? (oidcSaveButtonDisabled = true)
|
? (oidcSaveButtonDisabled = true)
|
||||||
: (oidcSaveButtonDisabled = false)
|
: (oidcSaveButtonDisabled = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a flag so that it will only try to save completed forms
|
$: googleComplete = !!(
|
||||||
$: partialGoogle =
|
|
||||||
providers.google?.config?.clientID || providers.google?.config?.clientSecret
|
|
||||||
$: partialOidc =
|
|
||||||
providers.oidc?.config?.configs[0].configUrl ||
|
|
||||||
providers.oidc?.config?.configs[0].clientID ||
|
|
||||||
providers.oidc?.config?.configs[0].clientSecret
|
|
||||||
$: googleComplete =
|
|
||||||
providers.google?.config?.clientID && providers.google?.config?.clientSecret
|
providers.google?.config?.clientID && providers.google?.config?.clientSecret
|
||||||
$: oidcComplete =
|
)
|
||||||
|
|
||||||
|
$: oidcComplete = !!(
|
||||||
providers.oidc?.config?.configs[0].configUrl &&
|
providers.oidc?.config?.configs[0].configUrl &&
|
||||||
providers.oidc?.config?.configs[0].clientID &&
|
providers.oidc?.config?.configs[0].clientID &&
|
||||||
providers.oidc?.config?.configs[0].clientSecret
|
providers.oidc?.config?.configs[0].clientSecret
|
||||||
|
)
|
||||||
|
|
||||||
const onFileSelected = e => {
|
const onFileSelected = e => {
|
||||||
let fileName = e.target.files[0].name
|
let fileName = e.target.files[0].name
|
||||||
|
@ -159,74 +160,88 @@
|
||||||
|
|
||||||
async function toggleIsSSOEnforced() {
|
async function toggleIsSSOEnforced() {
|
||||||
const value = $organisation.isSSOEnforced
|
const value = $organisation.isSSOEnforced
|
||||||
|
try {
|
||||||
await organisation.save({ isSSOEnforced: !value })
|
await organisation.save({ isSSOEnforced: !value })
|
||||||
|
} catch (e) {
|
||||||
|
notifications.error(e.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(docs) {
|
async function saveConfig(config) {
|
||||||
let calls = []
|
// Delete unsupported fields
|
||||||
// Only if the user has provided an image, upload it
|
delete config.createdAt
|
||||||
|
delete config.updatedAt
|
||||||
|
return API.saveConfig(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveOIDCLogo() {
|
||||||
if (image) {
|
if (image) {
|
||||||
let data = new FormData()
|
let data = new FormData()
|
||||||
data.append("file", image)
|
data.append("file", image)
|
||||||
calls.push(
|
await API.uploadOIDCLogo({
|
||||||
API.uploadOIDCLogo({
|
|
||||||
name: image.name,
|
name: image.name,
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
docs.forEach(element => {
|
}
|
||||||
// Delete unsupported fields
|
|
||||||
delete element.createdAt
|
|
||||||
delete element.updatedAt
|
|
||||||
|
|
||||||
const { activated } = element.config
|
async function saveOIDC() {
|
||||||
|
if (!oidcComplete) {
|
||||||
|
notifications.error(
|
||||||
|
`Please fill in all required ${ConfigTypes.OIDC} fields`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const oidc = providers.oidc
|
||||||
|
|
||||||
if (element.type === ConfigTypes.OIDC) {
|
|
||||||
// Add a UUID here so each config is distinguishable when it arrives at the login page
|
// Add a UUID here so each config is distinguishable when it arrives at the login page
|
||||||
for (let config of element.config.configs) {
|
for (let config of oidc.config.configs) {
|
||||||
if (!config.uuid) {
|
if (!config.uuid) {
|
||||||
config.uuid = Helpers.uuid()
|
config.uuid = Helpers.uuid()
|
||||||
}
|
}
|
||||||
// Callback urls shouldn't be included
|
// Callback urls shouldn't be included
|
||||||
delete config.callbackURL
|
delete config.callbackURL
|
||||||
}
|
}
|
||||||
if ((partialOidc || activated) && !oidcComplete) {
|
|
||||||
notifications.error(
|
try {
|
||||||
`Please fill in all required ${ConfigTypes.OIDC} fields`
|
const res = await saveConfig(oidc)
|
||||||
)
|
providers[res.type]._rev = res._rev
|
||||||
} else if (oidcComplete || !activated) {
|
providers[res.type]._id = res._id
|
||||||
calls.push(API.saveConfig(element))
|
await saveOIDCLogo()
|
||||||
|
notifications.success(`Settings saved`)
|
||||||
|
} catch (e) {
|
||||||
|
notifications.error(e.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Turn the save button grey when clicked
|
// Turn the save button grey when clicked
|
||||||
oidcSaveButtonDisabled = true
|
oidcSaveButtonDisabled = true
|
||||||
originalOidcDoc = cloneDeep(providers.oidc)
|
originalOidcDoc = cloneDeep(providers.oidc)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (element.type === ConfigTypes.Google) {
|
async function saveGoogle() {
|
||||||
if ((partialGoogle || activated) && !googleComplete) {
|
if (!googleComplete) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
`Please fill in all required ${ConfigTypes.Google} fields`
|
`Please fill in all required ${ConfigTypes.Google} fields`
|
||||||
)
|
)
|
||||||
} else if (googleComplete || !activated) {
|
return
|
||||||
calls.push(API.saveConfig(element))
|
|
||||||
googleSaveButtonDisabled = true
|
|
||||||
originalGoogleDoc = cloneDeep(providers.google)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
const google = providers.google
|
||||||
if (calls.length) {
|
|
||||||
Promise.all(calls)
|
try {
|
||||||
.then(data => {
|
const res = await saveConfig(google)
|
||||||
data.forEach(res => {
|
|
||||||
providers[res.type]._rev = res._rev
|
providers[res.type]._rev = res._rev
|
||||||
providers[res.type]._id = res._id
|
providers[res.type]._id = res._id
|
||||||
})
|
|
||||||
notifications.success(`Settings saved`)
|
notifications.success(`Settings saved`)
|
||||||
})
|
} catch (e) {
|
||||||
.catch(() => {
|
notifications.error(e.message)
|
||||||
notifications.error("Failed to update auth settings")
|
return
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
googleSaveButtonDisabled = true
|
||||||
|
originalGoogleDoc = cloneDeep(providers.google)
|
||||||
}
|
}
|
||||||
|
|
||||||
let defaultScopes = ["profile", "email", "offline_access"]
|
let defaultScopes = ["profile", "email", "offline_access"]
|
||||||
|
@ -266,7 +281,7 @@
|
||||||
if (!googleDoc?._id) {
|
if (!googleDoc?._id) {
|
||||||
providers.google = {
|
providers.google = {
|
||||||
type: ConfigTypes.Google,
|
type: ConfigTypes.Google,
|
||||||
config: { activated: true },
|
config: { activated: false },
|
||||||
}
|
}
|
||||||
originalGoogleDoc = cloneDeep(googleDoc)
|
originalGoogleDoc = cloneDeep(googleDoc)
|
||||||
} else {
|
} else {
|
||||||
|
@ -290,7 +305,10 @@
|
||||||
}
|
}
|
||||||
if (oidcLogos?.config) {
|
if (oidcLogos?.config) {
|
||||||
const logoKeys = Object.keys(oidcLogos.config)
|
const logoKeys = Object.keys(oidcLogos.config)
|
||||||
logoKeys.map(logoKey => {
|
logoKeys
|
||||||
|
// don't include the etag entry in the logo config
|
||||||
|
.filter(key => !key.toLowerCase().includes("etag"))
|
||||||
|
.map(logoKey => {
|
||||||
const logoUrl = oidcLogos.config[logoKey]
|
const logoUrl = oidcLogos.config[logoKey]
|
||||||
iconDropdownOptions.unshift({
|
iconDropdownOptions.unshift({
|
||||||
label: logoKey,
|
label: logoKey,
|
||||||
|
@ -310,7 +328,7 @@
|
||||||
if (!oidcDoc?._id) {
|
if (!oidcDoc?._id) {
|
||||||
providers.oidc = {
|
providers.oidc = {
|
||||||
type: ConfigTypes.OIDC,
|
type: ConfigTypes.OIDC,
|
||||||
config: { configs: [{ activated: true, scopes: defaultScopes }] },
|
config: { configs: [{ activated: false, scopes: defaultScopes }] },
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
originalOidcDoc = cloneDeep(oidcDoc)
|
originalOidcDoc = cloneDeep(oidcDoc)
|
||||||
|
@ -350,7 +368,7 @@
|
||||||
</div>
|
</div>
|
||||||
{#if !$licensing.enforceableSSO}
|
{#if !$licensing.enforceableSSO}
|
||||||
<Tags>
|
<Tags>
|
||||||
<Tag icon="LockClosed">Business plan</Tag>
|
<Tag icon="LockClosed">Enterprise plan</Tag>
|
||||||
</Tags>
|
</Tags>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
@ -413,7 +431,7 @@
|
||||||
<Button
|
<Button
|
||||||
disabled={googleSaveButtonDisabled}
|
disabled={googleSaveButtonDisabled}
|
||||||
cta
|
cta
|
||||||
on:click={() => save([providers.google])}
|
on:click={() => saveGoogle()}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -469,6 +487,7 @@
|
||||||
<Select
|
<Select
|
||||||
label=""
|
label=""
|
||||||
bind:value={providers.oidc.config.configs[0].logo}
|
bind:value={providers.oidc.config.configs[0].logo}
|
||||||
|
useOptionIconImage
|
||||||
options={iconDropdownOptions}
|
options={iconDropdownOptions}
|
||||||
on:change={e => e.detail === "Upload" && fileinput.click()}
|
on:change={e => e.detail === "Upload" && fileinput.click()}
|
||||||
/>
|
/>
|
||||||
|
@ -575,11 +594,7 @@
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button disabled={oidcSaveButtonDisabled} cta on:click={() => saveOIDC()}>
|
||||||
disabled={oidcSaveButtonDisabled}
|
|
||||||
cta
|
|
||||||
on:click={() => save([providers.oidc])}
|
|
||||||
>
|
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
<script>
|
<script>
|
||||||
import { ModalContent, Body, Layout, Icon } from "@budibase/bbui"
|
import { ModalContent, Body, Layout, Icon } from "@budibase/bbui"
|
||||||
|
import { OnboardingType } from "../../../../../../constants"
|
||||||
|
|
||||||
export let chooseCreationType
|
export let chooseCreationType
|
||||||
let emailOnboardingKey = "emailOnboarding"
|
|
||||||
let basicOnboaridngKey = "basicOnboarding"
|
|
||||||
|
|
||||||
let selectedOnboardingType
|
let selectedOnboardingType
|
||||||
</script>
|
</script>
|
||||||
|
@ -20,9 +19,9 @@
|
||||||
<Layout noPadding gap="S">
|
<Layout noPadding gap="S">
|
||||||
<div
|
<div
|
||||||
class="onboarding-type item"
|
class="onboarding-type item"
|
||||||
class:selected={selectedOnboardingType == emailOnboardingKey}
|
class:selected={selectedOnboardingType == OnboardingType.EMAIL}
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
selectedOnboardingType = emailOnboardingKey
|
selectedOnboardingType = OnboardingType.EMAIL
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="content onboarding-type-wrap">
|
<div class="content onboarding-type-wrap">
|
||||||
|
@ -32,7 +31,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="color: var(--spectrum-global-color-green-600); float: right">
|
<div style="color: var(--spectrum-global-color-green-600); float: right">
|
||||||
{#if selectedOnboardingType == emailOnboardingKey}
|
{#if selectedOnboardingType == OnboardingType.EMAIL}
|
||||||
<div class="checkmark-spacing">
|
<div class="checkmark-spacing">
|
||||||
<Icon size="S" name="CheckmarkCircle" />
|
<Icon size="S" name="CheckmarkCircle" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -42,9 +41,9 @@
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="onboarding-type item"
|
class="onboarding-type item"
|
||||||
class:selected={selectedOnboardingType == basicOnboaridngKey}
|
class:selected={selectedOnboardingType == OnboardingType.PASSWORD}
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
selectedOnboardingType = basicOnboaridngKey
|
selectedOnboardingType = OnboardingType.PASSWORD
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="content onboarding-type-wrap">
|
<div class="content onboarding-type-wrap">
|
||||||
|
@ -54,7 +53,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="color: var(--spectrum-global-color-green-600); float: right">
|
<div style="color: var(--spectrum-global-color-green-600); float: right">
|
||||||
{#if selectedOnboardingType == basicOnboaridngKey}
|
{#if selectedOnboardingType == OnboardingType.PASSWORD}
|
||||||
<div class="checkmark-spacing">
|
<div class="checkmark-spacing">
|
||||||
<Icon size="S" name="CheckmarkCircle" />
|
<Icon size="S" name="CheckmarkCircle" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
Divider,
|
Divider,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import AddUserModal from "./_components/AddUserModal.svelte"
|
import AddUserModal from "./_components/AddUserModal.svelte"
|
||||||
import { users, groups, auth, licensing } from "stores/portal"
|
import { users, groups, auth, licensing, organisation } from "stores/portal"
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
import DeleteRowsButton from "components/backend/DataTable/buttons/DeleteRowsButton.svelte"
|
import DeleteRowsButton from "components/backend/DataTable/buttons/DeleteRowsButton.svelte"
|
||||||
import GroupsTableRenderer from "./_components/GroupsTableRenderer.svelte"
|
import GroupsTableRenderer from "./_components/GroupsTableRenderer.svelte"
|
||||||
|
@ -27,6 +27,7 @@
|
||||||
import { get } from "svelte/store"
|
import { get } from "svelte/store"
|
||||||
import { Constants, Utils, fetchData } from "@budibase/frontend-core"
|
import { Constants, Utils, fetchData } from "@budibase/frontend-core"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
|
import { OnboardingType } from "../../../../../constants"
|
||||||
|
|
||||||
const fetch = fetchData({
|
const fetch = fetchData({
|
||||||
API,
|
API,
|
||||||
|
@ -105,11 +106,19 @@
|
||||||
const debouncedUpdateFetch = Utils.debounce(updateFetch, 250)
|
const debouncedUpdateFetch = Utils.debounce(updateFetch, 250)
|
||||||
|
|
||||||
const showOnboardingTypeModal = async addUsersData => {
|
const showOnboardingTypeModal = async addUsersData => {
|
||||||
|
// no-op if users already exist
|
||||||
userData = await removingDuplicities(addUsersData)
|
userData = await removingDuplicities(addUsersData)
|
||||||
if (!userData?.users?.length) return
|
if (!userData?.users?.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($organisation.isSSOEnforced) {
|
||||||
|
// bypass the onboarding type selection of sso is enforced
|
||||||
|
await chooseCreationType(OnboardingType.EMAIL)
|
||||||
|
} else {
|
||||||
onboardingTypeModal.show()
|
onboardingTypeModal.show()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createUserFlow() {
|
async function createUserFlow() {
|
||||||
const payload = userData?.users?.map(user => ({
|
const payload = userData?.users?.map(user => ({
|
||||||
|
@ -181,7 +190,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chooseCreationType(onboardingType) {
|
async function chooseCreationType(onboardingType) {
|
||||||
if (onboardingType === "emailOnboarding") {
|
if (onboardingType === OnboardingType.EMAIL) {
|
||||||
await createUserFlow()
|
await createUserFlow()
|
||||||
} else {
|
} else {
|
||||||
await createUsers()
|
await createUsers()
|
||||||
|
|
|
@ -154,9 +154,14 @@ export function createAuthStore() {
|
||||||
await setInitInfo({})
|
await setInitInfo({})
|
||||||
},
|
},
|
||||||
updateSelf: async fields => {
|
updateSelf: async fields => {
|
||||||
const newUser = { ...get(auth).user, ...fields }
|
await API.updateSelf({ ...fields })
|
||||||
await API.updateSelf(newUser)
|
// Refetch to enrich after update.
|
||||||
setUser(newUser)
|
try {
|
||||||
|
const user = await API.fetchBuilderSelf()
|
||||||
|
setUser(user)
|
||||||
|
} catch (error) {
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
forgotPassword: async email => {
|
forgotPassword: async email => {
|
||||||
const tenantId = get(store).tenantId
|
const tenantId = get(store).tenantId
|
||||||
|
|
|
@ -12,6 +12,7 @@ export const createLicensingStore = () => {
|
||||||
// the top level license
|
// the top level license
|
||||||
license: undefined,
|
license: undefined,
|
||||||
isFreePlan: true,
|
isFreePlan: true,
|
||||||
|
isEnterprisePlan: true,
|
||||||
// features
|
// features
|
||||||
groupsEnabled: false,
|
groupsEnabled: false,
|
||||||
backupsEnabled: false,
|
backupsEnabled: false,
|
||||||
|
@ -53,7 +54,9 @@ export const createLicensingStore = () => {
|
||||||
},
|
},
|
||||||
setLicense: () => {
|
setLicense: () => {
|
||||||
const license = get(auth).user.license
|
const license = get(auth).user.license
|
||||||
const isFreePlan = license?.plan.type === Constants.PlanType.FREE
|
const planType = license?.plan.type
|
||||||
|
const isEnterprisePlan = planType === Constants.PlanType.ENTERPRISE
|
||||||
|
const isFreePlan = planType === Constants.PlanType.FREE
|
||||||
const groupsEnabled = license.features.includes(
|
const groupsEnabled = license.features.includes(
|
||||||
Constants.Features.USER_GROUPS
|
Constants.Features.USER_GROUPS
|
||||||
)
|
)
|
||||||
|
@ -74,6 +77,7 @@ export const createLicensingStore = () => {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
license,
|
license,
|
||||||
|
isEnterprisePlan,
|
||||||
isFreePlan,
|
isFreePlan,
|
||||||
groupsEnabled,
|
groupsEnabled,
|
||||||
backupsEnabled,
|
backupsEnabled,
|
||||||
|
|
|
@ -75,11 +75,13 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
|
||||||
title: "Usage",
|
title: "Usage",
|
||||||
href: "/builder/portal/account/usage",
|
href: "/builder/portal/account/usage",
|
||||||
},
|
},
|
||||||
{
|
]
|
||||||
|
if ($auth.isAdmin) {
|
||||||
|
accountSubPages.push({
|
||||||
title: "Audit Logs",
|
title: "Audit Logs",
|
||||||
href: "/builder/portal/account/auditLogs",
|
href: "/builder/portal/account/auditLogs",
|
||||||
},
|
})
|
||||||
]
|
}
|
||||||
if ($admin.cloud && $auth?.user?.accountPortalAccess) {
|
if ($admin.cloud && $auth?.user?.accountPortalAccess) {
|
||||||
accountSubPages.push({
|
accountSubPages.push({
|
||||||
title: "Upgrade",
|
title: "Upgrade",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { writable, get } from "svelte/store"
|
import { writable, get } from "svelte/store"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
import { auth } from "stores/portal"
|
import { auth } from "stores/portal"
|
||||||
|
import _ from "lodash"
|
||||||
|
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
platformUrl: "",
|
platformUrl: "",
|
||||||
|
@ -26,14 +27,14 @@ export function createOrganisationStore() {
|
||||||
|
|
||||||
async function save(config) {
|
async function save(config) {
|
||||||
// Delete non-persisted fields
|
// Delete non-persisted fields
|
||||||
const storeConfig = get(store)
|
const storeConfig = _.cloneDeep(get(store))
|
||||||
delete storeConfig.oidc
|
delete storeConfig.oidc
|
||||||
delete storeConfig.google
|
delete storeConfig.google
|
||||||
delete storeConfig.oidcCallbackUrl
|
delete storeConfig.oidcCallbackUrl
|
||||||
delete storeConfig.googleCallbackUrl
|
delete storeConfig.googleCallbackUrl
|
||||||
await API.saveConfig({
|
await API.saveConfig({
|
||||||
type: "settings",
|
type: "settings",
|
||||||
config: { ...get(store), ...config },
|
config: { ...storeConfig, ...config },
|
||||||
})
|
})
|
||||||
await init()
|
await init()
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,9 +26,15 @@ export function createUsersStore() {
|
||||||
return await API.getUsers()
|
return await API.getUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One or more users.
|
||||||
|
async function onboard(payload) {
|
||||||
|
return await API.onboardUsers(payload)
|
||||||
|
}
|
||||||
|
|
||||||
async function invite(payload) {
|
async function invite(payload) {
|
||||||
return API.inviteUsers(payload)
|
return API.inviteUsers(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acceptInvite(inviteCode, password, firstName, lastName) {
|
async function acceptInvite(inviteCode, password, firstName, lastName) {
|
||||||
return API.acceptInvite({
|
return API.acceptInvite({
|
||||||
inviteCode,
|
inviteCode,
|
||||||
|
@ -42,6 +48,14 @@ export function createUsersStore() {
|
||||||
return API.getUserInvite(inviteCode)
|
return API.getUserInvite(inviteCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getInvites() {
|
||||||
|
return API.getUserInvites()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateInvite(invite) {
|
||||||
|
return API.updateUserInvite(invite)
|
||||||
|
}
|
||||||
|
|
||||||
async function create(data) {
|
async function create(data) {
|
||||||
let mappedUsers = data.users.map(user => {
|
let mappedUsers = data.users.map(user => {
|
||||||
const body = {
|
const body = {
|
||||||
|
@ -106,8 +120,11 @@ export function createUsersStore() {
|
||||||
getUserRole,
|
getUserRole,
|
||||||
fetch,
|
fetch,
|
||||||
invite,
|
invite,
|
||||||
|
onboard,
|
||||||
acceptInvite,
|
acceptInvite,
|
||||||
fetchInvite,
|
fetchInvite,
|
||||||
|
getInvites,
|
||||||
|
updateInvite,
|
||||||
create,
|
create,
|
||||||
save,
|
save,
|
||||||
bulkDelete,
|
bulkDelete,
|
||||||
|
|
1
packages/cli/.gitignore
vendored
1
packages/cli/.gitignore
vendored
|
@ -6,3 +6,4 @@ docker-error.log
|
||||||
envoy.yaml
|
envoy.yaml
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
prebuilds/
|
prebuilds/
|
||||||
|
dist/
|
||||||
|
|
|
@ -1,16 +1,19 @@
|
||||||
{
|
{
|
||||||
"name": "@budibase/cli",
|
"name": "@budibase/cli",
|
||||||
"version": "2.3.18-alpha.15",
|
"version": "2.4.12-alpha.0",
|
||||||
"description": "Budibase CLI, for developers, self hosting and migrations.",
|
"description": "Budibase CLI, for developers, self hosting and migrations.",
|
||||||
"main": "src/index.js",
|
"main": "dist/index.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
"budi": "src/index.js"
|
"budi": "dist/index.js"
|
||||||
},
|
},
|
||||||
"author": "Budibase",
|
"author": "Budibase",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prebuild": "rm -rf prebuilds 2> /dev/null && cp -r node_modules/leveldown/prebuilds prebuilds",
|
"prebuild": "rm -rf prebuilds 2> /dev/null && cp -r node_modules/leveldown/prebuilds prebuilds",
|
||||||
"build": "yarn prebuild && renamer --find .node --replace .fake 'prebuilds/**' && pkg . --out-path build && yarn postbuild",
|
"rename": "renamer --find .node --replace .fake 'prebuilds/**'",
|
||||||
|
"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",
|
||||||
"postbuild": "rm -rf prebuilds 2> /dev/null"
|
"postbuild": "rm -rf prebuilds 2> /dev/null"
|
||||||
},
|
},
|
||||||
"pkg": {
|
"pkg": {
|
||||||
|
@ -26,21 +29,21 @@
|
||||||
"outputPath": "build"
|
"outputPath": "build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@budibase/backend-core": "2.3.18-alpha.15",
|
"@budibase/backend-core": "2.4.12-alpha.0",
|
||||||
"@budibase/string-templates": "2.3.18-alpha.15",
|
"@budibase/string-templates": "2.4.12-alpha.0",
|
||||||
"@budibase/types": "2.3.18-alpha.15",
|
"@budibase/types": "2.4.12-alpha.0",
|
||||||
"axios": "0.21.2",
|
"axios": "0.21.2",
|
||||||
"chalk": "4.1.0",
|
"chalk": "4.1.0",
|
||||||
"cli-progress": "3.11.2",
|
"cli-progress": "3.11.2",
|
||||||
"commander": "7.1.0",
|
"commander": "7.1.0",
|
||||||
"docker-compose": "0.23.6",
|
"docker-compose": "0.23.12",
|
||||||
"dotenv": "16.0.1",
|
"dotenv": "16.0.1",
|
||||||
"download": "8.0.0",
|
"download": "8.0.0",
|
||||||
"find-free-port": "^2.0.0",
|
"find-free-port": "^2.0.0",
|
||||||
"inquirer": "8.0.0",
|
"inquirer": "8.0.0",
|
||||||
"joi": "17.6.0",
|
"joi": "17.6.0",
|
||||||
"lookpath": "1.1.0",
|
"lookpath": "1.1.0",
|
||||||
"node-fetch": "2",
|
"node-fetch": "2.6.7",
|
||||||
"pkg": "5.8.0",
|
"pkg": "5.8.0",
|
||||||
"posthog-node": "1.0.7",
|
"posthog-node": "1.0.7",
|
||||||
"pouchdb": "7.3.0",
|
"pouchdb": "7.3.0",
|
||||||
|
@ -50,8 +53,15 @@
|
||||||
"yaml": "^2.1.1"
|
"yaml": "^2.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@swc/core": "^1.3.25",
|
||||||
|
"@swc/jest": "^0.2.24",
|
||||||
|
"@types/jest": "^29.4.0",
|
||||||
|
"@types/node-fetch": "2.6.1",
|
||||||
|
"@types/pouchdb": "^6.4.0",
|
||||||
"copyfiles": "^2.4.1",
|
"copyfiles": "^2.4.1",
|
||||||
"eslint": "^7.20.0",
|
"eslint": "^7.20.0",
|
||||||
"renamer": "^4.0.0"
|
"renamer": "^4.0.0",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"typescript": "4.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
const PostHog = require("posthog-node")
|
|
||||||
const { POSTHOG_TOKEN, AnalyticsEvents } = require("../constants")
|
|
||||||
const ConfigManager = require("../structures/ConfigManager")
|
|
||||||
|
|
||||||
class AnalyticsClient {
|
|
||||||
constructor() {
|
|
||||||
this.client = new PostHog(POSTHOG_TOKEN)
|
|
||||||
this.configManager = new ConfigManager()
|
|
||||||
}
|
|
||||||
|
|
||||||
capture(event) {
|
|
||||||
if (this.configManager.config.analyticsDisabled) return
|
|
||||||
|
|
||||||
this.client.capture(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
enable() {
|
|
||||||
this.configManager.removeKey("analyticsDisabled")
|
|
||||||
this.client.capture({ event: AnalyticsEvents.OptIn, distinctId: "cli" })
|
|
||||||
}
|
|
||||||
|
|
||||||
disable() {
|
|
||||||
this.client.capture({ event: AnalyticsEvents.OptOut, distinctId: "cli" })
|
|
||||||
this.configManager.setValue("analyticsDisabled", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
status() {
|
|
||||||
return this.configManager.config.analyticsDisabled ? "disabled" : "enabled"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = AnalyticsClient
|
|
33
packages/cli/src/analytics/Client.ts
Normal file
33
packages/cli/src/analytics/Client.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import PostHog from "posthog-node"
|
||||||
|
import { POSTHOG_TOKEN, AnalyticsEvent } from "../constants"
|
||||||
|
import { ConfigManager } from "../structures/ConfigManager"
|
||||||
|
|
||||||
|
export class AnalyticsClient {
|
||||||
|
client: PostHog
|
||||||
|
configManager: ConfigManager
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = new PostHog(POSTHOG_TOKEN, {})
|
||||||
|
this.configManager = new ConfigManager()
|
||||||
|
}
|
||||||
|
|
||||||
|
capture(event: { distinctId: string; event: string; properties?: any }) {
|
||||||
|
if (this.configManager.config.analyticsDisabled) return
|
||||||
|
|
||||||
|
this.client.capture(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
enable() {
|
||||||
|
this.configManager.removeKey("analyticsDisabled")
|
||||||
|
this.client.capture({ event: AnalyticsEvent.OptIn, distinctId: "cli" })
|
||||||
|
}
|
||||||
|
|
||||||
|
disable() {
|
||||||
|
this.client.capture({ event: AnalyticsEvent.OptOut, distinctId: "cli" })
|
||||||
|
this.configManager.setValue("analyticsDisabled", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
return this.configManager.config.analyticsDisabled ? "disabled" : "enabled"
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
const Command = require("../structures/Command")
|
import { Command } from "../structures/Command"
|
||||||
const { CommandWords } = require("../constants")
|
import { CommandWord } from "../constants"
|
||||||
const { success, error } = require("../utils")
|
import { success, error } from "../utils"
|
||||||
const AnalyticsClient = require("./Client")
|
import { AnalyticsClient } from "./Client"
|
||||||
|
|
||||||
const client = new AnalyticsClient()
|
const client = new AnalyticsClient()
|
||||||
|
|
||||||
|
@ -14,11 +14,10 @@ async function optOut() {
|
||||||
"Successfully opted out of Budibase analytics. You can opt in at any time by running 'budi analytics opt-in'"
|
"Successfully opted out of Budibase analytics. You can opt in at any time by running 'budi analytics opt-in'"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.log(
|
console.log(
|
||||||
error(
|
error(
|
||||||
"Error opting out of Budibase analytics. Please try again later.",
|
`Error opting out of Budibase analytics. Please try again later - ${err}`
|
||||||
err
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -50,7 +49,7 @@ async function status() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = new Command(`${CommandWords.ANALYTICS}`)
|
export default new Command(`${CommandWord.ANALYTICS}`)
|
||||||
.addHelp("Control the analytics you send to Budibase.")
|
.addHelp("Control the analytics you send to Budibase.")
|
||||||
.addSubOption("--optin", "Opt in to sending analytics to Budibase", optIn)
|
.addSubOption("--optin", "Opt in to sending analytics to Budibase", optIn)
|
||||||
.addSubOption("--optout", "Opt out of sending analytics to Budibase.", optOut)
|
.addSubOption("--optout", "Opt out of sending analytics to Budibase.", optOut)
|
||||||
|
@ -59,5 +58,3 @@ const command = new Command(`${CommandWords.ANALYTICS}`)
|
||||||
"Check whether you are currently opted in to Budibase analytics.",
|
"Check whether you are currently opted in to Budibase analytics.",
|
||||||
status
|
status
|
||||||
)
|
)
|
||||||
|
|
||||||
exports.command = command
|
|
|
@ -1,28 +1,30 @@
|
||||||
const Command = require("../structures/Command")
|
import { Command } from "../structures/Command"
|
||||||
const { CommandWords } = require("../constants")
|
import { CommandWord } from "../constants"
|
||||||
const fs = require("fs")
|
import fs from "fs"
|
||||||
const { join } = require("path")
|
import { join } from "path"
|
||||||
const { getAllDbs } = require("../core/db")
|
import { getAllDbs } from "../core/db"
|
||||||
const tar = require("tar")
|
import { progressBar, httpCall } from "../utils"
|
||||||
const { progressBar, httpCall } = require("../utils")
|
import {
|
||||||
const {
|
|
||||||
TEMP_DIR,
|
TEMP_DIR,
|
||||||
COUCH_DIR,
|
COUCH_DIR,
|
||||||
MINIO_DIR,
|
MINIO_DIR,
|
||||||
getConfig,
|
getConfig,
|
||||||
replication,
|
replication,
|
||||||
getPouches,
|
getPouches,
|
||||||
} = require("./utils")
|
} from "./utils"
|
||||||
const { exportObjects, importObjects } = require("./objectStore")
|
import { exportObjects, importObjects } from "./objectStore"
|
||||||
|
const tar = require("tar")
|
||||||
|
|
||||||
async function exportBackup(opts) {
|
type BackupOpts = { env?: string; import?: string; export?: string }
|
||||||
|
|
||||||
|
async function exportBackup(opts: BackupOpts) {
|
||||||
const envFile = opts.env || undefined
|
const envFile = opts.env || undefined
|
||||||
let filename = opts["export"] || opts
|
let filename = opts["export"] || (opts as string)
|
||||||
if (typeof filename !== "string") {
|
if (typeof filename !== "string") {
|
||||||
filename = `backup-${new Date().toISOString()}.tar.gz`
|
filename = `backup-${new Date().toISOString()}.tar.gz`
|
||||||
}
|
}
|
||||||
const config = await getConfig(envFile)
|
const config = await getConfig(envFile)
|
||||||
const dbList = await getAllDbs(config["COUCH_DB_URL"])
|
const dbList = (await getAllDbs(config["COUCH_DB_URL"])) as string[]
|
||||||
const { Remote, Local } = getPouches(config)
|
const { Remote, Local } = getPouches(config)
|
||||||
if (fs.existsSync(TEMP_DIR)) {
|
if (fs.existsSync(TEMP_DIR)) {
|
||||||
fs.rmSync(TEMP_DIR, { recursive: true })
|
fs.rmSync(TEMP_DIR, { recursive: true })
|
||||||
|
@ -55,9 +57,9 @@ async function exportBackup(opts) {
|
||||||
console.log(`Generated export file - ${filename}`)
|
console.log(`Generated export file - ${filename}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importBackup(opts) {
|
async function importBackup(opts: BackupOpts) {
|
||||||
const envFile = opts.env || undefined
|
const envFile = opts.env || undefined
|
||||||
const filename = opts["import"] || opts
|
const filename = opts["import"] || (opts as string)
|
||||||
const config = await getConfig(envFile)
|
const config = await getConfig(envFile)
|
||||||
if (!filename || !fs.existsSync(filename)) {
|
if (!filename || !fs.existsSync(filename)) {
|
||||||
console.error("Cannot import without specifying a valid file to import")
|
console.error("Cannot import without specifying a valid file to import")
|
||||||
|
@ -99,7 +101,7 @@ async function importBackup(opts) {
|
||||||
fs.rmSync(TEMP_DIR, { recursive: true })
|
fs.rmSync(TEMP_DIR, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickOne(opts) {
|
async function pickOne(opts: BackupOpts) {
|
||||||
if (opts["import"]) {
|
if (opts["import"]) {
|
||||||
return importBackup(opts)
|
return importBackup(opts)
|
||||||
} else if (opts["export"]) {
|
} else if (opts["export"]) {
|
||||||
|
@ -107,7 +109,7 @@ async function pickOne(opts) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = new Command(`${CommandWords.BACKUPS}`)
|
export default new Command(`${CommandWord.BACKUPS}`)
|
||||||
.addHelp(
|
.addHelp(
|
||||||
"Allows building backups of Budibase, as well as importing a backup to a new instance."
|
"Allows building backups of Budibase, as well as importing a backup to a new instance."
|
||||||
)
|
)
|
||||||
|
@ -126,5 +128,3 @@ const command = new Command(`${CommandWords.BACKUPS}`)
|
||||||
"Provide an environment variable file to configure the CLI.",
|
"Provide an environment variable file to configure the CLI.",
|
||||||
pickOne
|
pickOne
|
||||||
)
|
)
|
||||||
|
|
||||||
exports.command = command
|
|
|
@ -1,8 +1,8 @@
|
||||||
const { objectStore } = require("@budibase/backend-core")
|
import { objectStore } from "@budibase/backend-core"
|
||||||
const fs = require("fs")
|
import fs from "fs"
|
||||||
const { join } = require("path")
|
import { join } from "path"
|
||||||
const { TEMP_DIR, MINIO_DIR } = require("./utils")
|
import { TEMP_DIR, MINIO_DIR } from "./utils"
|
||||||
const { progressBar } = require("../utils")
|
import { progressBar } from "../utils"
|
||||||
const {
|
const {
|
||||||
ObjectStoreBuckets,
|
ObjectStoreBuckets,
|
||||||
ObjectStore,
|
ObjectStore,
|
||||||
|
@ -13,10 +13,10 @@ const {
|
||||||
|
|
||||||
const bucketList = Object.values(ObjectStoreBuckets)
|
const bucketList = Object.values(ObjectStoreBuckets)
|
||||||
|
|
||||||
exports.exportObjects = async () => {
|
export async function exportObjects() {
|
||||||
const path = join(TEMP_DIR, MINIO_DIR)
|
const path = join(TEMP_DIR, MINIO_DIR)
|
||||||
fs.mkdirSync(path)
|
fs.mkdirSync(path)
|
||||||
let fullList = []
|
let fullList: any[] = []
|
||||||
let errorCount = 0
|
let errorCount = 0
|
||||||
for (let bucket of bucketList) {
|
for (let bucket of bucketList) {
|
||||||
const client = ObjectStore(bucket)
|
const client = ObjectStore(bucket)
|
||||||
|
@ -26,7 +26,7 @@ exports.exportObjects = async () => {
|
||||||
errorCount++
|
errorCount++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const list = await client.listObjectsV2().promise()
|
const list = (await client.listObjectsV2().promise()) as { Contents: any[] }
|
||||||
fullList = fullList.concat(list.Contents.map(el => ({ ...el, bucket })))
|
fullList = fullList.concat(list.Contents.map(el => ({ ...el, bucket })))
|
||||||
}
|
}
|
||||||
if (errorCount === bucketList.length) {
|
if (errorCount === bucketList.length) {
|
||||||
|
@ -48,7 +48,7 @@ exports.exportObjects = async () => {
|
||||||
bar.stop()
|
bar.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.importObjects = async () => {
|
export async function importObjects() {
|
||||||
const path = join(TEMP_DIR, MINIO_DIR)
|
const path = join(TEMP_DIR, MINIO_DIR)
|
||||||
const buckets = fs.readdirSync(path)
|
const buckets = fs.readdirSync(path)
|
||||||
let total = 0
|
let total = 0
|
|
@ -1,12 +1,13 @@
|
||||||
const dotenv = require("dotenv")
|
import dotenv from "dotenv"
|
||||||
const fs = require("fs")
|
import fs from "fs"
|
||||||
const { string } = require("../questions")
|
import { string } from "../questions"
|
||||||
const { getPouch } = require("../core/db")
|
import { getPouch } from "../core/db"
|
||||||
const { env: environment } = require("@budibase/backend-core")
|
import { env as environment } from "@budibase/backend-core"
|
||||||
|
import PouchDB from "pouchdb"
|
||||||
|
|
||||||
exports.TEMP_DIR = ".temp"
|
export const TEMP_DIR = ".temp"
|
||||||
exports.COUCH_DIR = "couchdb"
|
export const COUCH_DIR = "couchdb"
|
||||||
exports.MINIO_DIR = "minio"
|
export const MINIO_DIR = "minio"
|
||||||
|
|
||||||
const REQUIRED = [
|
const REQUIRED = [
|
||||||
{ value: "MAIN_PORT", default: "10000" },
|
{ value: "MAIN_PORT", default: "10000" },
|
||||||
|
@ -19,7 +20,7 @@ const REQUIRED = [
|
||||||
{ value: "MINIO_SECRET_KEY" },
|
{ value: "MINIO_SECRET_KEY" },
|
||||||
]
|
]
|
||||||
|
|
||||||
exports.checkURLs = config => {
|
export function checkURLs(config: Record<string, string>) {
|
||||||
const mainPort = config["MAIN_PORT"],
|
const mainPort = config["MAIN_PORT"],
|
||||||
username = config["COUCH_DB_USER"],
|
username = config["COUCH_DB_USER"],
|
||||||
password = config["COUCH_DB_PASSWORD"]
|
password = config["COUCH_DB_PASSWORD"]
|
||||||
|
@ -34,23 +35,23 @@ exports.checkURLs = config => {
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.askQuestions = async () => {
|
export async function askQuestions() {
|
||||||
console.log(
|
console.log(
|
||||||
"*** NOTE: use a .env file to load these parameters repeatedly ***"
|
"*** NOTE: use a .env file to load these parameters repeatedly ***"
|
||||||
)
|
)
|
||||||
let config = {}
|
let config: Record<string, string> = {}
|
||||||
for (let property of REQUIRED) {
|
for (let property of REQUIRED) {
|
||||||
config[property.value] = await string(property.value, property.default)
|
config[property.value] = await string(property.value, property.default)
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.loadEnvironment = path => {
|
export function loadEnvironment(path: string) {
|
||||||
if (!fs.existsSync(path)) {
|
if (!fs.existsSync(path)) {
|
||||||
throw "Unable to file specified .env file"
|
throw "Unable to file specified .env file"
|
||||||
}
|
}
|
||||||
const env = fs.readFileSync(path, "utf8")
|
const env = fs.readFileSync(path, "utf8")
|
||||||
const config = exports.checkURLs(dotenv.parse(env))
|
const config = checkURLs(dotenv.parse(env))
|
||||||
for (let required of REQUIRED) {
|
for (let required of REQUIRED) {
|
||||||
if (!config[required.value]) {
|
if (!config[required.value]) {
|
||||||
throw `Cannot find "${required.value}" property in .env file`
|
throw `Cannot find "${required.value}" property in .env file`
|
||||||
|
@ -60,12 +61,12 @@ exports.loadEnvironment = path => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// true is the default value passed by commander
|
// true is the default value passed by commander
|
||||||
exports.getConfig = async (envFile = true) => {
|
export async function getConfig(envFile: boolean | string = true) {
|
||||||
let config
|
let config
|
||||||
if (envFile !== true) {
|
if (envFile !== true) {
|
||||||
config = exports.loadEnvironment(envFile)
|
config = loadEnvironment(envFile as string)
|
||||||
} else {
|
} else {
|
||||||
config = await exports.askQuestions()
|
config = await askQuestions()
|
||||||
}
|
}
|
||||||
// fill out environment
|
// fill out environment
|
||||||
for (let key of Object.keys(config)) {
|
for (let key of Object.keys(config)) {
|
||||||
|
@ -74,12 +75,16 @@ exports.getConfig = async (envFile = true) => {
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.replication = async (from, to) => {
|
export async function replication(
|
||||||
|
from: PouchDB.Database,
|
||||||
|
to: PouchDB.Database
|
||||||
|
) {
|
||||||
const pouch = getPouch()
|
const pouch = getPouch()
|
||||||
try {
|
try {
|
||||||
await pouch.replicate(from, to, {
|
await pouch.replicate(from, to, {
|
||||||
batch_size: 1000,
|
batch_size: 1000,
|
||||||
batch_limit: 5,
|
batches_limit: 5,
|
||||||
|
// @ts-ignore
|
||||||
style: "main_only",
|
style: "main_only",
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -87,7 +92,7 @@ exports.replication = async (from, to) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.getPouches = config => {
|
export function getPouches(config: Record<string, string>) {
|
||||||
const Remote = getPouch(config["COUCH_DB_URL"])
|
const Remote = getPouch(config["COUCH_DB_URL"])
|
||||||
const Local = getPouch()
|
const Local = getPouch()
|
||||||
return { Remote, Local }
|
return { Remote, Local }
|
|
@ -1,25 +0,0 @@
|
||||||
const { Event } = require("@budibase/types")
|
|
||||||
|
|
||||||
exports.CommandWords = {
|
|
||||||
BACKUPS: "backups",
|
|
||||||
HOSTING: "hosting",
|
|
||||||
ANALYTICS: "analytics",
|
|
||||||
HELP: "help",
|
|
||||||
PLUGIN: "plugins",
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.InitTypes = {
|
|
||||||
QUICK: "quick",
|
|
||||||
DIGITAL_OCEAN: "do",
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.AnalyticsEvents = {
|
|
||||||
OptOut: "analytics:opt:out",
|
|
||||||
OptIn: "analytics:opt:in",
|
|
||||||
SelfHostInit: "hosting:init",
|
|
||||||
PluginInit: Event.PLUGIN_INIT,
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.POSTHOG_TOKEN = "phc_yGOn4i7jWKaCTapdGR6lfA4AvmuEQ2ijn5zAVSFYPlS"
|
|
||||||
|
|
||||||
exports.GENERATED_USER_EMAIL = "admin@admin.com"
|
|
4
packages/cli/src/constants.ts
Normal file
4
packages/cli/src/constants.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export { CommandWord, InitType, AnalyticsEvent } from "@budibase/types"
|
||||||
|
|
||||||
|
export const POSTHOG_TOKEN = "phc_yGOn4i7jWKaCTapdGR6lfA4AvmuEQ2ijn5zAVSFYPlS"
|
||||||
|
export const GENERATED_USER_EMAIL = "admin@admin.com"
|
|
@ -1,12 +1,12 @@
|
||||||
const PouchDB = require("pouchdb")
|
import PouchDB from "pouchdb"
|
||||||
const { checkSlashesInUrl } = require("../utils")
|
import { checkSlashesInUrl } from "../utils"
|
||||||
const fetch = require("node-fetch")
|
import fetch from "node-fetch"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fully qualified URL including username and password, or nothing for local
|
* Fully qualified URL including username and password, or nothing for local
|
||||||
*/
|
*/
|
||||||
exports.getPouch = (url = undefined) => {
|
export function getPouch(url?: string) {
|
||||||
let POUCH_DB_DEFAULTS = {}
|
let POUCH_DB_DEFAULTS
|
||||||
if (!url) {
|
if (!url) {
|
||||||
POUCH_DB_DEFAULTS = {
|
POUCH_DB_DEFAULTS = {
|
||||||
prefix: undefined,
|
prefix: undefined,
|
||||||
|
@ -19,11 +19,12 @@ exports.getPouch = (url = undefined) => {
|
||||||
}
|
}
|
||||||
const replicationStream = require("pouchdb-replication-stream")
|
const replicationStream = require("pouchdb-replication-stream")
|
||||||
PouchDB.plugin(replicationStream.plugin)
|
PouchDB.plugin(replicationStream.plugin)
|
||||||
|
// @ts-ignore
|
||||||
PouchDB.adapter("writableStream", replicationStream.adapters.writableStream)
|
PouchDB.adapter("writableStream", replicationStream.adapters.writableStream)
|
||||||
return PouchDB.defaults(POUCH_DB_DEFAULTS)
|
return PouchDB.defaults(POUCH_DB_DEFAULTS) as PouchDB.Static
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.getAllDbs = async url => {
|
export async function getAllDbs(url: string) {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
checkSlashesInUrl(encodeURI(`${url}/_all_dbs`)),
|
checkSlashesInUrl(encodeURI(`${url}/_all_dbs`)),
|
||||||
{
|
{
|
|
@ -1,2 +1,3 @@
|
||||||
process.env.NO_JS = "1"
|
process.env.NO_JS = "1"
|
||||||
process.env.JS_BCRYPT = "1"
|
process.env.JS_BCRYPT = "1"
|
||||||
|
process.env.DISABLE_JWT_WARNING = "1"
|
|
@ -1,11 +0,0 @@
|
||||||
const AnalyticsClient = require("./analytics/Client")
|
|
||||||
|
|
||||||
const client = new AnalyticsClient()
|
|
||||||
|
|
||||||
exports.captureEvent = (event, properties) => {
|
|
||||||
client.capture({
|
|
||||||
distinctId: "cli",
|
|
||||||
event,
|
|
||||||
properties,
|
|
||||||
})
|
|
||||||
}
|
|
11
packages/cli/src/events.ts
Normal file
11
packages/cli/src/events.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { AnalyticsClient } from "./analytics/Client"
|
||||||
|
|
||||||
|
const client = new AnalyticsClient()
|
||||||
|
|
||||||
|
export function captureEvent(event: string, properties: any) {
|
||||||
|
client.capture({
|
||||||
|
distinctId: "cli",
|
||||||
|
event,
|
||||||
|
properties,
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,21 +1,21 @@
|
||||||
const util = require("util")
|
import util from "util"
|
||||||
const exec = util.promisify(require("child_process").exec)
|
const runCommand = util.promisify(require("child_process").exec)
|
||||||
|
|
||||||
exports.exec = async (command, dir = "./") => {
|
export async function exec(command: string, dir = "./") {
|
||||||
const { stdout } = await exec(command, { cwd: dir })
|
const { stdout } = await runCommand(command, { cwd: dir })
|
||||||
return stdout
|
return stdout
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.utilityInstalled = async utilName => {
|
export async function utilityInstalled(utilName: string) {
|
||||||
try {
|
try {
|
||||||
await exports.exec(`${utilName} --version`)
|
await exec(`${utilName} --version`)
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.runPkgCommand = async (command, dir = "./") => {
|
export async function runPkgCommand(command: string, dir = "./") {
|
||||||
const yarn = await exports.utilityInstalled("yarn")
|
const yarn = await exports.utilityInstalled("yarn")
|
||||||
const npm = await exports.utilityInstalled("npm")
|
const npm = await exports.utilityInstalled("npm")
|
||||||
if (!yarn && !npm) {
|
if (!yarn && !npm) {
|
|
@ -2,15 +2,16 @@ const { success } = require("../utils")
|
||||||
const { updateDockerComposeService } = require("./utils")
|
const { updateDockerComposeService } = require("./utils")
|
||||||
const randomString = require("randomstring")
|
const randomString = require("randomstring")
|
||||||
const { GENERATED_USER_EMAIL } = require("../constants")
|
const { GENERATED_USER_EMAIL } = require("../constants")
|
||||||
|
import { DockerCompose } from "./types"
|
||||||
|
|
||||||
exports.generateUser = async (password, silent) => {
|
export async function generateUser(password: string | null, silent: boolean) {
|
||||||
const email = GENERATED_USER_EMAIL
|
const email = GENERATED_USER_EMAIL
|
||||||
if (!password) {
|
if (!password) {
|
||||||
password = randomString.generate({ length: 6 })
|
password = randomString.generate({ length: 6 })
|
||||||
}
|
}
|
||||||
updateDockerComposeService(service => {
|
updateDockerComposeService((service: DockerCompose) => {
|
||||||
service.environment["BB_ADMIN_USER_EMAIL"] = email
|
service.environment["BB_ADMIN_USER_EMAIL"] = email
|
||||||
service.environment["BB_ADMIN_USER_PASSWORD"] = password
|
service.environment["BB_ADMIN_USER_PASSWORD"] = password as string
|
||||||
})
|
})
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
console.log(
|
console.log(
|
|
@ -1,14 +1,14 @@
|
||||||
const Command = require("../structures/Command")
|
import { Command } from "../structures/Command"
|
||||||
const { CommandWords } = require("../constants")
|
import { CommandWord } from "../constants"
|
||||||
const { init } = require("./init")
|
import { init } from "./init"
|
||||||
const { start } = require("./start")
|
import { start } from "./start"
|
||||||
const { stop } = require("./stop")
|
import { stop } from "./stop"
|
||||||
const { status } = require("./status")
|
import { status } from "./status"
|
||||||
const { update } = require("./update")
|
import { update } from "./update"
|
||||||
const { generateUser } = require("./genUser")
|
import { generateUser } from "./genUser"
|
||||||
const { watchPlugins } = require("./watch")
|
import { watchPlugins } from "./watch"
|
||||||
|
|
||||||
const command = new Command(`${CommandWords.HOSTING}`)
|
export default new Command(`${CommandWord.HOSTING}`)
|
||||||
.addHelp("Controls self hosting on the Budibase platform.")
|
.addHelp("Controls self hosting on the Budibase platform.")
|
||||||
.addSubOption(
|
.addSubOption(
|
||||||
"--init [type]",
|
"--init [type]",
|
||||||
|
@ -46,5 +46,3 @@ const command = new Command(`${CommandWords.HOSTING}`)
|
||||||
generateUser
|
generateUser
|
||||||
)
|
)
|
||||||
.addSubOption("--single", "Specify this with init to use the single image.")
|
.addSubOption("--single", "Specify this with init to use the single image.")
|
||||||
|
|
||||||
exports.command = command
|
|
|
@ -1,24 +1,25 @@
|
||||||
const { InitTypes, AnalyticsEvents } = require("../constants")
|
import { InitType, AnalyticsEvent } from "../constants"
|
||||||
const { confirmation } = require("../questions")
|
import { confirmation } from "../questions"
|
||||||
const { captureEvent } = require("../events")
|
import { captureEvent } from "../events"
|
||||||
const makeFiles = require("./makeFiles")
|
import * as makeFiles from "./makeFiles"
|
||||||
const axios = require("axios")
|
import { parseEnv } from "../utils"
|
||||||
const { parseEnv } = require("../utils")
|
import { checkDockerConfigured, downloadDockerCompose } from "./utils"
|
||||||
const { checkDockerConfigured, downloadFiles } = require("./utils")
|
import { watchPlugins } from "./watch"
|
||||||
const { watchPlugins } = require("./watch")
|
import { generateUser } from "./genUser"
|
||||||
const { generateUser } = require("./genUser")
|
import fetch from "node-fetch"
|
||||||
|
|
||||||
const DO_USER_DATA_URL = "http://169.254.169.254/metadata/v1/user-data"
|
const DO_USER_DATA_URL = "http://169.254.169.254/metadata/v1/user-data"
|
||||||
|
|
||||||
async function getInitConfig(type, isQuick, port) {
|
async function getInitConfig(type: string, isQuick: boolean, port: number) {
|
||||||
const config = isQuick ? makeFiles.QUICK_CONFIG : {}
|
const config: any = isQuick ? makeFiles.QUICK_CONFIG : {}
|
||||||
if (type === InitTypes.DIGITAL_OCEAN) {
|
if (type === InitType.DIGITAL_OCEAN) {
|
||||||
try {
|
try {
|
||||||
const output = await axios.get(DO_USER_DATA_URL)
|
const output = await fetch(DO_USER_DATA_URL)
|
||||||
const response = parseEnv(output.data)
|
const data = await output.text()
|
||||||
|
const response = parseEnv(data)
|
||||||
for (let [key, value] of Object.entries(makeFiles.ConfigMap)) {
|
for (let [key, value] of Object.entries(makeFiles.ConfigMap)) {
|
||||||
if (response[key]) {
|
if (response[key]) {
|
||||||
config[value] = response[key]
|
config[value as string] = response[key]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -32,7 +33,7 @@ async function getInitConfig(type, isQuick, port) {
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.init = async opts => {
|
export async function init(opts: any) {
|
||||||
let type, isSingle, watchDir, genUser, port, silent
|
let type, isSingle, watchDir, genUser, port, silent
|
||||||
if (typeof opts === "string") {
|
if (typeof opts === "string") {
|
||||||
type = opts
|
type = opts
|
||||||
|
@ -44,7 +45,7 @@ exports.init = async opts => {
|
||||||
port = opts["port"]
|
port = opts["port"]
|
||||||
silent = opts["silent"]
|
silent = opts["silent"]
|
||||||
}
|
}
|
||||||
const isQuick = type === InitTypes.QUICK || type === InitTypes.DIGITAL_OCEAN
|
const isQuick = type === InitType.QUICK || type === InitType.DIGITAL_OCEAN
|
||||||
await checkDockerConfigured()
|
await checkDockerConfigured()
|
||||||
if (!isQuick) {
|
if (!isQuick) {
|
||||||
const shouldContinue = await confirmation(
|
const shouldContinue = await confirmation(
|
||||||
|
@ -55,12 +56,12 @@ exports.init = async opts => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
captureEvent(AnalyticsEvents.SelfHostInit, {
|
captureEvent(AnalyticsEvent.SelfHostInit, {
|
||||||
type,
|
type,
|
||||||
})
|
})
|
||||||
const config = await getInitConfig(type, isQuick, port)
|
const config = await getInitConfig(type, isQuick, port)
|
||||||
if (!isSingle) {
|
if (!isSingle) {
|
||||||
await downloadFiles()
|
await downloadDockerCompose()
|
||||||
await makeFiles.makeEnv(config, silent)
|
await makeFiles.makeEnv(config, silent)
|
||||||
} else {
|
} else {
|
||||||
await makeFiles.makeSingleCompose(config, silent)
|
await makeFiles.makeSingleCompose(config, silent)
|
|
@ -1,15 +1,15 @@
|
||||||
const { number } = require("../questions")
|
import { number } from "../questions"
|
||||||
const { success, stringifyToDotEnv } = require("../utils")
|
import { success, stringifyToDotEnv } from "../utils"
|
||||||
const fs = require("fs")
|
import fs from "fs"
|
||||||
const path = require("path")
|
import path from "path"
|
||||||
|
import yaml from "yaml"
|
||||||
|
import { getAppService } from "./utils"
|
||||||
const randomString = require("randomstring")
|
const randomString = require("randomstring")
|
||||||
const yaml = require("yaml")
|
|
||||||
const { getAppService } = require("./utils")
|
|
||||||
|
|
||||||
const SINGLE_IMAGE = "budibase/budibase:latest"
|
const SINGLE_IMAGE = "budibase/budibase:latest"
|
||||||
const VOL_NAME = "budibase_data"
|
const VOL_NAME = "budibase_data"
|
||||||
const COMPOSE_PATH = path.resolve("./docker-compose.yaml")
|
export const COMPOSE_PATH = path.resolve("./docker-compose.yaml")
|
||||||
const ENV_PATH = path.resolve("./.env")
|
export const ENV_PATH = path.resolve("./.env")
|
||||||
|
|
||||||
function getSecrets(opts = { single: false }) {
|
function getSecrets(opts = { single: false }) {
|
||||||
const secrets = [
|
const secrets = [
|
||||||
|
@ -19,7 +19,7 @@ function getSecrets(opts = { single: false }) {
|
||||||
"REDIS_PASSWORD",
|
"REDIS_PASSWORD",
|
||||||
"INTERNAL_API_KEY",
|
"INTERNAL_API_KEY",
|
||||||
]
|
]
|
||||||
const obj = {}
|
const obj: Record<string, string> = {}
|
||||||
secrets.forEach(secret => (obj[secret] = randomString.generate()))
|
secrets.forEach(secret => (obj[secret] = randomString.generate()))
|
||||||
// setup couch creds separately
|
// setup couch creds separately
|
||||||
if (opts && opts.single) {
|
if (opts && opts.single) {
|
||||||
|
@ -32,7 +32,7 @@ function getSecrets(opts = { single: false }) {
|
||||||
return obj
|
return obj
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSingleCompose(port) {
|
function getSingleCompose(port: number) {
|
||||||
const singleComposeObj = {
|
const singleComposeObj = {
|
||||||
version: "3",
|
version: "3",
|
||||||
services: {
|
services: {
|
||||||
|
@ -53,7 +53,7 @@ function getSingleCompose(port) {
|
||||||
return yaml.stringify(singleComposeObj)
|
return yaml.stringify(singleComposeObj)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEnv(port) {
|
function getEnv(port: number) {
|
||||||
const partOne = stringifyToDotEnv({
|
const partOne = stringifyToDotEnv({
|
||||||
MAIN_PORT: port,
|
MAIN_PORT: port,
|
||||||
})
|
})
|
||||||
|
@ -77,19 +77,21 @@ function getEnv(port) {
|
||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.ENV_PATH = ENV_PATH
|
export const ConfigMap = {
|
||||||
exports.COMPOSE_PATH = COMPOSE_PATH
|
|
||||||
|
|
||||||
module.exports.ConfigMap = {
|
|
||||||
MAIN_PORT: "port",
|
MAIN_PORT: "port",
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.QUICK_CONFIG = {
|
export const QUICK_CONFIG = {
|
||||||
key: "budibase",
|
key: "budibase",
|
||||||
port: 10000,
|
port: 10000,
|
||||||
}
|
}
|
||||||
|
|
||||||
async function make(path, contentsFn, inputs = {}, silent) {
|
async function make(
|
||||||
|
path: string,
|
||||||
|
contentsFn: Function,
|
||||||
|
inputs: any = {},
|
||||||
|
silent: boolean
|
||||||
|
) {
|
||||||
const port =
|
const port =
|
||||||
inputs.port ||
|
inputs.port ||
|
||||||
(await number(
|
(await number(
|
||||||
|
@ -107,15 +109,15 @@ async function make(path, contentsFn, inputs = {}, silent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.makeEnv = async (inputs = {}, silent) => {
|
export async function makeEnv(inputs: any = {}, silent: boolean) {
|
||||||
return make(ENV_PATH, getEnv, inputs, silent)
|
return make(ENV_PATH, getEnv, inputs, silent)
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.makeSingleCompose = async (inputs = {}, silent) => {
|
export async function makeSingleCompose(inputs: any = {}, silent: boolean) {
|
||||||
return make(COMPOSE_PATH, getSingleCompose, inputs, silent)
|
return make(COMPOSE_PATH, getSingleCompose, inputs, silent)
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.getEnvProperty = property => {
|
export function getEnvProperty(property: string) {
|
||||||
const props = fs.readFileSync(ENV_PATH, "utf8").split(property)
|
const props = fs.readFileSync(ENV_PATH, "utf8").split(property)
|
||||||
if (props[0].charAt(0) === "=") {
|
if (props[0].charAt(0) === "=") {
|
||||||
property = props[0]
|
property = props[0]
|
||||||
|
@ -125,7 +127,7 @@ module.exports.getEnvProperty = property => {
|
||||||
return property.split("=")[1].split("\n")[0]
|
return property.split("=")[1].split("\n")[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.getComposeProperty = property => {
|
export function getComposeProperty(property: string) {
|
||||||
const { service } = getAppService(COMPOSE_PATH)
|
const { service } = getAppService(COMPOSE_PATH)
|
||||||
if (property === "port" && Array.isArray(service.ports)) {
|
if (property === "port" && Array.isArray(service.ports)) {
|
||||||
const port = service.ports[0]
|
const port = service.ports[0]
|
|
@ -1,14 +1,10 @@
|
||||||
const {
|
import { checkDockerConfigured, checkInitComplete, handleError } from "./utils"
|
||||||
checkDockerConfigured,
|
import { info, success } from "../utils"
|
||||||
checkInitComplete,
|
import * as makeFiles from "./makeFiles"
|
||||||
handleError,
|
import compose from "docker-compose"
|
||||||
} = require("./utils")
|
import fs from "fs"
|
||||||
const { info, success } = require("../utils")
|
|
||||||
const makeFiles = require("./makeFiles")
|
|
||||||
const compose = require("docker-compose")
|
|
||||||
const fs = require("fs")
|
|
||||||
|
|
||||||
exports.start = async () => {
|
export async function start() {
|
||||||
await checkDockerConfigured()
|
await checkDockerConfigured()
|
||||||
checkInitComplete()
|
checkInitComplete()
|
||||||
console.log(
|
console.log(
|
|
@ -1,12 +1,8 @@
|
||||||
const {
|
import { checkDockerConfigured, checkInitComplete, handleError } from "./utils"
|
||||||
checkDockerConfigured,
|
import { info } from "../utils"
|
||||||
checkInitComplete,
|
import compose from "docker-compose"
|
||||||
handleError,
|
|
||||||
} = require("./utils")
|
|
||||||
const { info } = require("../utils")
|
|
||||||
const compose = require("docker-compose")
|
|
||||||
|
|
||||||
exports.status = async () => {
|
export async function status() {
|
||||||
await checkDockerConfigured()
|
await checkDockerConfigured()
|
||||||
checkInitComplete()
|
checkInitComplete()
|
||||||
console.log(info("Budibase status"))
|
console.log(info("Budibase status"))
|
|
@ -1,12 +1,8 @@
|
||||||
const {
|
import { checkDockerConfigured, checkInitComplete, handleError } from "./utils"
|
||||||
checkDockerConfigured,
|
import { info, success } from "../utils"
|
||||||
checkInitComplete,
|
import compose from "docker-compose"
|
||||||
handleError,
|
|
||||||
} = require("./utils")
|
|
||||||
const { info, success } = require("../utils")
|
|
||||||
const compose = require("docker-compose")
|
|
||||||
|
|
||||||
exports.stop = async () => {
|
export async function stop() {
|
||||||
await checkDockerConfigured()
|
await checkDockerConfigured()
|
||||||
checkInitComplete()
|
checkInitComplete()
|
||||||
console.log(info("Stopping services, this may take a moment."))
|
console.log(info("Stopping services, this may take a moment."))
|
4
packages/cli/src/hosting/types.ts
Normal file
4
packages/cli/src/hosting/types.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export interface DockerCompose {
|
||||||
|
environment: Record<string, string>
|
||||||
|
volumes: string[]
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue