1
0
Fork 0
mirror of synced 2024-09-01 02:01:32 +12:00

Merge branch 'develop' of github.com:Budibase/budibase into just-dataspace-things

This commit is contained in:
Andrew Kingston 2023-03-09 17:54:58 +00:00
commit c4e3667b6c
226 changed files with 5936 additions and 1951 deletions

View file

@ -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

View file

@ -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

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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: |

View file

@ -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

View file

@ -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 }}

View file

@ -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

View file

@ -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`.

View file

@ -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:

View file

@ -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;

View file

@ -1,5 +1,5 @@
{ {
"version": "2.3.18-alpha.15", "version": "2.4.12-alpha.0",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View file

@ -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",

View file

@ -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")
} }

View file

@ -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
} }
/** /**

View file

@ -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)
}) })
}) })
}) })

View file

@ -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.")

View file

@ -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: {},
} }

View file

@ -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.

View file

@ -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(),
}) })
} }
} }

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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"

View 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)
}

View file

@ -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",
}

View file

@ -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(/-/, "")}`
}

View file

@ -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(),

View file

@ -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"

View file

@ -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",

View file

@ -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>

View file

@ -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

View file

@ -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}

View file

@ -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>

View file

@ -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}
/> />

View file

@ -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
/> />

View file

@ -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}

View file

@ -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)

View file

@ -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".

View file

@ -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",

View file

@ -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 = () => {

View file

@ -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 {

View file

@ -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}:&nbsp;</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>

View file

@ -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}

View file

@ -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>

View file

@ -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"

View file

@ -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 () => {

View file

@ -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>

View file

@ -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(

View file

@ -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;
} }

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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">

View file

@ -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
}) })

View file

@ -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(),
}) })

View file

@ -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")
} }
} }

View file

@ -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,
} }
}) })
} }

View file

@ -62,3 +62,8 @@ export const PluginSource = {
GITHUB: "Github", GITHUB: "Github",
FILE: "File Upload", FILE: "File Upload",
} }
export const OnboardingType = {
EMAIL: "email",
PASSWORD: "password",
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 {

View file

@ -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

View file

@ -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 {

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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()

View file

@ -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

View file

@ -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,

View file

@ -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",

View file

@ -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()
} }

View file

@ -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,

View file

@ -6,3 +6,4 @@ docker-error.log
envoy.yaml envoy.yaml
*.tar.gz *.tar.gz
prebuilds/ prebuilds/
dist/

View file

@ -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"
} }
} }

View file

@ -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

View 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"
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 }

View file

@ -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"

View 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"

View file

@ -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`)),
{ {

View file

@ -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"

View file

@ -1,11 +0,0 @@
const AnalyticsClient = require("./analytics/Client")
const client = new AnalyticsClient()
exports.captureEvent = (event, properties) => {
client.capture({
distinctId: "cli",
event,
properties,
})
}

View 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,
})
}

View file

@ -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) {

View file

@ -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(

View file

@ -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

View file

@ -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)

View file

@ -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]

View file

@ -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(

View file

@ -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"))

View file

@ -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."))

View 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