1
0
Fork 0
mirror of synced 2024-07-08 15:56:23 +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
about: Create a report to help us improve
title: ''
labels: bug
labels: bug, linear
assignees: ''
---
**Checklist**
- [ ] 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
run: |
curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \
curl -H "Authorization: token ${{ secrets.GH_ACCESS_TOKEN }}" \
-H 'Accept: application/vnd.github.v3.raw' \
-o values.production.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:
workflow_dispatch:
env:
INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
workflow_dispatch:
workflow_call:
jobs:
release:
deploy-to-legacy-preprod-env:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: 'Get Previous tag'
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
@ -21,23 +19,16 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
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
run: |
curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \
run: |
curl -H "Authorization: token ${{ secrets.GH_ACCESS_TOKEN }}" \
-H 'Accept: application/vnd.github.v3.raw' \
-o values.preprod.yaml \
-L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/budibase-preprod/values.yaml
wc -l values.preprod.yaml
- name: Deploy to Preprod Environment
uses: glopezep/helm@v1.7.1
uses: budibase/helm@v1.8.0
with:
release: budibase-preprod
namespace: budibase
@ -46,7 +37,7 @@ jobs:
helm: helm3
values: |
globals:
appVersion: v${{ env.RELEASE_VERSION }}
appVersion: ${{ steps.previoustag.outputs.tag }}
ingress:
enabled: true
nginx: true
@ -61,5 +52,5 @@ jobs:
uses: tsickert/discord-webhook@v4.0.0
with:
webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }}
content: "Preprod Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Pre-prod."
embed-title: ${{ env.RELEASE_VERSION }}
content: "Preprod Deployment Complete: ${{ steps.previoustag.outputs.tag }} deployed to Budibase Pre-prod."
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

@ -117,4 +117,4 @@ jobs:
with:
repository: budibase/budibase-deploys
event: budicloud-qa-deploy
github_pat: ${{ secrets.GH_ACCESS_TOKEN }}
github_pat: ${{ secrets.GH_ACCESS_TOKEN }}

View file

@ -35,9 +35,8 @@ env:
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
jobs:
release:
release-images:
runs-on: ubuntu-latest
steps:
- name: Fail if branch is not master
if: github.ref != 'refs/heads/master'
@ -57,14 +56,6 @@ jobs:
- run: yarn lint
- run: yarn build
- 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
env:
@ -90,46 +81,63 @@ jobs:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}
BUDIBASE_RELEASE_VERSION: ${{ steps.previoustag.outputs.tag }}
- 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: Pull values.yaml from budibase-infra
run: |
curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \
-H 'Accept: application/vnd.github.v3.raw' \
-o values.preprod.yaml \
-L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/budibase-preprod/values.yaml
wc -l values.preprod.yaml
release-helm-chart:
needs: [release-images]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Helm
uses: azure/setup-helm@v1
id: helm-install
- name: Deploy to Preprod Environment
uses: glopezep/helm@v1.7.1
with:
release: budibase-preprod
namespace: budibase
chart: charts/budibase
token: ${{ github.token }}
helm: helm3
values: |
globals:
appVersion: ${{ steps.previoustag.outputs.tag }}
ingress:
enabled: true
nginx: true
value-files: >-
[
"values.preprod.yaml"
]
- 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: |
git config user.name "Budibase Helm Bot"
git config user.email "<>"
git reset --hard
git pull
mkdir sync
echo "Packaging chart to sync dir"
helm package charts/budibase --version 0.0.0-master --app-version "$RELEASE_VERSION" --destination sync
echo "Packaging successful"
git checkout gh-pages
echo "Indexing helm repo"
helm repo index --merge docs/index.yaml sync
mv -f sync/* docs
rm -rf sync
echo "Pushing new helm release"
git add -A
git commit -m "Helm Release: ${{ env.RELEASE_VERSION }}"
git push
env:
KUBECONFIG_FILE: '${{ secrets.PREPROD_KUBECONFIG }}'
RELEASE_VERSION: ${{ steps.previoustag.outputs.tag }}
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v4.0.0
deploy-to-legacy-preprod-env:
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:
webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }}
content: "Preprod Deployment Complete: ${{ steps.previoustag.outputs.tag }} deployed to Budibase Pre-prod."
embed-title: ${{ steps.previoustag.outputs.tag }}
repository: budibase/budibase-deploys
event: budicloud-preprod-deploy
github_pat: ${{ secrets.GH_ACCESS_TOKEN }}

View file

@ -16,9 +16,13 @@ jobs:
- uses: actions/checkout@v2
with:
node-version: 14.x
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
id: version
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 }}
- name: COUCHDB_UPSTREAM_URL
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
{{ if .Values.services.proxy.resolver }}
value: {{ .Values.services.proxy.resolver }}

View file

@ -245,7 +245,7 @@ couchdb:
## The CouchDB image
image:
repository: couchdb
tag: 3.2.1
tag: 3.1.1
pullPolicy: IfNotPresent
## Experimental integration with Lucene-powered fulltext search

View file

@ -52,4 +52,14 @@ 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.
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:
container_name: budi-minio-dev
restart: on-failure
# Last version that supports the "fs" backend
image: minio/minio:RELEASE.2022-10-24T18-35-07Z
image: minio/minio
volumes:
- minio_data:/data
ports:
@ -69,4 +68,4 @@ volumes:
minio_data:
driver: local
redis_data:
driver: local
driver: local

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_object "object-src 'none'";
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_frame "frame-src 'self' https:";
set $csp_img "img-src http: https: data: blob:";
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'";
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",
"packages": [
"packages/*"

View file

@ -1,6 +1,6 @@
{
"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",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
@ -22,9 +22,9 @@
"test:watch": "jest --watchAll"
},
"dependencies": {
"@budibase/nano": "10.1.1",
"@budibase/nano": "10.1.2",
"@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",
"@techpass/passport-openidconnect": "0.3.2",
"aws-cloudfront-sign": "2.2.0",

View file

@ -28,6 +28,7 @@ import * as events from "../events"
import * as configs from "../configs"
import { clearCookie, getCookie } from "../utils"
import { ssoSaveUserNoOp } from "../middleware/passport/sso/sso"
import env from "../environment"
const refresh = require("passport-oauth2-refresh")
export {
@ -52,7 +53,7 @@ export const jwt = require("jsonwebtoken")
_passport.use(new LocalStrategy(local.options, local.authenticate))
if (jwtPassport.options.secretOrKey) {
_passport.use(new JwtStrategy(jwtPassport.options, jwtPassport.authenticate))
} else {
} else if (!env.DISABLE_JWT_WARNING) {
logAlert("No JWT Secret supplied, cannot configure JWT strategy")
}

View file

@ -1,6 +1,6 @@
import { getAppClient } from "../redis/init"
import { doWithDB, DocumentType } from "../db"
import { Database } from "@budibase/types"
import { Database, App } from "@budibase/types"
const AppState = {
INVALID: "invalid",
@ -65,7 +65,7 @@ export async function getAppMetadata(appId: string) {
if (isInvalid(metadata)) {
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 { getDB } from "../../db"
import tk from "timekeeper"
const START_DATE = Date.now()
tk.freeze(START_DATE)
tk.freeze(Date.now())
const DELAY = 5000
@ -17,34 +20,99 @@ describe("writethrough", () => {
const writethrough = new Writethrough(db, DELAY)
const writethrough2 = new Writethrough(db2, DELAY)
const docId = structures.uuid()
beforeEach(() => {
jest.clearAllMocks()
})
describe("put", () => {
let first: any
let current: any
it("should be able to store, will go to DB", 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)
first = output
current = output
expect(output.value).toBe(1)
})
})
it("second put shouldn't update DB", 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)
expect(first._rev).toBe(output._rev)
expect(current._rev).toBe(output._rev)
expect(output.value).toBe(1)
})
})
it("should put it again after delay period", async () => {
await config.doInTenant(async () => {
tk.freeze(START_DATE + DELAY + 1)
const response = await writethrough.put({ ...first, value: 3 })
tk.freeze(Date.now() + DELAY + 1)
const response = await writethrough.put({ ...current, value: 3 })
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)
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", () => {
it("should be able to retrieve", async () => {
await config.doInTenant(async () => {
const response = await writethrough.get("test")
expect(response.value).toBe(3)
const response = await writethrough.get(docId)
expect(response.value).toBe(4)
})
})
})

View file

@ -1,7 +1,8 @@
import BaseCache from "./base"
import { getWritethroughClient } from "../redis/init"
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
let CACHE: BaseCache | null = null
@ -27,44 +28,62 @@ function makeCacheItem(doc: any, lastWrite: number | null = null): CacheItem {
return { doc, lastWrite: lastWrite || Date.now() }
}
export async function put(
async function put(
db: Database,
doc: any,
doc: Document,
writeRateMs: number = DEFAULT_WRITE_RATE_MS
) {
const cache = await getCache()
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
let output = doc
if (updateDb) {
const writeDb = async (toWrite: any) => {
// doc should contain the _id and _rev
const response = await db.put(toWrite)
output = {
...doc,
_id: response.id,
_rev: response.rev,
}
}
try {
await writeDb(doc)
} catch (err: any) {
if (err.status !== 409) {
throw err
} else {
// Swallow 409s but log them
logWarn(`Ignoring conflict in write-through cache`)
const lockResponse = await locks.doWithLock(
{
type: LockType.TRY_ONCE,
name: LockName.PERSIST_WRITETHROUGH,
resource: key,
ttl: 1000,
},
async () => {
const writeDb = async (toWrite: any) => {
// doc should contain the _id and _rev
const response = await db.put(toWrite, { force: true })
output = {
...doc,
_id: response.id,
_rev: response.rev,
}
}
try {
await writeDb(doc)
} catch (err: any) {
if (err.status !== 409) {
throw err
} else {
// Swallow 409s but log them
logWarn(`Ignoring conflict in write-through cache`)
}
}
}
)
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
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 }
}
export async function get(db: Database, id: string): Promise<any> {
async function get(db: Database, id: string): Promise<any> {
const cache = await getCache()
const cacheKey = makeCacheKey(db, id)
let cacheItem: CacheItem = await cache.get(cacheKey)
@ -76,11 +95,7 @@ export async function get(db: Database, id: string): Promise<any> {
return cacheItem.doc
}
export async function remove(
db: Database,
docOrId: any,
rev?: any
): Promise<void> {
async function remove(db: Database, docOrId: any, rev?: any): Promise<void> {
const cache = await getCache()
if (!docOrId) {
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()
return db.put(config)
}
@ -54,7 +56,7 @@ export async function getSettingsConfigDoc(): Promise<SettingsConfig> {
if (!config) {
config = {
_id: generateConfigID(ConfigType.GOOGLE),
_id: generateConfigID(ConfigType.SETTINGS),
type: ConfigType.SETTINGS,
config: {},
}

View file

@ -94,6 +94,7 @@ const environment = {
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: parseInt(process.env.SMTP_PORT || ""),
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.
* This can be useful to prevent lockout when configuring SSO.

View file

@ -8,7 +8,7 @@ import {
HostInfo,
} from "@budibase/types"
import { EventProcessor } from "./types"
import { getAppId } from "../../context"
import { getAppId, doInTenant, getTenantId } from "../../context"
import BullQueue from "bull"
import { createQueue, JobQueue } from "../../queue"
import { isAudited } from "../../utils"
@ -26,28 +26,30 @@ export default class AuditLogsProcessor implements EventProcessor {
JobQueue.AUDIT_LOG
)
return AuditLogsProcessor.auditLogQueue.process(async job => {
let properties = job.data.properties
if (properties.audited) {
properties = {
...properties,
...properties.audited,
return doInTenant(job.data.tenantId, async () => {
let properties = job.data.properties
if (properties.audited) {
properties = {
...properties,
...properties.audited,
}
delete properties.audited
}
delete properties.audited
}
// this feature is disabled by default due to privacy requirements
// in some countries - available as env var in-case it is desired
// in self host deployments
let hostInfo: HostInfo | undefined = {}
if (env.ENABLE_AUDIT_LOG_IP_ADDR) {
hostInfo = job.data.opts.hostInfo
}
// this feature is disabled by default due to privacy requirements
// in some countries - available as env var in-case it is desired
// in self host deployments
let hostInfo: HostInfo | undefined = {}
if (env.ENABLE_AUDIT_LOG_IP_ADDR) {
hostInfo = job.data.opts.hostInfo
}
await writeAuditLogs(job.data.event, properties, {
userId: job.data.opts.userId,
timestamp: job.data.opts.timestamp,
appId: job.data.opts.appId,
hostInfo,
await writeAuditLogs(job.data.event, properties, {
userId: job.data.opts.userId,
timestamp: job.data.opts.timestamp,
appId: job.data.opts.appId,
hostInfo,
})
})
})
}
@ -72,6 +74,7 @@ export default class AuditLogsProcessor implements EventProcessor {
appId: getAppId(),
hostInfo: identity.hostInfo,
},
tenantId: getTenantId(),
})
}
}

View file

@ -154,7 +154,8 @@ export default function (
return next()
}
} catch (err: any) {
console.error("Auth Error", err?.message || err)
console.error(`Auth Error: ${err.message}`)
console.error(err)
// invalid token, clear the cookie
if (err && err.name === "JsonWebTokenError") {
clearCookie(ctx, Cookie.Auth)

View file

@ -87,6 +87,7 @@ export const runMigration = async (
const lengthStatement = length > 1 ? `[${count}/${length}]` : ""
const db = getDB(dbName)
try {
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: {
// immediately throws an error if the lock is already held
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 }
const redisWrapper = await getLockClient()
const client = redisWrapper.getClient()
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)
let lock
try {
@ -73,8 +88,8 @@ export const doWithLock = async (opts: LockOptions, task: any) => {
let name: string = `lock:${prefix}_${opts.name}`
// add additional unique name if required
if (opts.nameSuffix) {
name = name + `_${opts.nameSuffix}`
if (opts.resource) {
name = name + `_${opts.resource}`
}
// create the lock
@ -83,7 +98,7 @@ export const doWithLock = async (opts: LockOptions, task: any) => {
// perform locked task
// need to await to ensure completion before unlocking
const result = await task()
return result
return { executed: true, result }
} catch (e: any) {
console.warn("lock error")
// 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
// due to retry count (0) exceeded
console.warn(e)
return
return { executed: false }
} else {
console.error(e)
throw e

View file

@ -5,6 +5,7 @@ import {
generateAppUserID,
queryGlobalView,
UNICODE_MAX,
directCouchFind,
} from "./db"
import { BulkDocsResponse, User } from "@budibase/types"
import { getGlobalDB } from "./context"
@ -101,6 +102,7 @@ export const searchGlobalUsersByApp = async (
})
params.startkey = opts && opts.startkey ? opts.startkey : params.startkey
let response = await queryGlobalView(ViewName.USER_BY_APP, params)
if (!response) {
response = []
}
@ -111,6 +113,45 @@ export const searchGlobalUsersByApp = async (
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) => {
if (!user) {
return

View file

@ -4,4 +4,6 @@ export { generator } from "./structures"
export * as testEnv from "./testEnv"
export * as testContainerUtils from "./testContainerUtils"
export * from "./jestUtils"
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,
Hosting,
SSOAccount,
CreateAccount,
CreatePassswordAccount,
} from "@budibase/types"
import _ from "lodash"
@ -29,6 +31,10 @@ export const account = (): Account => {
}
}
export function selfHostAccount() {
return account()
}
export const cloudAccount = (): CloudAccount => {
return {
...account(),
@ -47,9 +53,9 @@ function provider(): AccountSSOProvider {
return _.sample(Object.values(AccountSSOProvider)) as AccountSSOProvider
}
export function ssoAccount(): SSOAccount {
export function ssoAccount(account: Account = cloudAccount()): SSOAccount {
return {
...cloudAccount(),
...account,
authType: AuthType.SSO,
oauth2: {
accessToken: generator.string(),
@ -61,3 +67,49 @@ export function ssoAccount(): SSOAccount {
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"
export function id() {
return `db_${newid()}`
}
export function rev() {
return `${structures.generator.character({
numeric: true,
})}-${structures.uuid().replace(/-/, "")}`
}

View file

@ -1,6 +1,7 @@
import {
GoogleInnerConfig,
JwtClaims,
OAuth2,
OIDCInnerConfig,
OIDCWellKnownConfig,
SSOAuthDetails,
@ -14,6 +15,13 @@ import * as shared from "./shared"
import _ from "lodash"
import { user } from "./shared"
export function OAuth(): OAuth2 {
return {
refreshToken: generator.string(),
accessToken: generator.string(),
}
}
export function authDetails(userDoc?: User): SSOAuthDetails {
if (!userDoc) {
userDoc = user()
@ -28,10 +36,7 @@ export function authDetails(userDoc?: User): SSOAuthDetails {
return {
email: userDoc.email,
oauth2: {
refreshToken: generator.string(),
accessToken: generator.string(),
},
oauth2: OAuth(),
profile,
provider,
providerType: providerType(),

View file

@ -475,10 +475,10 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@budibase/nano@10.1.1":
version "10.1.1"
resolved "https://registry.yarnpkg.com/@budibase/nano/-/nano-10.1.1.tgz#36ccda4d9bb64b5ee14dd2b27a295b40739b1038"
integrity sha512-kbMIzMkjVtl+xI0UPwVU0/pn8/ccxTyfzwBz6Z+ZiN2oUSb0fJCe0qwA6o8dxwSa8nZu4MbGAeMJl3CJndmWtA==
"@budibase/nano@10.1.2":
version "10.1.2"
resolved "https://registry.yarnpkg.com/@budibase/nano/-/nano-10.1.2.tgz#10fae5a1ab39be6a81261f40e7b7ec6d21cbdd4a"
integrity sha512-1w+YN2n/M5aZ9hBKCP4NEjdQbT8BfCLRizkdvm0Je665eEHw3aE1hvo8mon9Ro9QuDdxj1DfDMMFnym6/QUwpQ==
dependencies:
"@types/tough-cookie" "^4.0.2"
axios "^1.1.3"

View file

@ -1,7 +1,7 @@
{
"name": "@budibase/bbui",
"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",
"svelte": "src/index.js",
"module": "dist/bbui.es.js",
@ -38,7 +38,8 @@
],
"dependencies": {
"@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/actionbutton": "1.0.1",
"@spectrum-css/actiongroup": "1.0.1",

View file

@ -1,6 +1,9 @@
<script>
import "@spectrum-css/actionbutton/dist/index-vars.css"
import { createEventDispatcher } from "svelte"
import Tooltip from "../Tooltip/Tooltip.svelte"
import { fade } from "svelte/transition"
const dispatch = createEventDispatcher()
export let quiet = false
@ -13,6 +16,9 @@
export let active = false
export let fullWidth = false
export let noPadding = false
export let tooltip = ""
let showTooltip = false
function longPress(element) {
if (!longPressable) return
@ -35,42 +41,54 @@
}
</script>
<button
use:longPress
class:spectrum-ActionButton--quiet={quiet}
class:spectrum-ActionButton--emphasized={emphasized}
class:is-selected={selected}
class:noPadding
class:fullWidth
class="spectrum-ActionButton spectrum-ActionButton--size{size}"
class:active
{disabled}
on:longPress
on:click|preventDefault
<span
class="btn-wrap"
on:mouseover={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
on:focus={() => (showTooltip = true)}
>
{#if longPressable}
<svg
class="spectrum-Icon spectrum-UIIcon-CornerTriangle100 spectrum-ActionButton-hold"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-CornerTriangle100" />
</svg>
{/if}
{#if icon}
<svg
class="spectrum-Icon spectrum-Icon--size{size}"
focusable="false"
aria-hidden="true"
aria-label={icon}
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
{/if}
{#if $$slots}
<span class="spectrum-ActionButton-label"><slot /></span>
{/if}
</button>
<button
use:longPress
class:spectrum-ActionButton--quiet={quiet}
class:spectrum-ActionButton--emphasized={emphasized}
class:is-selected={selected}
class:noPadding
class:fullWidth
class="spectrum-ActionButton spectrum-ActionButton--size{size}"
class:active
{disabled}
on:longPress
on:click|preventDefault
>
{#if longPressable}
<svg
class="spectrum-Icon spectrum-UIIcon-CornerTriangle100 spectrum-ActionButton-hold"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-CornerTriangle100" />
</svg>
{/if}
{#if icon}
<svg
class="spectrum-Icon spectrum-Icon--size{size}"
focusable="false"
aria-hidden="true"
aria-label={icon}
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
{/if}
{#if $$slots}
<span class="spectrum-ActionButton-label"><slot /></span>
{/if}
{#if tooltip && showTooltip}
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}>
<Tooltip textWrapping direction="bottom" text={tooltip} />
</div>
{/if}
</button>
</span>
<style>
.fullWidth {
@ -95,7 +113,20 @@
.spectrum-ActionButton--quiet {
padding: 0 8px;
}
.spectrum-ActionButton--quiet.is-selected {
color: var(--spectrum-global-color-gray-900);
}
.is-selected:not(.emphasized) .spectrum-Icon {
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>

View file

@ -31,6 +31,7 @@ export default function positionDropdown(element, opts) {
styles.top = anchorBounds.top
} else if (window.innerHeight - anchorBounds.bottom < 100) {
styles.top = anchorBounds.top - elementBounds.height - offset
styles.maxHeight = 240
} else {
styles.top = anchorBounds.bottom + offset
styles.maxHeight = window.innerHeight - anchorBounds.bottom - 20

View file

@ -15,6 +15,7 @@
export let sort = false
export let autoWidth = false
export let fetchTerm = null
export let useFetch = false
export let customPopoverHeight
const dispatch = createEventDispatcher()
@ -87,6 +88,7 @@
isPlaceholder={!arrayValue.length}
{autocomplete}
bind:fetchTerm
{useFetch}
{isOptionSelected}
{getOptionLabel}
{getOptionValue}

View file

@ -24,6 +24,7 @@
export let getOptionLabel = option => option
export let getOptionValue = option => option
export let getOptionIcon = () => null
export let useOptionIconImage = false
export let getOptionColour = () => null
export let open = false
export let readonly = false
@ -32,7 +33,11 @@
export let autocomplete = false
export let sort = false
export let fetchTerm = null
export let useFetch = false
export let customPopoverHeight
export let align = "left"
export let footer = null
const dispatch = createEventDispatcher()
let searchTerm = null
@ -131,7 +136,7 @@
<Popover
anchor={button}
align="left"
align={align || "left"}
bind:this={popover}
{open}
on:close={() => (open = false)}
@ -146,9 +151,9 @@
>
{#if autocomplete}
<Search
value={fetchTerm ? fetchTerm : searchTerm}
value={useFetch ? fetchTerm : searchTerm}
on:change={event =>
fetchTerm ? (fetchTerm = event.detail) : (searchTerm = event.detail)}
useFetch ? (fetchTerm = event.detail) : (searchTerm = event.detail)}
{disabled}
placeholder="Search"
/>
@ -186,7 +191,16 @@
>
{#if getOptionIcon(option, idx)}
<span class="option-extra icon">
<Icon size="S" name={getOptionIcon(option, idx)} />
{#if useOptionIconImage}
<img
src={getOptionIcon(option, idx)}
alt="icon"
width="15"
height="15"
/>
{:else}
<Icon size="S" name={getOptionIcon(option, idx)} />
{/if}
</span>
{/if}
{#if getOptionColour(option, idx)}
@ -208,6 +222,12 @@
{/each}
{/if}
</ul>
{#if footer}
<div class="footer">
{footer}
</div>
{/if}
</div>
</Popover>
@ -284,4 +304,11 @@
.popover-content :global(.spectrum-Search .spectrum-Textfield-icon) {
top: 9px;
}
.footer {
padding: 4px 12px 12px 12px;
font-style: italic;
max-width: 170px;
font-size: 12px;
}
</style>

View file

@ -11,6 +11,7 @@
export let getOptionLabel = option => option
export let getOptionValue = option => option
export let getOptionIcon = () => null
export let useOptionIconImage = false
export let getOptionColour = () => null
export let isOptionEnabled
export let readonly = false
@ -18,6 +19,8 @@
export let autoWidth = false
export let autocomplete = false
export let sort = false
export let align
export let footer = null
const dispatch = createEventDispatcher()
@ -41,7 +44,7 @@
const getFieldText = (value, options, placeholder) => {
// Always use placeholder if no value
if (value == null || value === "") {
return placeholder || "Choose an option"
return placeholder !== false ? "Choose an option" : ""
}
return getFieldAttribute(getOptionLabel, value, options)
@ -66,15 +69,18 @@
{fieldColour}
{options}
{autoWidth}
{align}
{footer}
{getOptionLabel}
{getOptionValue}
{getOptionIcon}
{useOptionIconImage}
{getOptionColour}
{isOptionEnabled}
{autocomplete}
{sort}
isPlaceholder={value == null || value === ""}
placeholderOption={placeholder}
placeholderOption={placeholder === false ? null : placeholder}
isOptionSelected={option => option === value}
onSelectOption={selectOption}
/>

View file

@ -17,6 +17,7 @@
export let autoWidth = false
export let autocomplete = false
export let fetchTerm = null
export let useFetch = false
export let customPopoverHeight
const dispatch = createEventDispatcher()
@ -41,6 +42,7 @@
{autocomplete}
{customPopoverHeight}
bind:fetchTerm
{useFetch}
on:change={onChange}
on:click
/>

View file

@ -14,6 +14,7 @@
export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value")
export let getOptionIcon = option => option?.icon
export let useOptionIconImage = false
export let getOptionColour = option => option?.colour
export let isOptionEnabled
export let quiet = false
@ -22,6 +23,8 @@
export let tooltip = ""
export let autocomplete = false
export let customPopoverHeight
export let align
export let footer = null
const dispatch = createEventDispatcher()
const onChange = e => {
@ -48,10 +51,13 @@
{placeholder}
{autoWidth}
{sort}
{align}
{footer}
{getOptionLabel}
{getOptionValue}
{getOptionIcon}
{getOptionColour}
{useOptionIconImage}
{isOptionEnabled}
{autocomplete}
{customPopoverHeight}

View file

@ -29,6 +29,14 @@
visible = false
}
export function toggle() {
if (visible) {
hide()
} else {
show()
}
}
export function cancel() {
if (!visible) {
return
@ -61,7 +69,7 @@
}
}
setContext(Context.Modal, { show, hide, cancel })
setContext(Context.Modal, { show, hide, toggle, cancel })
onMount(() => {
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.
* Starting with a letter is important to make it DOM safe.
@ -41,30 +44,6 @@ export const hashString = string => {
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
* fields - e.g. "a.b.c".

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/builder",
"version": "2.3.18-alpha.15",
"version": "2.4.12-alpha.0",
"license": "GPL-3.0",
"private": true,
"scripts": {
@ -58,10 +58,11 @@
}
},
"dependencies": {
"@budibase/bbui": "2.3.18-alpha.15",
"@budibase/client": "2.3.18-alpha.15",
"@budibase/frontend-core": "2.3.18-alpha.15",
"@budibase/string-templates": "2.3.18-alpha.15",
"@budibase/bbui": "2.4.12-alpha.0",
"@budibase/client": "2.4.12-alpha.0",
"@budibase/frontend-core": "2.4.12-alpha.0",
"@budibase/shared-core": "2.4.12-alpha.0",
"@budibase/string-templates": "2.4.12-alpha.0",
"@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-brands-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: false,
tourNodes: null,
builderSidePanel: false,
}
export const getFrontendStore = () => {

View file

@ -73,14 +73,14 @@
<Tabs noHorizPadding selected="Input">
<Tab title="Input">
<TextArea
minHeight="80px"
minHeight="160px"
disabled
value={textArea(filteredResults?.[idx]?.inputs, "No input")}
/>
</Tab>
<Tab title="Output">
<TextArea
minHeight="100px"
minHeight="160px"
disabled
value={textArea(filteredResults?.[idx]?.outputs, "No output")}
/>
@ -98,8 +98,9 @@
<style>
.container {
padding: 0 30px 0 30px;
padding: 0 30px 30px 30px;
height: 100%;
overflow: auto;
}
.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 allowPublic = true
export let allowRemove = false
export let disabled = false
export let align
export let footer = null
export let allowedRoles = null
const dispatch = createEventDispatcher()
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) {
roles = [
...roles,
newRoles = [
...newRoles,
{
_id: RemoveID,
name: "Remove",
@ -28,9 +36,9 @@
]
}
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 => {
@ -59,6 +67,9 @@
<Select
{autoWidth}
{quiet}
{disabled}
{align}
{footer}
bind:value
on:change={onChange}
{options}

View file

@ -6,8 +6,10 @@
Heading,
Body,
Button,
Icon,
ActionButton,
} from "@budibase/bbui"
import RevertModal from "components/deploy/RevertModal.svelte"
import VersionModal from "components/deploy/VersionModal.svelte"
import { processStringSync } from "@budibase/string-templates"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import analytics, { Events, EventSource } from "analytics"
@ -16,6 +18,9 @@
import { onMount } from "svelte"
import DeployModal from "components/deploy/DeployModal.svelte"
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
@ -108,66 +113,97 @@
})
</script>
<div class="deployment-top-nav">
{#if isPublished}
<div class="publish-popover">
<div bind:this={publishPopoverAnchor}>
<Icon
size="M"
hoverable
name="Globe"
tooltip="Your published app"
on:click={publishPopover.show()}
/>
</div>
<Popover
bind:this={publishPopover}
align="right"
disabled={!isPublished}
anchor={publishPopoverAnchor}
offset={10}
>
<div class="popover-content">
<Layout noPadding gap="M">
<Heading size="XS">Your published app</Heading>
<Body size="S">
<span class="publish-popover-message">
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
</span>
</Body>
<div class="buttons">
<Button
warning={true}
icon="GlobeStrike"
disabled={!isPublished}
on:click={unpublishApp}
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View app</Button>
</div>
</Layout>
</div>
</Popover>
<div class="action-top-nav">
<div class="action-buttons">
<div class="version">
<VersionModal />
</div>
{/if}
<RevertModal />
{#if !isPublished}
<Icon
size="M"
name="GlobeStrike"
disabled
tooltip="Your app has not been published yet"
/>
{/if}
{#if isPublished}
<div class="publish-popover">
<div bind:this={publishPopoverAnchor}>
<ActionButton
quiet
icon="Globe"
size="M"
tooltip="Your published app"
on:click={publishPopover.show()}
/>
</div>
<Popover
bind:this={publishPopover}
align="right"
disabled={!isPublished}
anchor={publishPopoverAnchor}
offset={10}
>
<div class="popover-content">
<Layout noPadding gap="M">
<Heading size="XS">Your published app</Heading>
<Body size="S">
<span class="publish-popover-message">
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
</span>
</Body>
<div class="buttons">
<Button
warning={true}
icon="GlobeStrike"
disabled={!isPublished}
on:click={unpublishApp}
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View app</Button>
</div>
</Layout>
</div>
</Popover>
</div>
{/if}
{#if !isPublished}
<ActionButton
quiet
icon="GlobeStrike"
size="M"
tooltip="Your app has not been published yet"
disabled
/>
{/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>
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
@ -183,6 +219,11 @@
</div>
<style>
/* .banner-btn {
display: flex;
align-items: center;
gap: var(--spacing-s);
} */
.popover-content {
padding: var(--spacing-xl);
}
@ -191,6 +232,22 @@
flex-direction: row;
justify-content: flex-end;
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>

View file

@ -1,10 +1,10 @@
<script>
import {
Icon,
Input,
Modal,
notifications,
ModalContent,
ActionButton,
} from "@budibase/bbui"
import { store } from "builderStore"
import { API } from "api"
@ -28,12 +28,14 @@
}
</script>
<Icon
name="Revert"
hoverable
on:click={revertModal.show}
<ActionButton
quiet
icon="Revert"
size="M"
tooltip="Revert changes"
on:click={revertModal.show}
/>
<Modal bind:this={revertModal}>
<ModalContent
title="Revert Changes"

View file

@ -24,7 +24,10 @@
let updateModal
$: appId = $store.appId
$: updateAvailable = clientPackage.version !== $store.version
$: updateAvailable =
clientPackage.version &&
$store.version &&
clientPackage.version !== $store.version
$: revertAvailable = $store.revertableVersion != null
const refreshAppPackage = async () => {

View file

@ -14,10 +14,11 @@
export let borderRight = false
let wide = false
$: customHeaderContent = $$slots["panel-header-content"]
</script>
<div class="panel" class:wide class:borderLeft class:borderRight>
<div class="header">
<div class="header" class:custom={customHeaderContent}>
{#if showBackButton}
<Icon name="ArrowLeft" hoverable on:click={onClickBackButton} />
{/if}
@ -43,6 +44,13 @@
<Icon name="Close" hoverable on:click={onClickCloseButton} />
{/if}
</div>
{#if customHeaderContent}
<span class="custom-content-wrap">
<slot name="panel-header-content" />
</span>
{/if}
<div class="body">
<slot />
</div>
@ -116,4 +124,10 @@
justify-content: flex-start;
align-items: stretch;
}
.header.custom {
border: none;
}
.custom-content-wrap {
border-bottom: var(--border-light);
}
</style>

View file

@ -20,6 +20,12 @@
x =>
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) {
const source = $datasources.list.find(

View file

@ -1,22 +1,66 @@
<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 DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
export let parameters
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>
<div class="root">
<Label small>Screen</Label>
<DrawerBindableInput
title="Destination URL"
placeholder="/screen"
value={parameters.url}
on:change={value => (parameters.url = value.detail)}
{bindings}
<Label small>Destination</Label>
<Select
placeholder={null}
bind:value={parameters.type}
options={typeOptions}
on:change={() => (parameters.url = "")}
/>
<div />
<Checkbox text="Open screen in modal" bind:value={parameters.peek} />
{#if parameters.type === "screen"}
<DrawerBindableCombobox
title="Destination"
placeholder="/screen"
value={parameters.url}
on:change={value => (parameters.url = value.detail)}
{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 />
<Checkbox text="New Tab" bind:value={parameters.externalNewTab} />
{/if}
</div>
<style>
@ -24,7 +68,7 @@
display: grid;
align-items: center;
gap: var(--spacing-m);
grid-template-columns: auto 1fr;
grid-template-columns: auto;
max-width: 400px;
margin: 0 auto;
}

View file

@ -8,6 +8,7 @@
getSchemaForDatasource,
} from "builderStore/dataBinding"
import { currentAsset } from "builderStore"
import { getFields } from "helpers/searchFields"
export let componentInstance
export let value = []
@ -21,9 +22,14 @@
$: datasource = getDatasourceForProvider($currentAsset, componentInstance)
$: schema = getSchema($currentAsset, datasource)
$: options = Object.keys(schema || {})
$: options = allowCellEditing
? Object.keys(schema || {})
: enrichedSchemaFields?.map(field => field.name)
$: sanitisedValue = getValidColumns(value, options)
$: updateBoundValue(sanitisedValue)
$: enrichedSchemaFields = getFields(Object.values(schema) || [], {
allowLinks: true,
})
const getSchema = (asset, datasource) => {
const schema = getSchemaForDatasource(asset, datasource).schema

View file

@ -35,6 +35,7 @@
let parameters
let data = []
let saveId
let currentTab = "JSON"
$: datasource = $datasources.list.find(ds => ds._id === query.datasourceId)
$: query.schema = fieldsToSchema(fields)
@ -84,7 +85,16 @@
return
}
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
currentTab = "JSON"
notifications.success("Query executed successfully")
} catch (error) {
notifications.error(`Query Error: ${error.message}`)
@ -205,7 +215,7 @@
</Body>
<section class="viewer">
{#if data}
<Tabs selected="JSON">
<Tabs bind:selected={currentTab}>
<Tab title="JSON">
<JSONPreview data={data[0]} minHeight="120" />
</Tab>

View file

@ -120,7 +120,7 @@
const cleanUrl = inputUrl =>
url
?.replace(/(http)|(https)|[{}:]/g, "")
?.replace(/(https)|(http)|[{}:]/g, "")
?.replaceAll(".", "_")
?.replaceAll("/", " ")
?.trim() || inputUrl

View file

@ -122,7 +122,9 @@
<Layout noPadding gap="M">
<div class="tour-header">
<Heading size="XS">{tourStep?.title || "-"}</Heading>
<div>{`${tourStepIdx + 1}/${tourSteps?.length}`}</div>
{#if tourSteps?.length > 1}
<div>{`${tourStepIdx + 1}/${tourSteps?.length}`}</div>
{/if}
</div>
<Body size="S">
<span class="tour-body">

View file

@ -6,16 +6,19 @@
export let tourStepKey
let currentTour
let currentTourStep
let ready = false
let handler
onMount(() => {
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)
ready = true
})

View file

@ -1,19 +1,23 @@
import { get } from "svelte/store"
import { store } from "builderStore"
import { users, auth } from "stores/portal"
import { auth } from "stores/portal"
import analytics from "analytics"
import { OnboardingData, OnboardingDesign, OnboardingPublish } from "./steps"
import { API } from "api"
const ONBOARDING_EVENT_PREFIX = "onboarding"
export const TOUR_STEP_KEYS = {
BUILDER_APP_PUBLISH: "builder-app-publish",
BUILDER_DATA_SECTION: "builder-data-section",
BUILDER_DESIGN_SECTION: "builder-design-section",
BUILDER_USER_MANAGEMENT: "builder-user-management",
BUILDER_AUTOMATE_SECTION: "builder-automate-section",
FEATURE_USER_MANAGEMENT: "feature-user-management",
}
export const TOUR_KEYS = {
TOUR_BUILDER_ONBOARDING: "builder-onboarding",
FEATURE_ONBOARDING: "feature-onboarding",
}
const tourEvent = eventKey => {
@ -58,6 +62,15 @@ const getTours = () => {
},
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,
title: "Publish",
@ -71,8 +84,37 @@ const getTours = () => {
// Mark the users onboarding as complete
// Clear all tour related state
if (get(auth).user) {
await users.save({
...get(auth).user,
await API.updateSelf({
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(),
})

View file

@ -13,6 +13,7 @@
await auth.updateSelf($values)
notifications.success("Information updated successfully")
} catch (error) {
console.error(error)
notifications.error("Failed to update information")
}
}

View file

@ -18,7 +18,7 @@
return list.map(item => {
return {
...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",
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,
Tab,
Heading,
Modal,
notifications,
} from "@budibase/bbui"
import RevertModal from "components/deploy/RevertModal.svelte"
import VersionModal from "components/deploy/VersionModal.svelte"
import DeployNavigation from "components/deploy/DeployNavigation.svelte"
import AppActions from "components/deploy/AppActions.svelte"
import { API } from "api"
import { isActive, goto, layout, redirect } from "@roxi/routify"
import { capitalise } from "helpers"
import { onMount, onDestroy } from "svelte"
import CommandPalette from "components/commandPalette/CommandPalette.svelte"
import TourWrap from "components/portal/onboarding/TourWrap.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"
export let application
// Get Package and set store
let promise = getPackage()
// let betaAccess = false
// Sync once when you load the app
let hasSynced = false
let commandPaletteModal
$: selected = capitalise(
$layout.children.find(layout => $isActive(layout.path))?.title ?? "data"
@ -51,7 +49,6 @@
$redirect("../../")
}
}
// Handles navigation between frontend, backend, automation.
// This remembers your last place on each of the sections
// e.g. if one of your screens is selected on front end, then
@ -68,23 +65,41 @@
})
}
// Event handler for the command palette
const handleKeyDown = e => {
if (e.key === "k" && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
commandPaletteModal.toggle()
}
}
const initTour = async () => {
if (
!$auth.user?.onboardedAt &&
isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)
) {
// Determine the correct step
const activeNav = $layout.children.find(c => $isActive(c.path))
const onboardingTour = TOURS[TOUR_KEYS.TOUR_BUILDER_ONBOARDING]
const targetStep = activeNav
? onboardingTour.find(step => step.route === activeNav?.path)
: null
await store.update(state => ({
...state,
onboarding: true,
tourKey: TOUR_KEYS.TOUR_BUILDER_ONBOARDING,
tourStepKey: targetStep?.id,
}))
// Check if onboarding is enabled.
if (isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)) {
if (!$auth.user?.onboardedAt) {
// Determine the correct step
const activeNav = $layout.children.find(c => $isActive(c.path))
const onboardingTour = TOURS[TOUR_KEYS.TOUR_BUILDER_ONBOARDING]
const targetStep = activeNav
? onboardingTour.find(step => step.route === activeNav?.path)
: null
await store.update(state => ({
...state,
onboarding: true,
tourKey: TOUR_KEYS.TOUR_BUILDER_ONBOARDING,
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,88 +127,91 @@
})
</script>
{#await promise}
<!-- This should probably be some kind of loading state? -->
<div class="loading" />
{:then _}
<TourPopover />
<div class="root">
<div class="top-nav">
<div class="topleftnav">
<ActionMenu>
<div slot="control">
<Icon size="M" hoverable name="ShowMenu" />
</div>
<MenuItem on:click={() => $goto("../../portal/apps")}>
Exit to portal
</MenuItem>
<MenuItem
on:click={() => $goto(`../../portal/overview/${application}`)}
>
Overview
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/access`)}
>
Access
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/automation-history`)}
>
Automation history
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/backups`)}
>
Backups
</MenuItem>
<TourPopover />
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/name-and-url`)}
>
Name and URL
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/version`)}
>
Version
</MenuItem>
</ActionMenu>
<Heading size="XS">{$store.name || "App"}</Heading>
</div>
<div class="topcenternav">
<Tabs {selected} size="M">
{#each $layout.children as { path, title }}
<TourWrap tourStepKey={`builder-${title}-section`}>
<Tab
quiet
selected={$isActive(path)}
on:click={topItemNavigate(path)}
title={capitalise(title)}
id={`builder-${title}-tab`}
/>
</TourWrap>
{/each}
</Tabs>
</div>
<div class="toprightnav">
<div class="version">
<VersionModal />
{#if $store.builderSidePanel}
<BuilderSidePanel />
{/if}
<div class="root">
<div class="top-nav">
<div class="topleftnav">
<ActionMenu>
<div slot="control">
<Icon size="M" hoverable name="ShowMenu" />
</div>
<RevertModal />
<DeployNavigation {application} />
</div>
<MenuItem on:click={() => $goto("../../portal/apps")}>
Exit to portal
</MenuItem>
<MenuItem
on:click={() => $goto(`../../portal/overview/${application}`)}
>
Overview
</MenuItem>
<MenuItem
on:click={() => $goto(`../../portal/overview/${application}/access`)}
>
Access
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/automation-history`)}
>
Automation history
</MenuItem>
<MenuItem
on:click={() => $goto(`../../portal/overview/${application}/backups`)}
>
Backups
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/name-and-url`)}
>
Name and URL
</MenuItem>
<MenuItem
on:click={() => $goto(`../../portal/overview/${application}/version`)}
>
Version
</MenuItem>
</ActionMenu>
<Heading size="XS">{$store.name}</Heading>
</div>
<div class="topcenternav">
<Tabs {selected} size="M">
{#each $layout.children as { path, title }}
<TourWrap tourStepKey={`builder-${title}-section`}>
<Tab
quiet
selected={$isActive(path)}
on:click={topItemNavigate(path)}
title={capitalise(title)}
id={`builder-${title}-tab`}
/>
</TourWrap>
{/each}
</Tabs>
</div>
<div class="toprightnav">
<AppActions {application} />
</div>
<slot />
</div>
{:catch error}
<p>Something went wrong: {error.message}</p>
{/await}
{#await promise}
<!-- This should probably be some kind of loading state? -->
<div class="loading" />
{:then _}
<slot />
{:catch error}
<p>Something went wrong: {error.message}</p>
{/await}
</div>
<svelte:window on:keydown={handleKeyDown} />
<Modal bind:this={commandPaletteModal}>
<CommandPalette />
</Modal>
<style>
.loading {
@ -251,10 +269,6 @@
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-xl);
}
.version {
margin-right: var(--spacing-s);
gap: var(--spacing-l);
}
</style>

View file

@ -34,8 +34,8 @@
{#if duplicates?.length}
<div class="alert-wrap">
<Banner type="warning" showCloseButton={false}>
{`Schema Invalid - There are duplicate auto column types defined in this schema.
Please delete the duplicate entries where appropriate: -
{`Schema Invalid - There are duplicate auto column types defined in this schema.
Please delete the duplicate entries where appropriate: -
${invalidColumnText.join(", ")}`}
</Banner>
</div>

View file

@ -10,6 +10,8 @@
getBindableProperties,
getComponentBindableProperties,
} from "builderStore/dataBinding"
import { ActionButton } from "@budibase/bbui"
import { capitalise } from "helpers"
$: componentInstance = $selectedComponent
$: componentDefinition = store.actions.components.getDefinition(
@ -25,32 +27,69 @@
)
$: isScreen = $selectedComponent?._id === $selectedScreen?.props._id
$: title = isScreen ? "Screen" : $selectedComponent?._instanceName
let section = "settings"
const tabs = ["settings", "styles", "conditions"]
$: id = $selectedComponent?._id
$: id, (section = tabs[0])
</script>
{#if $selectedComponent}
{#key $selectedComponent._id}
<Panel {title} icon={componentDefinition?.icon} borderLeft>
{#if componentDefinition?.info}
<ComponentInfoSection {componentDefinition} />
<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}
<ComponentInfoSection {componentDefinition} />
{/if}
<ComponentSettingsSection
{componentInstance}
{componentDefinition}
{bindings}
{componentBindings}
{isScreen}
/>
{/if}
{#if section == "styles"}
<DesignSection {componentInstance} {componentDefinition} {bindings} />
<CustomStylesSection
{componentInstance}
{componentDefinition}
{bindings}
/>
{/if}
{#if section == "conditions"}
<ConditionalUISection
{componentInstance}
{componentDefinition}
{bindings}
/>
{/if}
<ComponentSettingsSection
{componentInstance}
{componentDefinition}
{bindings}
{componentBindings}
{isScreen}
/>
<DesignSection {componentInstance} {componentDefinition} {bindings} />
<CustomStylesSection
{componentInstance}
{componentDefinition}
{bindings}
/>
<ConditionalUISection
{componentInstance}
{componentDefinition}
{bindings}
/>
</Panel>
{/key}
{/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 onboarding = false
let errors = {}
let loaded = false
$: company = $organisation.company || "Budibase"
@ -39,6 +40,11 @@
if (invite?.email) {
formData.email = invite?.email
}
if ($organisation.isSSOEnforced) {
// auto accept invite and redirect to login
await users.acceptInvite(inviteCode)
$goto("../auth")
}
} catch (error) {
notifications.error(error.message)
}
@ -61,130 +67,135 @@
try {
await organisation.init()
await getInvite()
loaded = true
} catch (error) {
notifications.error("Error getting invite config")
}
})
</script>
<TestimonialPage>
<Layout gap="M" noPadding>
<img alt="logo" src={$organisation.logoUrl || Logo} />
<Layout gap="XS" noPadding>
<Heading size="M">Join {company}</Heading>
<Body size="M">Create your account to access your budibase apps!</Body>
</Layout>
{#if loaded}
<TestimonialPage>
<Layout gap="M" noPadding>
<img alt="logo" src={$organisation.logoUrl || Logo} />
<Layout gap="XS" noPadding>
<Heading size="M">Join {company}</Heading>
<Body size="M">Create your account to access your budibase apps!</Body>
</Layout>
<Layout gap="S" noPadding>
<FancyForm bind:this={form}>
<FancyInput
label="Email"
value={formData.email}
disabled={true}
error={errors.email}
/>
<FancyInput
label="First name"
value={formData.firstName}
on:change={e => {
formData = {
...formData,
firstName: e.detail,
}
}}
validate={() => {
let fieldError = {
firstName: !formData.firstName
? "Please enter your first name"
: undefined,
}
errors = handleError({ ...errors, ...fieldError })
}}
error={errors.firstName}
disabled={onboarding}
/>
<FancyInput
label="Last name (optional)"
value={formData.lastName}
on:change={e => {
formData = {
...formData,
lastName: e.detail,
}
}}
disabled={onboarding}
/>
<FancyInput
label="Password"
value={formData.password}
type="password"
on:change={e => {
formData = {
...formData,
password: e.detail,
}
}}
validate={() => {
let fieldError = {}
fieldError["password"] = !formData.password
? "Please enter a password"
: undefined
fieldError["confirmationPassword"] =
!passwordsMatch(
formData.password,
formData.confirmationPassword
) && formData.confirmationPassword
? "Passwords must match"
: undefined
errors = handleError({ ...errors, ...fieldError })
}}
error={errors.password}
disabled={onboarding}
/>
<FancyInput
label="Repeat password"
value={formData.confirmationPassword}
type="password"
on:change={e => {
formData = {
...formData,
confirmationPassword: e.detail,
}
}}
validate={() => {
let fieldError = {
confirmationPassword:
!passwordsMatch(
formData.password,
formData.confirmationPassword
) && formData.password
? "Passwords must match"
<Layout gap="S" noPadding>
<FancyForm bind:this={form}>
<FancyInput
label="Email"
value={formData.email}
disabled={true}
error={errors.email}
/>
<FancyInput
label="First name"
value={formData.firstName}
on:change={e => {
formData = {
...formData,
firstName: e.detail,
}
}}
validate={() => {
let fieldError = {
firstName: !formData.firstName
? "Please enter your first name"
: undefined,
}
}
errors = handleError({ ...errors, ...fieldError })
}}
error={errors.confirmationPassword}
disabled={onboarding}
/>
</FancyForm>
errors = handleError({ ...errors, ...fieldError })
}}
error={errors.firstName}
disabled={onboarding}
/>
<FancyInput
label="Last name (optional)"
value={formData.lastName}
on:change={e => {
formData = {
...formData,
lastName: e.detail,
}
}}
disabled={onboarding}
/>
{#if !$organisation.isSSOEnforced}
<FancyInput
label="Password"
value={formData.password}
type="password"
on:change={e => {
formData = {
...formData,
password: e.detail,
}
}}
validate={() => {
let fieldError = {}
fieldError["password"] = !formData.password
? "Please enter a password"
: undefined
fieldError["confirmationPassword"] =
!passwordsMatch(
formData.password,
formData.confirmationPassword
) && formData.confirmationPassword
? "Passwords must match"
: undefined
errors = handleError({ ...errors, ...fieldError })
}}
error={errors.password}
disabled={onboarding}
/>
<FancyInput
label="Repeat password"
value={formData.confirmationPassword}
type="password"
on:change={e => {
formData = {
...formData,
confirmationPassword: e.detail,
}
}}
validate={() => {
let fieldError = {
confirmationPassword:
!passwordsMatch(
formData.password,
formData.confirmationPassword
) && formData.password
? "Passwords must match"
: undefined,
}
errors = handleError({ ...errors, ...fieldError })
}}
error={errors.confirmationPassword}
disabled={onboarding}
/>
{/if}
</FancyForm>
</Layout>
<div>
<Button
size="L"
disabled={Object.keys(errors).length > 0 || onboarding}
cta
on:click={acceptInvite}
>
Create account
</Button>
</div>
</Layout>
<div>
<Button
size="L"
disabled={Object.keys(errors).length > 0 || onboarding}
cta
on:click={acceptInvite}
>
Create account
</Button>
</div>
</Layout>
</TestimonialPage>
</TestimonialPage>
{/if}
<style>
img {

View file

@ -1,11 +1,11 @@
<script>
import { Button } from "@budibase/bbui"
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"
</script>
{#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING)}
{#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING) && !$licensing.isEnterprisePlan}
{#if $admin.cloud && $auth?.user?.accountPortalAccess}
<Button
cta

View file

@ -12,18 +12,20 @@
}
</script>
<div
class="container"
on:mouseover={() => (showTooltip = true)}
on:focus={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
>
<Avatar size="M" initials={getInitials(row?.user)} />
</div>
{#if showTooltip}
<div class="tooltip">
<Tooltip textWrapping text={row?.user.email} direction="bottom" />
{#if row?.user?.email}
<div
class="container"
on:mouseover={() => (showTooltip = true)}
on:focus={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
>
<Avatar size="M" initials={getInitials(row.user)} />
</div>
{#if showTooltip}
<div class="tooltip">
<Tooltip textWrapping text={row.user.email} direction="bottom" />
</div>
{/if}
{/if}
<style>

View file

@ -257,6 +257,7 @@
<div class="select">
<Multiselect
bind:fetchTerm={userSearchTerm}
useFetch
placeholder="All users"
label="Users"
autocomplete

View file

@ -131,24 +131,25 @@
isEqual(providers.google?.config, originalGoogleDoc?.config)
? (googleSaveButtonDisabled = true)
: (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)
? (oidcSaveButtonDisabled = true)
: (oidcSaveButtonDisabled = false)
}
// Create a flag so that it will only try to save completed forms
$: 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 =
$: googleComplete = !!(
providers.google?.config?.clientID && providers.google?.config?.clientSecret
$: oidcComplete =
)
$: oidcComplete = !!(
providers.oidc?.config?.configs[0].configUrl &&
providers.oidc?.config?.configs[0].clientID &&
providers.oidc?.config?.configs[0].clientSecret
)
const onFileSelected = e => {
let fileName = e.target.files[0].name
@ -159,74 +160,88 @@
async function toggleIsSSOEnforced() {
const value = $organisation.isSSOEnforced
await organisation.save({ isSSOEnforced: !value })
try {
await organisation.save({ isSSOEnforced: !value })
} catch (e) {
notifications.error(e.message)
}
}
async function save(docs) {
let calls = []
// Only if the user has provided an image, upload it
async function saveConfig(config) {
// Delete unsupported fields
delete config.createdAt
delete config.updatedAt
return API.saveConfig(config)
}
async function saveOIDCLogo() {
if (image) {
let data = new FormData()
data.append("file", image)
calls.push(
API.uploadOIDCLogo({
name: image.name,
data,
})
await API.uploadOIDCLogo({
name: image.name,
data,
})
}
}
async function saveOIDC() {
if (!oidcComplete) {
notifications.error(
`Please fill in all required ${ConfigTypes.OIDC} fields`
)
return
}
docs.forEach(element => {
// Delete unsupported fields
delete element.createdAt
delete element.updatedAt
const { activated } = element.config
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
for (let config of element.config.configs) {
if (!config.uuid) {
config.uuid = Helpers.uuid()
}
// Callback urls shouldn't be included
delete config.callbackURL
}
if ((partialOidc || activated) && !oidcComplete) {
notifications.error(
`Please fill in all required ${ConfigTypes.OIDC} fields`
)
} else if (oidcComplete || !activated) {
calls.push(API.saveConfig(element))
// Turn the save button grey when clicked
oidcSaveButtonDisabled = true
originalOidcDoc = cloneDeep(providers.oidc)
}
// Add a UUID here so each config is distinguishable when it arrives at the login page
for (let config of oidc.config.configs) {
if (!config.uuid) {
config.uuid = Helpers.uuid()
}
if (element.type === ConfigTypes.Google) {
if ((partialGoogle || activated) && !googleComplete) {
notifications.error(
`Please fill in all required ${ConfigTypes.Google} fields`
)
} else if (googleComplete || !activated) {
calls.push(API.saveConfig(element))
googleSaveButtonDisabled = true
originalGoogleDoc = cloneDeep(providers.google)
}
}
})
if (calls.length) {
Promise.all(calls)
.then(data => {
data.forEach(res => {
providers[res.type]._rev = res._rev
providers[res.type]._id = res._id
})
notifications.success(`Settings saved`)
})
.catch(() => {
notifications.error("Failed to update auth settings")
})
// Callback urls shouldn't be included
delete config.callbackURL
}
try {
const res = await saveConfig(oidc)
providers[res.type]._rev = res._rev
providers[res.type]._id = res._id
await saveOIDCLogo()
notifications.success(`Settings saved`)
} catch (e) {
notifications.error(e.message)
return
}
// Turn the save button grey when clicked
oidcSaveButtonDisabled = true
originalOidcDoc = cloneDeep(providers.oidc)
}
async function saveGoogle() {
if (!googleComplete) {
notifications.error(
`Please fill in all required ${ConfigTypes.Google} fields`
)
return
}
const google = providers.google
try {
const res = await saveConfig(google)
providers[res.type]._rev = res._rev
providers[res.type]._id = res._id
notifications.success(`Settings saved`)
} catch (e) {
notifications.error(e.message)
return
}
googleSaveButtonDisabled = true
originalGoogleDoc = cloneDeep(providers.google)
}
let defaultScopes = ["profile", "email", "offline_access"]
@ -266,7 +281,7 @@
if (!googleDoc?._id) {
providers.google = {
type: ConfigTypes.Google,
config: { activated: true },
config: { activated: false },
}
originalGoogleDoc = cloneDeep(googleDoc)
} else {
@ -290,14 +305,17 @@
}
if (oidcLogos?.config) {
const logoKeys = Object.keys(oidcLogos.config)
logoKeys.map(logoKey => {
const logoUrl = oidcLogos.config[logoKey]
iconDropdownOptions.unshift({
label: logoKey,
value: logoKey,
icon: logoUrl,
logoKeys
// don't include the etag entry in the logo config
.filter(key => !key.toLowerCase().includes("etag"))
.map(logoKey => {
const logoUrl = oidcLogos.config[logoKey]
iconDropdownOptions.unshift({
label: logoKey,
value: logoKey,
icon: logoUrl,
})
})
})
}
// Fetch OIDC config
@ -310,7 +328,7 @@
if (!oidcDoc?._id) {
providers.oidc = {
type: ConfigTypes.OIDC,
config: { configs: [{ activated: true, scopes: defaultScopes }] },
config: { configs: [{ activated: false, scopes: defaultScopes }] },
}
} else {
originalOidcDoc = cloneDeep(oidcDoc)
@ -350,7 +368,7 @@
</div>
{#if !$licensing.enforceableSSO}
<Tags>
<Tag icon="LockClosed">Business plan</Tag>
<Tag icon="LockClosed">Enterprise plan</Tag>
</Tags>
{/if}
</div>
@ -413,7 +431,7 @@
<Button
disabled={googleSaveButtonDisabled}
cta
on:click={() => save([providers.google])}
on:click={() => saveGoogle()}
>
Save
</Button>
@ -469,6 +487,7 @@
<Select
label=""
bind:value={providers.oidc.config.configs[0].logo}
useOptionIconImage
options={iconDropdownOptions}
on:change={e => e.detail === "Upload" && fileinput.click()}
/>
@ -575,11 +594,7 @@
</div>
</Layout>
<div>
<Button
disabled={oidcSaveButtonDisabled}
cta
on:click={() => save([providers.oidc])}
>
<Button disabled={oidcSaveButtonDisabled} cta on:click={() => saveOIDC()}>
Save
</Button>
</div>

View file

@ -1,9 +1,8 @@
<script>
import { ModalContent, Body, Layout, Icon } from "@budibase/bbui"
import { OnboardingType } from "../../../../../../constants"
export let chooseCreationType
let emailOnboardingKey = "emailOnboarding"
let basicOnboaridngKey = "basicOnboarding"
let selectedOnboardingType
</script>
@ -20,9 +19,9 @@
<Layout noPadding gap="S">
<div
class="onboarding-type item"
class:selected={selectedOnboardingType == emailOnboardingKey}
class:selected={selectedOnboardingType == OnboardingType.EMAIL}
on:click={() => {
selectedOnboardingType = emailOnboardingKey
selectedOnboardingType = OnboardingType.EMAIL
}}
>
<div class="content onboarding-type-wrap">
@ -32,7 +31,7 @@
</div>
</div>
<div style="color: var(--spectrum-global-color-green-600); float: right">
{#if selectedOnboardingType == emailOnboardingKey}
{#if selectedOnboardingType == OnboardingType.EMAIL}
<div class="checkmark-spacing">
<Icon size="S" name="CheckmarkCircle" />
</div>
@ -42,9 +41,9 @@
<div
class="onboarding-type item"
class:selected={selectedOnboardingType == basicOnboaridngKey}
class:selected={selectedOnboardingType == OnboardingType.PASSWORD}
on:click={() => {
selectedOnboardingType = basicOnboaridngKey
selectedOnboardingType = OnboardingType.PASSWORD
}}
>
<div class="content onboarding-type-wrap">
@ -54,7 +53,7 @@
</div>
</div>
<div style="color: var(--spectrum-global-color-green-600); float: right">
{#if selectedOnboardingType == basicOnboaridngKey}
{#if selectedOnboardingType == OnboardingType.PASSWORD}
<div class="checkmark-spacing">
<Icon size="S" name="CheckmarkCircle" />
</div>

View file

@ -13,7 +13,7 @@
Divider,
} from "@budibase/bbui"
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 DeleteRowsButton from "components/backend/DataTable/buttons/DeleteRowsButton.svelte"
import GroupsTableRenderer from "./_components/GroupsTableRenderer.svelte"
@ -27,6 +27,7 @@
import { get } from "svelte/store"
import { Constants, Utils, fetchData } from "@budibase/frontend-core"
import { API } from "api"
import { OnboardingType } from "../../../../../constants"
const fetch = fetchData({
API,
@ -105,10 +106,18 @@
const debouncedUpdateFetch = Utils.debounce(updateFetch, 250)
const showOnboardingTypeModal = async addUsersData => {
// no-op if users already exist
userData = await removingDuplicities(addUsersData)
if (!userData?.users?.length) return
if (!userData?.users?.length) {
return
}
onboardingTypeModal.show()
if ($organisation.isSSOEnforced) {
// bypass the onboarding type selection of sso is enforced
await chooseCreationType(OnboardingType.EMAIL)
} else {
onboardingTypeModal.show()
}
}
async function createUserFlow() {
@ -181,7 +190,7 @@
}
async function chooseCreationType(onboardingType) {
if (onboardingType === "emailOnboarding") {
if (onboardingType === OnboardingType.EMAIL) {
await createUserFlow()
} else {
await createUsers()

View file

@ -154,9 +154,14 @@ export function createAuthStore() {
await setInitInfo({})
},
updateSelf: async fields => {
const newUser = { ...get(auth).user, ...fields }
await API.updateSelf(newUser)
setUser(newUser)
await API.updateSelf({ ...fields })
// Refetch to enrich after update.
try {
const user = await API.fetchBuilderSelf()
setUser(user)
} catch (error) {
setUser(null)
}
},
forgotPassword: async email => {
const tenantId = get(store).tenantId

View file

@ -12,6 +12,7 @@ export const createLicensingStore = () => {
// the top level license
license: undefined,
isFreePlan: true,
isEnterprisePlan: true,
// features
groupsEnabled: false,
backupsEnabled: false,
@ -53,7 +54,9 @@ export const createLicensingStore = () => {
},
setLicense: () => {
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(
Constants.Features.USER_GROUPS
)
@ -74,6 +77,7 @@ export const createLicensingStore = () => {
return {
...state,
license,
isEnterprisePlan,
isFreePlan,
groupsEnabled,
backupsEnabled,

View file

@ -75,11 +75,13 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
title: "Usage",
href: "/builder/portal/account/usage",
},
{
]
if ($auth.isAdmin) {
accountSubPages.push({
title: "Audit Logs",
href: "/builder/portal/account/auditLogs",
},
]
})
}
if ($admin.cloud && $auth?.user?.accountPortalAccess) {
accountSubPages.push({
title: "Upgrade",

View file

@ -1,6 +1,7 @@
import { writable, get } from "svelte/store"
import { API } from "api"
import { auth } from "stores/portal"
import _ from "lodash"
const DEFAULT_CONFIG = {
platformUrl: "",
@ -26,14 +27,14 @@ export function createOrganisationStore() {
async function save(config) {
// Delete non-persisted fields
const storeConfig = get(store)
const storeConfig = _.cloneDeep(get(store))
delete storeConfig.oidc
delete storeConfig.google
delete storeConfig.oidcCallbackUrl
delete storeConfig.googleCallbackUrl
await API.saveConfig({
type: "settings",
config: { ...get(store), ...config },
config: { ...storeConfig, ...config },
})
await init()
}

View file

@ -26,9 +26,15 @@ export function createUsersStore() {
return await API.getUsers()
}
// One or more users.
async function onboard(payload) {
return await API.onboardUsers(payload)
}
async function invite(payload) {
return API.inviteUsers(payload)
}
async function acceptInvite(inviteCode, password, firstName, lastName) {
return API.acceptInvite({
inviteCode,
@ -42,6 +48,14 @@ export function createUsersStore() {
return API.getUserInvite(inviteCode)
}
async function getInvites() {
return API.getUserInvites()
}
async function updateInvite(invite) {
return API.updateUserInvite(invite)
}
async function create(data) {
let mappedUsers = data.users.map(user => {
const body = {
@ -106,8 +120,11 @@ export function createUsersStore() {
getUserRole,
fetch,
invite,
onboard,
acceptInvite,
fetchInvite,
getInvites,
updateInvite,
create,
save,
bulkDelete,

View file

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

View file

@ -1,16 +1,19 @@
{
"name": "@budibase/cli",
"version": "2.3.18-alpha.15",
"version": "2.4.12-alpha.0",
"description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js",
"main": "dist/index.js",
"bin": {
"budi": "src/index.js"
"budi": "dist/index.js"
},
"author": "Budibase",
"license": "GPL-3.0",
"scripts": {
"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"
},
"pkg": {
@ -26,21 +29,21 @@
"outputPath": "build"
},
"dependencies": {
"@budibase/backend-core": "2.3.18-alpha.15",
"@budibase/string-templates": "2.3.18-alpha.15",
"@budibase/types": "2.3.18-alpha.15",
"@budibase/backend-core": "2.4.12-alpha.0",
"@budibase/string-templates": "2.4.12-alpha.0",
"@budibase/types": "2.4.12-alpha.0",
"axios": "0.21.2",
"chalk": "4.1.0",
"cli-progress": "3.11.2",
"commander": "7.1.0",
"docker-compose": "0.23.6",
"docker-compose": "0.23.12",
"dotenv": "16.0.1",
"download": "8.0.0",
"find-free-port": "^2.0.0",
"inquirer": "8.0.0",
"joi": "17.6.0",
"lookpath": "1.1.0",
"node-fetch": "2",
"node-fetch": "2.6.7",
"pkg": "5.8.0",
"posthog-node": "1.0.7",
"pouchdb": "7.3.0",
@ -50,8 +53,15 @@
"yaml": "^2.1.1"
},
"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",
"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")
const { CommandWords } = require("../constants")
const { success, error } = require("../utils")
const AnalyticsClient = require("./Client")
import { Command } from "../structures/Command"
import { CommandWord } from "../constants"
import { success, error } from "../utils"
import { AnalyticsClient } from "./Client"
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'"
)
)
} catch (err) {
} catch (err: any) {
console.log(
error(
"Error opting out of Budibase analytics. Please try again later.",
err
`Error opting out of Budibase analytics. Please try again later - ${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.")
.addSubOption("--optin", "Opt in to sending analytics to Budibase", optIn)
.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.",
status
)
exports.command = command

View file

@ -1,28 +1,30 @@
const Command = require("../structures/Command")
const { CommandWords } = require("../constants")
const fs = require("fs")
const { join } = require("path")
const { getAllDbs } = require("../core/db")
const tar = require("tar")
const { progressBar, httpCall } = require("../utils")
const {
import { Command } from "../structures/Command"
import { CommandWord } from "../constants"
import fs from "fs"
import { join } from "path"
import { getAllDbs } from "../core/db"
import { progressBar, httpCall } from "../utils"
import {
TEMP_DIR,
COUCH_DIR,
MINIO_DIR,
getConfig,
replication,
getPouches,
} = require("./utils")
const { exportObjects, importObjects } = require("./objectStore")
} from "./utils"
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
let filename = opts["export"] || opts
let filename = opts["export"] || (opts as string)
if (typeof filename !== "string") {
filename = `backup-${new Date().toISOString()}.tar.gz`
}
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)
if (fs.existsSync(TEMP_DIR)) {
fs.rmSync(TEMP_DIR, { recursive: true })
@ -55,9 +57,9 @@ async function exportBackup(opts) {
console.log(`Generated export file - ${filename}`)
}
async function importBackup(opts) {
async function importBackup(opts: BackupOpts) {
const envFile = opts.env || undefined
const filename = opts["import"] || opts
const filename = opts["import"] || (opts as string)
const config = await getConfig(envFile)
if (!filename || !fs.existsSync(filename)) {
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 })
}
async function pickOne(opts) {
async function pickOne(opts: BackupOpts) {
if (opts["import"]) {
return importBackup(opts)
} 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(
"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.",
pickOne
)
exports.command = command

View file

@ -1,8 +1,8 @@
const { objectStore } = require("@budibase/backend-core")
const fs = require("fs")
const { join } = require("path")
const { TEMP_DIR, MINIO_DIR } = require("./utils")
const { progressBar } = require("../utils")
import { objectStore } from "@budibase/backend-core"
import fs from "fs"
import { join } from "path"
import { TEMP_DIR, MINIO_DIR } from "./utils"
import { progressBar } from "../utils"
const {
ObjectStoreBuckets,
ObjectStore,
@ -13,10 +13,10 @@ const {
const bucketList = Object.values(ObjectStoreBuckets)
exports.exportObjects = async () => {
export async function exportObjects() {
const path = join(TEMP_DIR, MINIO_DIR)
fs.mkdirSync(path)
let fullList = []
let fullList: any[] = []
let errorCount = 0
for (let bucket of bucketList) {
const client = ObjectStore(bucket)
@ -26,7 +26,7 @@ exports.exportObjects = async () => {
errorCount++
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 })))
}
if (errorCount === bucketList.length) {
@ -48,7 +48,7 @@ exports.exportObjects = async () => {
bar.stop()
}
exports.importObjects = async () => {
export async function importObjects() {
const path = join(TEMP_DIR, MINIO_DIR)
const buckets = fs.readdirSync(path)
let total = 0

View file

@ -1,12 +1,13 @@
const dotenv = require("dotenv")
const fs = require("fs")
const { string } = require("../questions")
const { getPouch } = require("../core/db")
const { env: environment } = require("@budibase/backend-core")
import dotenv from "dotenv"
import fs from "fs"
import { string } from "../questions"
import { getPouch } from "../core/db"
import { env as environment } from "@budibase/backend-core"
import PouchDB from "pouchdb"
exports.TEMP_DIR = ".temp"
exports.COUCH_DIR = "couchdb"
exports.MINIO_DIR = "minio"
export const TEMP_DIR = ".temp"
export const COUCH_DIR = "couchdb"
export const MINIO_DIR = "minio"
const REQUIRED = [
{ value: "MAIN_PORT", default: "10000" },
@ -19,7 +20,7 @@ const REQUIRED = [
{ value: "MINIO_SECRET_KEY" },
]
exports.checkURLs = config => {
export function checkURLs(config: Record<string, string>) {
const mainPort = config["MAIN_PORT"],
username = config["COUCH_DB_USER"],
password = config["COUCH_DB_PASSWORD"]
@ -34,23 +35,23 @@ exports.checkURLs = config => {
return config
}
exports.askQuestions = async () => {
export async function askQuestions() {
console.log(
"*** NOTE: use a .env file to load these parameters repeatedly ***"
)
let config = {}
let config: Record<string, string> = {}
for (let property of REQUIRED) {
config[property.value] = await string(property.value, property.default)
}
return config
}
exports.loadEnvironment = path => {
export function loadEnvironment(path: string) {
if (!fs.existsSync(path)) {
throw "Unable to file specified .env file"
}
const env = fs.readFileSync(path, "utf8")
const config = exports.checkURLs(dotenv.parse(env))
const config = checkURLs(dotenv.parse(env))
for (let required of REQUIRED) {
if (!config[required.value]) {
throw `Cannot find "${required.value}" property in .env file`
@ -60,12 +61,12 @@ exports.loadEnvironment = path => {
}
// true is the default value passed by commander
exports.getConfig = async (envFile = true) => {
export async function getConfig(envFile: boolean | string = true) {
let config
if (envFile !== true) {
config = exports.loadEnvironment(envFile)
config = loadEnvironment(envFile as string)
} else {
config = await exports.askQuestions()
config = await askQuestions()
}
// fill out environment
for (let key of Object.keys(config)) {
@ -74,12 +75,16 @@ exports.getConfig = async (envFile = true) => {
return config
}
exports.replication = async (from, to) => {
export async function replication(
from: PouchDB.Database,
to: PouchDB.Database
) {
const pouch = getPouch()
try {
await pouch.replicate(from, to, {
batch_size: 1000,
batch_limit: 5,
batches_limit: 5,
// @ts-ignore
style: "main_only",
})
} 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 Local = getPouch()
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")
const { checkSlashesInUrl } = require("../utils")
const fetch = require("node-fetch")
import PouchDB from "pouchdb"
import { checkSlashesInUrl } from "../utils"
import fetch from "node-fetch"
/**
* Fully qualified URL including username and password, or nothing for local
*/
exports.getPouch = (url = undefined) => {
let POUCH_DB_DEFAULTS = {}
export function getPouch(url?: string) {
let POUCH_DB_DEFAULTS
if (!url) {
POUCH_DB_DEFAULTS = {
prefix: undefined,
@ -19,11 +19,12 @@ exports.getPouch = (url = undefined) => {
}
const replicationStream = require("pouchdb-replication-stream")
PouchDB.plugin(replicationStream.plugin)
// @ts-ignore
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(
checkSlashesInUrl(encodeURI(`${url}/_all_dbs`)),
{

View file

@ -1,2 +1,3 @@
process.env.NO_JS = "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")
const exec = util.promisify(require("child_process").exec)
import util from "util"
const runCommand = util.promisify(require("child_process").exec)
exports.exec = async (command, dir = "./") => {
const { stdout } = await exec(command, { cwd: dir })
export async function exec(command: string, dir = "./") {
const { stdout } = await runCommand(command, { cwd: dir })
return stdout
}
exports.utilityInstalled = async utilName => {
export async function utilityInstalled(utilName: string) {
try {
await exports.exec(`${utilName} --version`)
await exec(`${utilName} --version`)
return true
} catch (err) {
return false
}
}
exports.runPkgCommand = async (command, dir = "./") => {
export async function runPkgCommand(command: string, dir = "./") {
const yarn = await exports.utilityInstalled("yarn")
const npm = await exports.utilityInstalled("npm")
if (!yarn && !npm) {

View file

@ -2,15 +2,16 @@ const { success } = require("../utils")
const { updateDockerComposeService } = require("./utils")
const randomString = require("randomstring")
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
if (!password) {
password = randomString.generate({ length: 6 })
}
updateDockerComposeService(service => {
updateDockerComposeService((service: DockerCompose) => {
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) {
console.log(

View file

@ -1,14 +1,14 @@
const Command = require("../structures/Command")
const { CommandWords } = require("../constants")
const { init } = require("./init")
const { start } = require("./start")
const { stop } = require("./stop")
const { status } = require("./status")
const { update } = require("./update")
const { generateUser } = require("./genUser")
const { watchPlugins } = require("./watch")
import { Command } from "../structures/Command"
import { CommandWord } from "../constants"
import { init } from "./init"
import { start } from "./start"
import { stop } from "./stop"
import { status } from "./status"
import { update } from "./update"
import { generateUser } from "./genUser"
import { watchPlugins } from "./watch"
const command = new Command(`${CommandWords.HOSTING}`)
export default new Command(`${CommandWord.HOSTING}`)
.addHelp("Controls self hosting on the Budibase platform.")
.addSubOption(
"--init [type]",
@ -46,5 +46,3 @@ const command = new Command(`${CommandWords.HOSTING}`)
generateUser
)
.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")
const { confirmation } = require("../questions")
const { captureEvent } = require("../events")
const makeFiles = require("./makeFiles")
const axios = require("axios")
const { parseEnv } = require("../utils")
const { checkDockerConfigured, downloadFiles } = require("./utils")
const { watchPlugins } = require("./watch")
const { generateUser } = require("./genUser")
import { InitType, AnalyticsEvent } from "../constants"
import { confirmation } from "../questions"
import { captureEvent } from "../events"
import * as makeFiles from "./makeFiles"
import { parseEnv } from "../utils"
import { checkDockerConfigured, downloadDockerCompose } from "./utils"
import { watchPlugins } from "./watch"
import { generateUser } from "./genUser"
import fetch from "node-fetch"
const DO_USER_DATA_URL = "http://169.254.169.254/metadata/v1/user-data"
async function getInitConfig(type, isQuick, port) {
const config = isQuick ? makeFiles.QUICK_CONFIG : {}
if (type === InitTypes.DIGITAL_OCEAN) {
async function getInitConfig(type: string, isQuick: boolean, port: number) {
const config: any = isQuick ? makeFiles.QUICK_CONFIG : {}
if (type === InitType.DIGITAL_OCEAN) {
try {
const output = await axios.get(DO_USER_DATA_URL)
const response = parseEnv(output.data)
const output = await fetch(DO_USER_DATA_URL)
const data = await output.text()
const response = parseEnv(data)
for (let [key, value] of Object.entries(makeFiles.ConfigMap)) {
if (response[key]) {
config[value] = response[key]
config[value as string] = response[key]
}
}
} catch (err) {
@ -32,7 +33,7 @@ async function getInitConfig(type, isQuick, port) {
return config
}
exports.init = async opts => {
export async function init(opts: any) {
let type, isSingle, watchDir, genUser, port, silent
if (typeof opts === "string") {
type = opts
@ -44,7 +45,7 @@ exports.init = async opts => {
port = opts["port"]
silent = opts["silent"]
}
const isQuick = type === InitTypes.QUICK || type === InitTypes.DIGITAL_OCEAN
const isQuick = type === InitType.QUICK || type === InitType.DIGITAL_OCEAN
await checkDockerConfigured()
if (!isQuick) {
const shouldContinue = await confirmation(
@ -55,12 +56,12 @@ exports.init = async opts => {
return
}
}
captureEvent(AnalyticsEvents.SelfHostInit, {
captureEvent(AnalyticsEvent.SelfHostInit, {
type,
})
const config = await getInitConfig(type, isQuick, port)
if (!isSingle) {
await downloadFiles()
await downloadDockerCompose()
await makeFiles.makeEnv(config, silent)
} else {
await makeFiles.makeSingleCompose(config, silent)

View file

@ -1,15 +1,15 @@
const { number } = require("../questions")
const { success, stringifyToDotEnv } = require("../utils")
const fs = require("fs")
const path = require("path")
import { number } from "../questions"
import { success, stringifyToDotEnv } from "../utils"
import fs from "fs"
import path from "path"
import yaml from "yaml"
import { getAppService } from "./utils"
const randomString = require("randomstring")
const yaml = require("yaml")
const { getAppService } = require("./utils")
const SINGLE_IMAGE = "budibase/budibase:latest"
const VOL_NAME = "budibase_data"
const COMPOSE_PATH = path.resolve("./docker-compose.yaml")
const ENV_PATH = path.resolve("./.env")
export const COMPOSE_PATH = path.resolve("./docker-compose.yaml")
export const ENV_PATH = path.resolve("./.env")
function getSecrets(opts = { single: false }) {
const secrets = [
@ -19,7 +19,7 @@ function getSecrets(opts = { single: false }) {
"REDIS_PASSWORD",
"INTERNAL_API_KEY",
]
const obj = {}
const obj: Record<string, string> = {}
secrets.forEach(secret => (obj[secret] = randomString.generate()))
// setup couch creds separately
if (opts && opts.single) {
@ -32,7 +32,7 @@ function getSecrets(opts = { single: false }) {
return obj
}
function getSingleCompose(port) {
function getSingleCompose(port: number) {
const singleComposeObj = {
version: "3",
services: {
@ -53,7 +53,7 @@ function getSingleCompose(port) {
return yaml.stringify(singleComposeObj)
}
function getEnv(port) {
function getEnv(port: number) {
const partOne = stringifyToDotEnv({
MAIN_PORT: port,
})
@ -77,19 +77,21 @@ function getEnv(port) {
].join("\n")
}
exports.ENV_PATH = ENV_PATH
exports.COMPOSE_PATH = COMPOSE_PATH
module.exports.ConfigMap = {
export const ConfigMap = {
MAIN_PORT: "port",
}
module.exports.QUICK_CONFIG = {
export const QUICK_CONFIG = {
key: "budibase",
port: 10000,
}
async function make(path, contentsFn, inputs = {}, silent) {
async function make(
path: string,
contentsFn: Function,
inputs: any = {},
silent: boolean
) {
const port =
inputs.port ||
(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)
}
module.exports.makeSingleCompose = async (inputs = {}, silent) => {
export async function makeSingleCompose(inputs: any = {}, silent: boolean) {
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)
if (props[0].charAt(0) === "=") {
property = props[0]
@ -125,7 +127,7 @@ module.exports.getEnvProperty = property => {
return property.split("=")[1].split("\n")[0]
}
module.exports.getComposeProperty = property => {
export function getComposeProperty(property: string) {
const { service } = getAppService(COMPOSE_PATH)
if (property === "port" && Array.isArray(service.ports)) {
const port = service.ports[0]

View file

@ -1,14 +1,10 @@
const {
checkDockerConfigured,
checkInitComplete,
handleError,
} = require("./utils")
const { info, success } = require("../utils")
const makeFiles = require("./makeFiles")
const compose = require("docker-compose")
const fs = require("fs")
import { checkDockerConfigured, checkInitComplete, handleError } from "./utils"
import { info, success } from "../utils"
import * as makeFiles from "./makeFiles"
import compose from "docker-compose"
import fs from "fs"
exports.start = async () => {
export async function start() {
await checkDockerConfigured()
checkInitComplete()
console.log(

View file

@ -1,12 +1,8 @@
const {
checkDockerConfigured,
checkInitComplete,
handleError,
} = require("./utils")
const { info } = require("../utils")
const compose = require("docker-compose")
import { checkDockerConfigured, checkInitComplete, handleError } from "./utils"
import { info } from "../utils"
import compose from "docker-compose"
exports.status = async () => {
export async function status() {
await checkDockerConfigured()
checkInitComplete()
console.log(info("Budibase status"))

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