diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index e940e6fa10..42a0c0a273 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -59,3 +59,9 @@ jobs: with: install: false command: yarn test:e2e:ci + + - name: QA Core Integration Tests + run: | + cd qa-core + yarn + yarn api:test:ci \ No newline at end of file diff --git a/.gitignore b/.gitignore index 32c6faf980..e1d3e6db0e 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,7 @@ typings/ # dotenv environment variables file .env +!qa-core/.env !hosting/.env hosting/.generated-nginx.dev.conf hosting/proxy/.generated-nginx.prod.conf diff --git a/.prettierignore b/.prettierignore index bbeff65da7..ad36a86b99 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,4 +8,4 @@ packages/server/client packages/server/src/definitions/openapi.ts packages/builder/.routify packages/builder/cypress/support/queryLevelTransformerFunction.js -packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js +packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js \ No newline at end of file diff --git a/README.md b/README.md index 1dec1737da..bd38610566 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Budibase is open-source - licensed as GPL v3. This should fill you with confiden

### Load data or start from scratch -Budibase pulls in data from multiple sources, including MongoDB, CouchDB, PostgreSQL, MySQL, Airtable, S3, DynamoDB, or a REST API. And unlike other platforms, with Budibase you can start from scratch and create business apps with no data sources. [Request new data sources](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas). +Budibase pulls in data from multiple sources, including MongoDB, CouchDB, PostgreSQL, MySQL, Airtable, S3, DynamoDB, or a REST API. And unlike other platforms, with Budibase you can start from scratch and create business apps with no datasources. [Request new datasources](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).

Budibase data diff --git a/examples/nextjs-api-sales/definitions/openapi.ts b/examples/nextjs-api-sales/definitions/openapi.ts index 4f4ad45fc6..7f7f6befec 100644 --- a/examples/nextjs-api-sales/definitions/openapi.ts +++ b/examples/nextjs-api-sales/definitions/openapi.ts @@ -348,7 +348,7 @@ export interface paths { } } responses: { - /** Returns the created table, including the ID which has been generated for it. This can be internal or external data sources. */ + /** Returns the created table, including the ID which has been generated for it. This can be internal or external datasources. */ 200: { content: { "application/json": components["schemas"]["tableOutput"] @@ -959,7 +959,7 @@ export interface components { query: { /** @description The ID of the query. */ _id: string - /** @description The ID of the data source the query belongs to. */ + /** @description The ID of the datasource the query belongs to. */ datasourceId?: string /** @description The bindings which are required to perform this query. */ parameters?: string[] @@ -983,7 +983,7 @@ export interface components { data: { /** @description The ID of the query. */ _id: string - /** @description The ID of the data source the query belongs to. */ + /** @description The ID of the datasource the query belongs to. */ datasourceId?: string /** @description The bindings which are required to perform this query. */ parameters?: string[] diff --git a/hosting/scripts/build-target-paths.sh b/hosting/scripts/build-target-paths.sh index fce768e2ee..c974d9a304 100644 --- a/hosting/scripts/build-target-paths.sh +++ b/hosting/scripts/build-target-paths.sh @@ -9,7 +9,11 @@ if [[ "${TARGETBUILD}" = "aas" ]]; then chown -R couchdb:couchdb $DATA_DIR/couch/ apt update apt-get install -y openssh-server - sed -i "s/#Port 22/Port 2222/" /etc/ssh/sshd_config + echo "root:Docker!" | chpasswd + mkdir -p /tmp + chmod +x /tmp/ssh_setup.sh \ + && (sleep 1;/tmp/ssh_setup.sh 2>&1 > /dev/null) + cp /etc/sshd_config /etc/ssh/sshd_config /etc/init.d/ssh restart sed -i "s#DATA_DIR#/home#g" /opt/clouseau/clouseau.ini sed -i "s#DATA_DIR#/home#g" /opt/couchdb/etc/local.ini diff --git a/hosting/single/Dockerfile b/hosting/single/Dockerfile index 476a6e5e94..f34290f627 100644 --- a/hosting/single/Dockerfile +++ b/hosting/single/Dockerfile @@ -29,23 +29,8 @@ ENV TARGETBUILD $TARGETBUILD COPY --from=build /app /app COPY --from=build /worker /worker -ENV \ - APP_PORT=4001 \ - ARCHITECTURE=amd \ - BUDIBASE_ENVIRONMENT=PRODUCTION \ - CLUSTER_PORT=80 \ - # CUSTOM_DOMAIN=budi001.custom.com \ - DATA_DIR=/data \ - DEPLOYMENT_ENVIRONMENT=docker \ - MINIO_URL=http://localhost:9000 \ - POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU \ - REDIS_URL=localhost:6379 \ - SELF_HOSTED=1 \ - TARGETBUILD=$TARGETBUILD \ - WORKER_PORT=4002 \ - WORKER_URL=http://localhost:4002 \ - APPS_URL=http://localhost:4001 - +# ENV CUSTOM_DOMAIN=budi001.custom.com \ +# See runner.sh for Env Vars # These secret env variables are generated by the runner at startup # their values can be overriden by the user, they will be written # to the .env file in the /data directory for use later on @@ -117,6 +102,8 @@ RUN chmod +x ./build-target-paths.sh # Script below sets the path for storing data based on $DATA_DIR # For Azure App Service install SSH & point data locations to /home +ADD hosting/single/ssh/sshd_config /etc/ +ADD hosting/single/ssh/ssh_setup.sh /tmp RUN /build-target-paths.sh # cleanup cache @@ -124,6 +111,8 @@ RUN yarn cache clean -f EXPOSE 80 EXPOSE 443 +# Expose port 2222 for SSH on Azure App Service build +EXPOSE 2222 VOLUME /data # setup letsencrypt certificate diff --git a/hosting/single/runner.sh b/hosting/single/runner.sh index 77015d75ee..cf82e6701b 100644 --- a/hosting/single/runner.sh +++ b/hosting/single/runner.sh @@ -1,6 +1,21 @@ #!/bin/bash declare -a ENV_VARS=("COUCHDB_USER" "COUCHDB_PASSWORD" "DATA_DIR" "MINIO_ACCESS_KEY" "MINIO_SECRET_KEY" "INTERNAL_API_KEY" "JWT_SECRET" "REDIS_PASSWORD") - +declare -a DOCKER_VARS=("APP_PORT" "APPS_URL" "ARCHITECTURE" "BUDIBASE_ENVIRONMENT" "CLUSTER_PORT" "DEPLOYMENT_ENVIRONMENT" "MINIO_URL" "NODE_ENV" "POSTHOG_TOKEN" "REDIS_URL" "SELF_HOSTED" "WORKER_PORT" "WORKER_URL") +# Check the env vars set in Dockerfile have come through, AAS seems to drop them +[[ -z "${APP_PORT}" ]] && export APP_PORT=4001 +[[ -z "${ARCHITECTURE}" ]] && export ARCHITECTURE=amd +[[ -z "${BUDIBASE_ENVIRONMENT}" ]] && export BUDIBASE_ENVIRONMENT=PRODUCTION +[[ -z "${CLUSTER_PORT}" ]] && export CLUSTER_PORT=80 +[[ -z "${DEPLOYMENT_ENVIRONMENT}" ]] && export DEPLOYMENT_ENVIRONMENT=docker +[[ -z "${MINIO_URL}" ]] && export MINIO_URL=http://localhost:9000 +[[ -z "${NODE_ENV}" ]] && export NODE_ENV=production +[[ -z "${POSTHOG_TOKEN}" ]] && export POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU +[[ -z "${REDIS_URL}" ]] && export REDIS_URL=localhost:6379 +[[ -z "${SELF_HOSTED}" ]] && export SELF_HOSTED=1 +[[ -z "${WORKER_PORT}" ]] && export WORKER_PORT=4002 +[[ -z "${WORKER_URL}" ]] && export WORKER_URL=http://localhost:4002 +[[ -z "${APPS_URL}" ]] && export APPS_URL=http://localhost:4001 +# export CUSTOM_DOMAIN=budi001.custom.com # Azure App Service customisations if [[ "${TARGETBUILD}" = "aas" ]]; then DATA_DIR=/home @@ -10,9 +25,10 @@ else fi if [ -f "${DATA_DIR}/.env" ]; then - export $(cat ${DATA_DIR}/.env | xargs) + # Read in the .env file and export the variables + for LINE in $(cat ${DATA_DIR}/.env); do export $LINE; done fi -# first randomise any unset environment variables +# randomise any unset environment variables for ENV_VAR in "${ENV_VARS[@]}" do temp=$(eval "echo \$$ENV_VAR") @@ -30,11 +46,18 @@ if [ ! -f "${DATA_DIR}/.env" ]; then temp=$(eval "echo \$$ENV_VAR") echo "$ENV_VAR=$temp" >> ${DATA_DIR}/.env done + for ENV_VAR in "${DOCKER_VARS[@]}" + do + temp=$(eval "echo \$$ENV_VAR") + echo "$ENV_VAR=$temp" >> ${DATA_DIR}/.env + done echo "COUCH_DB_URL=${COUCH_DB_URL}" >> ${DATA_DIR}/.env fi -export COUCH_DB_URL=http://$COUCHDB_USER:$COUCHDB_PASSWORD@localhost:5984 - +# Read in the .env file and export the variables +for LINE in $(cat ${DATA_DIR}/.env); do export $LINE; done +ln -s ${DATA_DIR}/.env /app/.env +ln -s ${DATA_DIR}/.env /worker/.env # make these directories in runner, incase of mount mkdir -p ${DATA_DIR}/couch/{dbs,views} mkdir -p ${DATA_DIR}/minio diff --git a/hosting/single/ssh/ssh_setup.sh b/hosting/single/ssh/ssh_setup.sh new file mode 100644 index 0000000000..0af0b6d7ad --- /dev/null +++ b/hosting/single/ssh/ssh_setup.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +ssh-keygen -A + +#prepare run dir +if [ ! -d "/var/run/sshd" ]; then + mkdir -p /var/run/sshd +fi \ No newline at end of file diff --git a/hosting/single/ssh/sshd_config b/hosting/single/ssh/sshd_config new file mode 100644 index 0000000000..7eb5df953a --- /dev/null +++ b/hosting/single/ssh/sshd_config @@ -0,0 +1,12 @@ +Port 2222 +ListenAddress 0.0.0.0 +LoginGraceTime 180 +X11Forwarding yes +Ciphers aes128-cbc,3des-cbc,aes256-cbc,aes128-ctr,aes192-ctr,aes256-ctr +MACs hmac-sha1,hmac-sha1-96 +StrictModes yes +SyslogFacility DAEMON +PasswordAuthentication yes +PermitEmptyPasswords no +PermitRootLogin yes +Subsystem sftp internal-sftp diff --git a/lerna.json b/lerna.json index 6b8eaa45b2..87fc470b92 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.3.15-alpha.7", + "version": "1.3.19-alpha.6", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/package.json b/package.json index 4c24e0025b..71acd886d3 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,8 @@ "lint:eslint": "eslint packages", "lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\"", "lint": "yarn run lint:eslint && yarn run lint:prettier", - "lint:fix:eslint": "eslint --fix packages", - "lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\"", + "lint:fix:eslint": "eslint --fix packages qa-core", + "lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --write \"qa-core/**/*.{js,ts,svelte}\"", "lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint", "test:e2e": "lerna run cy:test --stream", "test:e2e:ci": "lerna run cy:ci --stream", diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index 627cd16f8c..92ac2acbd1 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/backend-core", - "version": "1.3.15-alpha.7", + "version": "1.3.19-alpha.6", "description": "Budibase backend core libraries used in server and worker", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -20,7 +20,7 @@ "test:watch": "jest --watchAll" }, "dependencies": { - "@budibase/types": "1.3.15-alpha.7", + "@budibase/types": "1.3.19-alpha.6", "@shopify/jest-koa-mocks": "5.0.1", "@techpass/passport-openidconnect": "0.3.2", "aws-sdk": "2.1030.0", diff --git a/packages/backend-core/src/constants.js b/packages/backend-core/src/constants.js index 172e66e603..44c271a4f8 100644 --- a/packages/backend-core/src/constants.js +++ b/packages/backend-core/src/constants.js @@ -7,6 +7,7 @@ exports.Cookies = { CurrentApp: "budibase:currentapp", Auth: "budibase:auth", Init: "budibase:init", + ACCOUNT_RETURN_URL: "budibase:account:returnurl", DatasourceAuth: "budibase:datasourceauth", OIDC_CONFIG: "budibase:oidc:config", } diff --git a/packages/backend-core/src/db/constants.ts b/packages/backend-core/src/db/constants.ts index fd464ba5fb..2c2c29cee2 100644 --- a/packages/backend-core/src/db/constants.ts +++ b/packages/backend-core/src/db/constants.ts @@ -19,6 +19,7 @@ export enum ViewName { ROUTING = "screen_routes", AUTOMATION_LOGS = "automation_logs", ACCOUNT_BY_EMAIL = "account_by_email", + PLATFORM_USERS_LOWERCASE = "platform_users_lowercase", } export const DeprecatedViews = { diff --git a/packages/backend-core/src/db/utils.ts b/packages/backend-core/src/db/utils.ts index 4926a60150..c93c7b5662 100644 --- a/packages/backend-core/src/db/utils.ts +++ b/packages/backend-core/src/db/utils.ts @@ -2,7 +2,8 @@ import { newid } from "../hashing" import { DEFAULT_TENANT_ID, Configs } from "../constants" import env from "../environment" import { SEPARATOR, DocumentType, UNICODE_MAX, ViewName } from "./constants" -import { getTenantId, getGlobalDBName, getGlobalDB } from "../tenancy" +import { getTenantId, getGlobalDB } from "../context" +import { getGlobalDBName } from "../tenancy/utils" import fetch from "node-fetch" import { doWithDB, allDbs } from "./index" import { getCouchInfo } from "./pouch" diff --git a/packages/backend-core/src/db/views.js b/packages/backend-core/src/db/views.js deleted file mode 100644 index b2562bdc71..0000000000 --- a/packages/backend-core/src/db/views.js +++ /dev/null @@ -1,203 +0,0 @@ -const { - DocumentType, - ViewName, - DeprecatedViews, - SEPARATOR, -} = require("./utils") -const { getGlobalDB } = require("../tenancy") -const { StaticDatabases } = require("./constants") -const { doWithDB } = require("./") - -const DESIGN_DB = "_design/database" - -function DesignDoc() { - return { - _id: DESIGN_DB, - // view collation information, read before writing any complex views: - // https://docs.couchdb.org/en/master/ddocs/views/collation.html#collation-specification - views: {}, - } -} - -async function removeDeprecated(db, viewName) { - if (!DeprecatedViews[viewName]) { - return - } - try { - const designDoc = await db.get(DESIGN_DB) - for (let deprecatedNames of DeprecatedViews[viewName]) { - delete designDoc.views[deprecatedNames] - } - await db.put(designDoc) - } catch (err) { - // doesn't exist, ignore - } -} - -exports.createNewUserEmailView = async () => { - const db = getGlobalDB() - let designDoc - try { - designDoc = await db.get(DESIGN_DB) - } catch (err) { - // no design doc, make one - designDoc = DesignDoc() - } - const view = { - // if using variables in a map function need to inject them before use - map: `function(doc) { - if (doc._id.startsWith("${DocumentType.USER}${SEPARATOR}")) { - emit(doc.email.toLowerCase(), doc._id) - } - }`, - } - designDoc.views = { - ...designDoc.views, - [ViewName.USER_BY_EMAIL]: view, - } - await db.put(designDoc) -} - -exports.createAccountEmailView = async () => { - await doWithDB(StaticDatabases.PLATFORM_INFO.name, async db => { - let designDoc - try { - designDoc = await db.get(DESIGN_DB) - } catch (err) { - // no design doc, make one - designDoc = DesignDoc() - } - const view = { - // if using variables in a map function need to inject them before use - map: `function(doc) { - if (doc._id.startsWith("${DocumentType.ACCOUNT_METADATA}${SEPARATOR}")) { - emit(doc.email.toLowerCase(), doc._id) - } - }`, - } - designDoc.views = { - ...designDoc.views, - [ViewName.ACCOUNT_BY_EMAIL]: view, - } - await db.put(designDoc) - }) -} - -exports.createUserAppView = async () => { - const db = getGlobalDB() - let designDoc - try { - designDoc = await db.get("_design/database") - } catch (err) { - // no design doc, make one - designDoc = DesignDoc() - } - const view = { - // if using variables in a map function need to inject them before use - map: `function(doc) { - if (doc._id.startsWith("${DocumentType.USER}${SEPARATOR}") && doc.roles) { - for (let prodAppId of Object.keys(doc.roles)) { - let emitted = prodAppId + "${SEPARATOR}" + doc._id - emit(emitted, null) - } - } - }`, - } - designDoc.views = { - ...designDoc.views, - [ViewName.USER_BY_APP]: view, - } - await db.put(designDoc) -} - -exports.createApiKeyView = async () => { - const db = getGlobalDB() - let designDoc - try { - designDoc = await db.get("_design/database") - } catch (err) { - designDoc = DesignDoc() - } - const view = { - map: `function(doc) { - if (doc._id.startsWith("${DocumentType.DEV_INFO}") && doc.apiKey) { - emit(doc.apiKey, doc.userId) - } - }`, - } - designDoc.views = { - ...designDoc.views, - [ViewName.BY_API_KEY]: view, - } - await db.put(designDoc) -} - -exports.createUserBuildersView = async () => { - const db = getGlobalDB() - let designDoc - try { - designDoc = await db.get("_design/database") - } catch (err) { - // no design doc, make one - designDoc = DesignDoc() - } - const view = { - map: `function(doc) { - if (doc.builder && doc.builder.global === true) { - emit(doc._id, doc._id) - } - }`, - } - designDoc.views = { - ...designDoc.views, - [ViewName.USER_BY_BUILDERS]: view, - } - await db.put(designDoc) -} - -exports.queryView = async (viewName, params, db, CreateFuncByName) => { - try { - let response = (await db.query(`database/${viewName}`, params)).rows - response = response.map(resp => - params.include_docs ? resp.doc : resp.value - ) - if (params.arrayResponse) { - return response - } else { - return response.length <= 1 ? response[0] : response - } - } catch (err) { - if (err != null && err.name === "not_found") { - const createFunc = CreateFuncByName[viewName] - await removeDeprecated(db, viewName) - await createFunc() - return exports.queryView(viewName, params, db, CreateFuncByName) - } else { - throw err - } - } -} - -exports.queryPlatformView = async (viewName, params) => { - const CreateFuncByName = { - [ViewName.ACCOUNT_BY_EMAIL]: exports.createAccountEmailView, - } - - return doWithDB(StaticDatabases.PLATFORM_INFO.name, async db => { - return exports.queryView(viewName, params, db, CreateFuncByName) - }) -} - -exports.queryGlobalView = async (viewName, params, db = null) => { - const CreateFuncByName = { - [ViewName.USER_BY_EMAIL]: exports.createNewUserEmailView, - [ViewName.BY_API_KEY]: exports.createApiKeyView, - [ViewName.USER_BY_BUILDERS]: exports.createUserBuildersView, - [ViewName.USER_BY_APP]: exports.createUserAppView, - } - // can pass DB in if working with something specific - if (!db) { - db = getGlobalDB() - } - return exports.queryView(viewName, params, db, CreateFuncByName) -} diff --git a/packages/backend-core/src/db/views.ts b/packages/backend-core/src/db/views.ts new file mode 100644 index 0000000000..c337d26eaa --- /dev/null +++ b/packages/backend-core/src/db/views.ts @@ -0,0 +1,261 @@ +import { DocumentType, ViewName, DeprecatedViews, SEPARATOR } from "./utils" +import { getGlobalDB } from "../context" +import PouchDB from "pouchdb" +import { StaticDatabases } from "./constants" +import { doWithDB } from "./" + +const DESIGN_DB = "_design/database" + +function DesignDoc() { + return { + _id: DESIGN_DB, + // view collation information, read before writing any complex views: + // https://docs.couchdb.org/en/master/ddocs/views/collation.html#collation-specification + views: {}, + } +} + +interface DesignDocument { + views: any +} + +async function removeDeprecated(db: PouchDB.Database, viewName: ViewName) { + // @ts-ignore + if (!DeprecatedViews[viewName]) { + return + } + try { + const designDoc = await db.get(DESIGN_DB) + // @ts-ignore + for (let deprecatedNames of DeprecatedViews[viewName]) { + delete designDoc.views[deprecatedNames] + } + await db.put(designDoc) + } catch (err) { + // doesn't exist, ignore + } +} + +export const createNewUserEmailView = async () => { + const db = getGlobalDB() + let designDoc + try { + designDoc = await db.get(DESIGN_DB) + } catch (err) { + // no design doc, make one + designDoc = DesignDoc() + } + const view = { + // if using variables in a map function need to inject them before use + map: `function(doc) { + if (doc._id.startsWith("${DocumentType.USER}${SEPARATOR}")) { + emit(doc.email.toLowerCase(), doc._id) + } + }`, + } + designDoc.views = { + ...designDoc.views, + [ViewName.USER_BY_EMAIL]: view, + } + await db.put(designDoc) +} + +export const createAccountEmailView = async () => { + await doWithDB( + StaticDatabases.PLATFORM_INFO.name, + async (db: PouchDB.Database) => { + let designDoc + try { + designDoc = await db.get(DESIGN_DB) + } catch (err) { + // no design doc, make one + designDoc = DesignDoc() + } + const view = { + // if using variables in a map function need to inject them before use + map: `function(doc) { + if (doc._id.startsWith("${DocumentType.ACCOUNT_METADATA}${SEPARATOR}")) { + emit(doc.email.toLowerCase(), doc._id) + } + }`, + } + designDoc.views = { + ...designDoc.views, + [ViewName.ACCOUNT_BY_EMAIL]: view, + } + await db.put(designDoc) + } + ) +} + +export const createUserAppView = async () => { + const db = getGlobalDB() as PouchDB.Database + let designDoc + try { + designDoc = await db.get("_design/database") + } catch (err) { + // no design doc, make one + designDoc = DesignDoc() + } + const view = { + // if using variables in a map function need to inject them before use + map: `function(doc) { + if (doc._id.startsWith("${DocumentType.USER}${SEPARATOR}") && doc.roles) { + for (let prodAppId of Object.keys(doc.roles)) { + let emitted = prodAppId + "${SEPARATOR}" + doc._id + emit(emitted, null) + } + } + }`, + } + designDoc.views = { + ...designDoc.views, + [ViewName.USER_BY_APP]: view, + } + await db.put(designDoc) +} + +export const createApiKeyView = async () => { + const db = getGlobalDB() + let designDoc + try { + designDoc = await db.get("_design/database") + } catch (err) { + designDoc = DesignDoc() + } + const view = { + map: `function(doc) { + if (doc._id.startsWith("${DocumentType.DEV_INFO}") && doc.apiKey) { + emit(doc.apiKey, doc.userId) + } + }`, + } + designDoc.views = { + ...designDoc.views, + [ViewName.BY_API_KEY]: view, + } + await db.put(designDoc) +} + +export const createUserBuildersView = async () => { + const db = getGlobalDB() + let designDoc + try { + designDoc = await db.get("_design/database") + } catch (err) { + // no design doc, make one + designDoc = DesignDoc() + } + const view = { + map: `function(doc) { + if (doc.builder && doc.builder.global === true) { + emit(doc._id, doc._id) + } + }`, + } + designDoc.views = { + ...designDoc.views, + [ViewName.USER_BY_BUILDERS]: view, + } + await db.put(designDoc) +} + +export const createPlatformUserView = async () => { + await doWithDB( + StaticDatabases.PLATFORM_INFO.name, + async (db: PouchDB.Database) => { + let designDoc + try { + designDoc = await db.get(DESIGN_DB) + } catch (err) { + // no design doc, make one + designDoc = DesignDoc() + } + const view = { + // if using variables in a map function need to inject them before use + map: `function(doc) { + if (doc.tenantId) { + emit(doc._id.toLowerCase(), doc._id) + } + }`, + } + designDoc.views = { + ...designDoc.views, + [ViewName.PLATFORM_USERS_LOWERCASE]: view, + } + await db.put(designDoc) + } + ) +} + +export interface QueryViewOptions { + arrayResponse?: boolean +} + +export const queryView = async ( + viewName: ViewName, + params: PouchDB.Query.Options, + db: PouchDB.Database, + CreateFuncByName: any, + opts?: QueryViewOptions +): Promise => { + try { + let response = await db.query(`database/${viewName}`, params) + const rows = response.rows + const docs = rows.map(row => (params.include_docs ? row.doc : row.value)) + + // if arrayResponse has been requested, always return array regardless of length + if (opts?.arrayResponse) { + return docs + } else { + // return the single document if there is only one + return docs.length <= 1 ? docs[0] : docs + } + } catch (err: any) { + if (err != null && err.name === "not_found") { + const createFunc = CreateFuncByName[viewName] + await removeDeprecated(db, viewName) + await createFunc() + return queryView(viewName, params, db, CreateFuncByName, opts) + } else { + throw err + } + } +} + +export const queryPlatformView = async ( + viewName: ViewName, + params: PouchDB.Query.Options, + opts?: QueryViewOptions +): Promise => { + const CreateFuncByName = { + [ViewName.ACCOUNT_BY_EMAIL]: createAccountEmailView, + [ViewName.PLATFORM_USERS_LOWERCASE]: createPlatformUserView, + } + + return doWithDB( + StaticDatabases.PLATFORM_INFO.name, + async (db: PouchDB.Database) => { + return queryView(viewName, params, db, CreateFuncByName, opts) + } + ) +} + +export const queryGlobalView = async ( + viewName: ViewName, + params: PouchDB.Query.Options, + db?: PouchDB.Database, + opts?: QueryViewOptions +): Promise => { + const CreateFuncByName = { + [ViewName.USER_BY_EMAIL]: createNewUserEmailView, + [ViewName.BY_API_KEY]: createApiKeyView, + [ViewName.USER_BY_BUILDERS]: createUserBuildersView, + [ViewName.USER_BY_APP]: createUserAppView, + } + // can pass DB in if working with something specific + if (!db) { + db = getGlobalDB() as PouchDB.Database + } + return queryView(viewName, params, db, CreateFuncByName, opts) +} diff --git a/packages/backend-core/src/environment.ts b/packages/backend-core/src/environment.ts index b979635fcc..be1e1eacfc 100644 --- a/packages/backend-core/src/environment.ts +++ b/packages/backend-core/src/environment.ts @@ -37,7 +37,7 @@ const env = { MULTI_TENANCY: process.env.MULTI_TENANCY, ACCOUNT_PORTAL_URL: process.env.ACCOUNT_PORTAL_URL || "https://account.budibase.app", - ACCOUNT_PORTAL_API_KEY: process.env.ACCOUNT_PORTAL_API_KEY, + ACCOUNT_PORTAL_API_KEY: process.env.ACCOUNT_PORTAL_API_KEY || "", DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL, SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED || ""), COOKIE_DOMAIN: process.env.COOKIE_DOMAIN, diff --git a/packages/backend-core/src/errors/base.js b/packages/backend-core/src/errors/base.js deleted file mode 100644 index 7cb0c0fc23..0000000000 --- a/packages/backend-core/src/errors/base.js +++ /dev/null @@ -1,11 +0,0 @@ -class BudibaseError extends Error { - constructor(message, code, type) { - super(message) - this.code = code - this.type = type - } -} - -module.exports = { - BudibaseError, -} diff --git a/packages/backend-core/src/errors/base.ts b/packages/backend-core/src/errors/base.ts new file mode 100644 index 0000000000..801dcf168d --- /dev/null +++ b/packages/backend-core/src/errors/base.ts @@ -0,0 +1,10 @@ +export class BudibaseError extends Error { + code: string + type: string + + constructor(message: string, code: string, type: string) { + super(message) + this.code = code + this.type = type + } +} diff --git a/packages/backend-core/src/errors/generic.js b/packages/backend-core/src/errors/generic.js deleted file mode 100644 index 5c7661f035..0000000000 --- a/packages/backend-core/src/errors/generic.js +++ /dev/null @@ -1,11 +0,0 @@ -const { BudibaseError } = require("./base") - -class GenericError extends BudibaseError { - constructor(message, code, type) { - super(message, code, type ? type : "generic") - } -} - -module.exports = { - GenericError, -} diff --git a/packages/backend-core/src/errors/generic.ts b/packages/backend-core/src/errors/generic.ts new file mode 100644 index 0000000000..71b3352438 --- /dev/null +++ b/packages/backend-core/src/errors/generic.ts @@ -0,0 +1,7 @@ +import { BudibaseError } from "./base" + +export class GenericError extends BudibaseError { + constructor(message: string, code: string, type: string) { + super(message, code, type ? type : "generic") + } +} diff --git a/packages/backend-core/src/errors/http.js b/packages/backend-core/src/errors/http.js deleted file mode 100644 index 8e7cab4638..0000000000 --- a/packages/backend-core/src/errors/http.js +++ /dev/null @@ -1,12 +0,0 @@ -const { GenericError } = require("./generic") - -class HTTPError extends GenericError { - constructor(message, httpStatus, code = "http", type = "generic") { - super(message, code, type) - this.status = httpStatus - } -} - -module.exports = { - HTTPError, -} diff --git a/packages/backend-core/src/errors/http.ts b/packages/backend-core/src/errors/http.ts new file mode 100644 index 0000000000..182e009f58 --- /dev/null +++ b/packages/backend-core/src/errors/http.ts @@ -0,0 +1,15 @@ +import { GenericError } from "./generic" + +export class HTTPError extends GenericError { + status: number + + constructor( + message: string, + httpStatus: number, + code = "http", + type = "generic" + ) { + super(message, code, type) + this.status = httpStatus + } +} diff --git a/packages/backend-core/src/errors/index.js b/packages/backend-core/src/errors/index.ts similarity index 65% rename from packages/backend-core/src/errors/index.js rename to packages/backend-core/src/errors/index.ts index 31ffd739a0..be6657093d 100644 --- a/packages/backend-core/src/errors/index.js +++ b/packages/backend-core/src/errors/index.ts @@ -1,5 +1,6 @@ -const http = require("./http") -const licensing = require("./licensing") +import { HTTPError } from "./http" +import { UsageLimitError, FeatureDisabledError } from "./licensing" +import * as licensing from "./licensing" const codes = { ...licensing.codes, @@ -11,7 +12,7 @@ const context = { ...licensing.context, } -const getPublicError = err => { +const getPublicError = (err: any) => { let error if (err.code || err.type) { // add generic error information @@ -32,13 +33,15 @@ const getPublicError = err => { return error } -module.exports = { +const pkg = { codes, types, errors: { - UsageLimitError: licensing.UsageLimitError, - FeatureDisabledError: licensing.FeatureDisabledError, - HTTPError: http.HTTPError, + UsageLimitError, + FeatureDisabledError, + HTTPError, }, getPublicError, } + +export = pkg diff --git a/packages/backend-core/src/errors/licensing.js b/packages/backend-core/src/errors/licensing.js deleted file mode 100644 index 85d207ac35..0000000000 --- a/packages/backend-core/src/errors/licensing.js +++ /dev/null @@ -1,43 +0,0 @@ -const { HTTPError } = require("./http") - -const type = "license_error" - -const codes = { - USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded", - FEATURE_DISABLED: "feature_disabled", -} - -const context = { - [codes.USAGE_LIMIT_EXCEEDED]: err => { - return { - limitName: err.limitName, - } - }, - [codes.FEATURE_DISABLED]: err => { - return { - featureName: err.featureName, - } - }, -} - -class UsageLimitError extends HTTPError { - constructor(message, limitName) { - super(message, 400, codes.USAGE_LIMIT_EXCEEDED, type) - this.limitName = limitName - } -} - -class FeatureDisabledError extends HTTPError { - constructor(message, featureName) { - super(message, 400, codes.FEATURE_DISABLED, type) - this.featureName = featureName - } -} - -module.exports = { - type, - codes, - context, - UsageLimitError, - FeatureDisabledError, -} diff --git a/packages/backend-core/src/errors/licensing.ts b/packages/backend-core/src/errors/licensing.ts new file mode 100644 index 0000000000..7ffcefa167 --- /dev/null +++ b/packages/backend-core/src/errors/licensing.ts @@ -0,0 +1,39 @@ +import { HTTPError } from "./http" + +export const type = "license_error" + +export const codes = { + USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded", + FEATURE_DISABLED: "feature_disabled", +} + +export const context = { + [codes.USAGE_LIMIT_EXCEEDED]: (err: any) => { + return { + limitName: err.limitName, + } + }, + [codes.FEATURE_DISABLED]: (err: any) => { + return { + featureName: err.featureName, + } + }, +} + +export class UsageLimitError extends HTTPError { + limitName: string + + constructor(message: string, limitName: string) { + super(message, 400, codes.USAGE_LIMIT_EXCEEDED, type) + this.limitName = limitName + } +} + +export class FeatureDisabledError extends HTTPError { + featureName: string + + constructor(message: string, featureName: string) { + super(message, 400, codes.FEATURE_DISABLED, type) + this.featureName = featureName + } +} diff --git a/packages/backend-core/src/events/publishers/datasource.ts b/packages/backend-core/src/events/publishers/datasource.ts index 3cd68033fc..d3ea7402f9 100644 --- a/packages/backend-core/src/events/publishers/datasource.ts +++ b/packages/backend-core/src/events/publishers/datasource.ts @@ -5,8 +5,15 @@ import { DatasourceCreatedEvent, DatasourceUpdatedEvent, DatasourceDeletedEvent, + SourceName, } from "@budibase/types" +function isCustom(datasource: Datasource) { + const sources = Object.values(SourceName) + // if not in the base source list, then it must be custom + return !sources.includes(datasource.source) +} + export async function created( datasource: Datasource, timestamp?: string | number @@ -14,6 +21,7 @@ export async function created( const properties: DatasourceCreatedEvent = { datasourceId: datasource._id as string, source: datasource.source, + custom: isCustom(datasource), } await publishEvent(Event.DATASOURCE_CREATED, properties, timestamp) } @@ -22,6 +30,7 @@ export async function updated(datasource: Datasource) { const properties: DatasourceUpdatedEvent = { datasourceId: datasource._id as string, source: datasource.source, + custom: isCustom(datasource), } await publishEvent(Event.DATASOURCE_UPDATED, properties) } @@ -30,6 +39,7 @@ export async function deleted(datasource: Datasource) { const properties: DatasourceDeletedEvent = { datasourceId: datasource._id as string, source: datasource.source, + custom: isCustom(datasource), } await publishEvent(Event.DATASOURCE_DELETED, properties) } diff --git a/packages/backend-core/src/events/publishers/index.ts b/packages/backend-core/src/events/publishers/index.ts index 57fd0bf8e2..6fe42c4bda 100644 --- a/packages/backend-core/src/events/publishers/index.ts +++ b/packages/backend-core/src/events/publishers/index.ts @@ -18,3 +18,4 @@ export * as view from "./view" export * as installation from "./installation" export * as backfill from "./backfill" export * as group from "./group" +export * as plugin from "./plugin" diff --git a/packages/backend-core/src/events/publishers/plugin.ts b/packages/backend-core/src/events/publishers/plugin.ts new file mode 100644 index 0000000000..4e4d87cf56 --- /dev/null +++ b/packages/backend-core/src/events/publishers/plugin.ts @@ -0,0 +1,41 @@ +import { publishEvent } from "../events" +import { + Event, + Plugin, + PluginDeletedEvent, + PluginImportedEvent, + PluginInitEvent, +} from "@budibase/types" + +export async function init(plugin: Plugin) { + const properties: PluginInitEvent = { + type: plugin.schema.type, + name: plugin.name, + description: plugin.description, + version: plugin.version, + } + await publishEvent(Event.PLUGIN_INIT, properties) +} + +export async function imported(plugin: Plugin) { + const properties: PluginImportedEvent = { + pluginId: plugin._id as string, + type: plugin.schema.type, + source: plugin.source, + name: plugin.name, + description: plugin.description, + version: plugin.version, + } + await publishEvent(Event.PLUGIN_IMPORTED, properties) +} + +export async function deleted(plugin: Plugin) { + const properties: PluginDeletedEvent = { + pluginId: plugin._id as string, + type: plugin.schema.type, + name: plugin.name, + description: plugin.description, + version: plugin.version, + } + await publishEvent(Event.PLUGIN_DELETED, properties) +} diff --git a/packages/backend-core/src/featureFlags/index.js b/packages/backend-core/src/featureFlags/index.js index 103ac4df59..b328839fda 100644 --- a/packages/backend-core/src/featureFlags/index.js +++ b/packages/backend-core/src/featureFlags/index.js @@ -31,20 +31,26 @@ const TENANT_FEATURE_FLAGS = getFeatureFlags() exports.isEnabled = featureFlag => { const tenantId = tenancy.getTenantId() - - return ( - TENANT_FEATURE_FLAGS && - TENANT_FEATURE_FLAGS[tenantId] && - TENANT_FEATURE_FLAGS[tenantId].includes(featureFlag) - ) + const flags = exports.getTenantFeatureFlags(tenantId) + return flags.includes(featureFlag) } exports.getTenantFeatureFlags = tenantId => { - if (TENANT_FEATURE_FLAGS && TENANT_FEATURE_FLAGS[tenantId]) { - return TENANT_FEATURE_FLAGS[tenantId] + const flags = [] + + if (TENANT_FEATURE_FLAGS) { + const globalFlags = TENANT_FEATURE_FLAGS["*"] + const tenantFlags = TENANT_FEATURE_FLAGS[tenantId] + + if (globalFlags) { + flags.push(...globalFlags) + } + if (tenantFlags) { + flags.push(...tenantFlags) + } } - return [] + return flags } exports.FeatureFlag = { diff --git a/packages/backend-core/src/index.ts b/packages/backend-core/src/index.ts index d9dbe58264..2c234bd4b8 100644 --- a/packages/backend-core/src/index.ts +++ b/packages/backend-core/src/index.ts @@ -1,5 +1,4 @@ import errors from "./errors" - const errorClasses = errors.errors import * as events from "./events" import * as migrations from "./migrations" @@ -15,7 +14,7 @@ import deprovisioning from "./context/deprovision" import auth from "./auth" import constants from "./constants" import * as dbConstants from "./db/constants" -import logging from "./logging" +import * as logging from "./logging" import pino from "./pino" import * as middleware from "./middleware" import plugins from "./plugin" diff --git a/packages/backend-core/src/middleware/authenticated.ts b/packages/backend-core/src/middleware/authenticated.ts index 062070785d..a3c6b67cde 100644 --- a/packages/backend-core/src/middleware/authenticated.ts +++ b/packages/backend-core/src/middleware/authenticated.ts @@ -106,6 +106,7 @@ export = ( user = await getUser(userId, session.tenantId) } user.csrfToken = session.csrfToken + if (session?.lastAccessedAt < timeMinusOneMinute()) { // make sure we denote that the session is still in use await updateSessionTTL(session) diff --git a/packages/backend-core/src/migrations/definitions.ts b/packages/backend-core/src/migrations/definitions.ts index 34ec0f0cad..0eea946be8 100644 --- a/packages/backend-core/src/migrations/definitions.ts +++ b/packages/backend-core/src/migrations/definitions.ts @@ -17,14 +17,6 @@ export const DEFINITIONS: MigrationDefinition[] = [ type: MigrationType.APP, name: MigrationName.APP_URLS, }, - { - type: MigrationType.GLOBAL, - name: MigrationName.DEVELOPER_QUOTA, - }, - { - type: MigrationType.GLOBAL, - name: MigrationName.PUBLISHED_APP_QUOTA, - }, { type: MigrationType.APP, name: MigrationName.EVENT_APP_BACKFILL, diff --git a/packages/backend-core/src/security/sessions.ts b/packages/backend-core/src/security/sessions.ts index f621b99dc2..33230afc60 100644 --- a/packages/backend-core/src/security/sessions.ts +++ b/packages/backend-core/src/security/sessions.ts @@ -2,28 +2,12 @@ const redis = require("../redis/init") const { v4: uuidv4 } = require("uuid") const { logWarn } = require("../logging") const env = require("../environment") - -interface CreateSession { - sessionId: string - tenantId: string - csrfToken?: string -} - -interface Session extends CreateSession { - userId: string - lastAccessedAt: string - createdAt: string - // make optional attributes required - csrfToken: string -} - -interface SessionKey { - key: string -} - -interface ScannedSession { - value: Session -} +import { + Session, + ScannedSession, + SessionKey, + CreateSession, +} from "@budibase/types" // a week in seconds const EXPIRY_SECONDS = 86400 * 7 diff --git a/packages/backend-core/src/tenancy/index.ts b/packages/backend-core/src/tenancy/index.ts index e0006abab2..d1ccbbf001 100644 --- a/packages/backend-core/src/tenancy/index.ts +++ b/packages/backend-core/src/tenancy/index.ts @@ -1,9 +1,11 @@ import * as context from "../context" import * as tenancy from "./tenancy" +import * as utils from "./utils" const pkg = { ...context, ...tenancy, + ...utils, } export = pkg diff --git a/packages/backend-core/src/tenancy/tenancy.ts b/packages/backend-core/src/tenancy/tenancy.ts index 041f694d34..1c71935eb0 100644 --- a/packages/backend-core/src/tenancy/tenancy.ts +++ b/packages/backend-core/src/tenancy/tenancy.ts @@ -1,6 +1,7 @@ import { doWithDB } from "../db" -import { StaticDatabases } from "../db/constants" -import { baseGlobalDBName } from "./utils" +import { queryPlatformView } from "../db/views" +import { StaticDatabases, ViewName } from "../db/constants" +import { getGlobalDBName } from "./utils" import { getTenantId, DEFAULT_TENANT_ID, @@ -8,6 +9,7 @@ import { getTenantIDFromAppID, } from "../context" import env from "../environment" +import { PlatformUser, PlatformUserByEmail } from "@budibase/types" const TENANT_DOC = StaticDatabases.PLATFORM_INFO.docs.tenants const PLATFORM_INFO_DB = StaticDatabases.PLATFORM_INFO.name @@ -87,15 +89,6 @@ export const tryAddTenant = async ( }) } -export const getGlobalDBName = (tenantId?: string) => { - // tenant ID can be set externally, for example user API where - // new tenants are being created, this may be the case - if (!tenantId) { - tenantId = getTenantId() - } - return baseGlobalDBName(tenantId) -} - export const doWithGlobalDB = (tenantId: string, cb: any) => { return doWithDB(getGlobalDBName(tenantId), cb) } @@ -116,14 +109,16 @@ export const lookupTenantId = async (userId: string) => { } // lookup, could be email or userId, either will return a doc -export const getTenantUser = async (identifier: string) => { - return doWithDB(PLATFORM_INFO_DB, async (db: any) => { - try { - return await db.get(identifier) - } catch (err) { - return null - } - }) +export const getTenantUser = async ( + identifier: string +): Promise => { + // use the view here and allow to find anyone regardless of casing + // Use lowercase to ensure email login is case insensitive + const response = queryPlatformView(ViewName.PLATFORM_USERS_LOWERCASE, { + keys: [identifier.toLowerCase()], + include_docs: true, + }) as Promise + return response } export const isUserInAppTenant = (appId: string, user: any) => { diff --git a/packages/backend-core/src/tenancy/utils.js b/packages/backend-core/src/tenancy/utils.js deleted file mode 100644 index 70a965ddb7..0000000000 --- a/packages/backend-core/src/tenancy/utils.js +++ /dev/null @@ -1,12 +0,0 @@ -const { DEFAULT_TENANT_ID } = require("../constants") -const { StaticDatabases, SEPARATOR } = require("../db/constants") - -exports.baseGlobalDBName = tenantId => { - let dbName - if (!tenantId || tenantId === DEFAULT_TENANT_ID) { - dbName = StaticDatabases.GLOBAL.name - } else { - dbName = `${tenantId}${SEPARATOR}${StaticDatabases.GLOBAL.name}` - } - return dbName -} diff --git a/packages/backend-core/src/tenancy/utils.ts b/packages/backend-core/src/tenancy/utils.ts new file mode 100644 index 0000000000..f99f1e30af --- /dev/null +++ b/packages/backend-core/src/tenancy/utils.ts @@ -0,0 +1,22 @@ +import { DEFAULT_TENANT_ID } from "../constants" +import { StaticDatabases, SEPARATOR } from "../db/constants" +import { getTenantId } from "../context" + +export const getGlobalDBName = (tenantId?: string) => { + // tenant ID can be set externally, for example user API where + // new tenants are being created, this may be the case + if (!tenantId) { + tenantId = getTenantId() + } + return baseGlobalDBName(tenantId) +} + +export const baseGlobalDBName = (tenantId: string | undefined | null) => { + let dbName + if (!tenantId || tenantId === DEFAULT_TENANT_ID) { + dbName = StaticDatabases.GLOBAL.name + } else { + dbName = `${tenantId}${SEPARATOR}${StaticDatabases.GLOBAL.name}` + } + return dbName +} diff --git a/packages/backend-core/src/users.js b/packages/backend-core/src/users.ts similarity index 60% rename from packages/backend-core/src/users.js rename to packages/backend-core/src/users.ts index 81bf28bb46..0793eeb1d9 100644 --- a/packages/backend-core/src/users.js +++ b/packages/backend-core/src/users.ts @@ -1,29 +1,39 @@ -const { +import { ViewName, getUsersByAppParams, getProdAppID, generateAppUserID, -} = require("./db/utils") -const { queryGlobalView } = require("./db/views") -const { UNICODE_MAX } = require("./db/constants") +} from "./db/utils" +import { queryGlobalView } from "./db/views" +import { UNICODE_MAX } from "./db/constants" +import { User } from "@budibase/types" /** * Given an email address this will use a view to search through * all the users to find one with this email address. * @param {string} email the email to lookup the user by. */ -exports.getGlobalUserByEmail = async email => { +export const getGlobalUserByEmail = async ( + email: String +): Promise => { if (email == null) { throw "Must supply an email address to view" } - return await queryGlobalView(ViewName.USER_BY_EMAIL, { + const response = await queryGlobalView(ViewName.USER_BY_EMAIL, { key: email.toLowerCase(), include_docs: true, }) + + if (Array.isArray(response)) { + // shouldn't be able to happen, but need to handle just in case + throw new Error(`Multiple users found with email address: ${email}`) + } + + return response } -exports.searchGlobalUsersByApp = async (appId, opts) => { +export const searchGlobalUsersByApp = async (appId: any, opts: any) => { if (typeof appId !== "string") { throw new Error("Must provide a string based app ID") } @@ -38,24 +48,24 @@ exports.searchGlobalUsersByApp = async (appId, opts) => { return Array.isArray(response) ? response : [response] } -exports.getGlobalUserByAppPage = (appId, user) => { +export const getGlobalUserByAppPage = (appId: string, user: User) => { if (!user) { return } - return generateAppUserID(getProdAppID(appId), user._id) + return generateAppUserID(getProdAppID(appId), user._id!) } /** * Performs a starts with search on the global email view. */ -exports.searchGlobalUsersByEmail = async (email, opts) => { +export const searchGlobalUsersByEmail = async (email: string, opts: any) => { if (typeof email !== "string") { throw new Error("Must provide a string to search by") } const lcEmail = email.toLowerCase() // handle if passing up startkey for pagination const startkey = opts && opts.startkey ? opts.startkey : lcEmail - let response = await queryGlobalView(ViewName.USER_BY_EMAIL, { + let response = await queryGlobalView(ViewName.USER_BY_EMAIL, { ...opts, startkey, endkey: `${lcEmail}${UNICODE_MAX}`, diff --git a/packages/backend-core/src/utils.js b/packages/backend-core/src/utils.js index 0587267e9a..6b59c7cb72 100644 --- a/packages/backend-core/src/utils.js +++ b/packages/backend-core/src/utils.js @@ -42,6 +42,18 @@ async function resolveAppUrl(ctx) { return app && app.appId ? app.appId : undefined } +exports.isServingApp = ctx => { + // dev app + if (ctx.path.startsWith(`/${APP_PREFIX}`)) { + return true + } + // prod app + if (ctx.path.startsWith(PROD_APP_PREFIX)) { + return true + } + return false +} + /** * Given a request tries to find the appId, which can be located in various places * @param {object} ctx The main request body to look through. diff --git a/packages/backend-core/yarn.lock b/packages/backend-core/yarn.lock index 22c17a9444..2e62aea734 100644 --- a/packages/backend-core/yarn.lock +++ b/packages/backend-core/yarn.lock @@ -1377,6 +1377,11 @@ bcrypt@5.0.1: "@mapbox/node-pre-gyp" "^1.0.0" node-addon-api "^3.1.0" +bcryptjs@2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb" + integrity sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ== + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" diff --git a/packages/bbui/package.json b/packages/bbui/package.json index da52ae0084..d19fdaecdd 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -1,7 +1,7 @@ { "name": "@budibase/bbui", "description": "A UI solution used in the different Budibase projects.", - "version": "1.3.15-alpha.7", + "version": "1.3.19-alpha.6", "license": "MPL-2.0", "svelte": "src/index.js", "module": "dist/bbui.es.js", @@ -38,7 +38,7 @@ ], "dependencies": { "@adobe/spectrum-css-workflow-icons": "^1.2.1", - "@budibase/string-templates": "1.3.15-alpha.7", + "@budibase/string-templates": "1.3.19-alpha.6", "@spectrum-css/actionbutton": "^1.0.1", "@spectrum-css/actiongroup": "^1.0.1", "@spectrum-css/avatar": "^3.0.2", diff --git a/packages/bbui/src/Banner/BannerDisplay.svelte b/packages/bbui/src/Banner/BannerDisplay.svelte index aad742b1bd..9ea2eaf2ec 100644 --- a/packages/bbui/src/Banner/BannerDisplay.svelte +++ b/packages/bbui/src/Banner/BannerDisplay.svelte @@ -4,22 +4,32 @@ import { banner } from "../Stores/banner" import Banner from "./Banner.svelte" import { fly } from "svelte/transition" + import TooltipWrapper from "../Tooltip/TooltipWrapper.svelte"

diff --git a/packages/bbui/src/InlineAlert/InlineAlert.svelte b/packages/bbui/src/InlineAlert/InlineAlert.svelte index 25e14b7caf..94ac6b2c2a 100644 --- a/packages/bbui/src/InlineAlert/InlineAlert.svelte +++ b/packages/bbui/src/InlineAlert/InlineAlert.svelte @@ -6,6 +6,7 @@ export let header = "" export let message = "" export let onConfirm = undefined + export let buttonText = "" $: icon = selectIcon(type) // if newlines used, convert them to different elements @@ -39,13 +40,16 @@
{splitMsg}
{/each} {#if onConfirm} - diff --git a/packages/bbui/src/SideNavigation/Item.svelte b/packages/bbui/src/SideNavigation/Item.svelte index 30da1fa172..aa86fc02a8 100644 --- a/packages/bbui/src/SideNavigation/Item.svelte +++ b/packages/bbui/src/SideNavigation/Item.svelte @@ -1,6 +1,7 @@
  • {/if} + {#if badge} +
    + {badge} +
    + {/if} + {#if multilevel && $$slots.subnav} {/if}
  • + + diff --git a/packages/bbui/src/Stores/banner.js b/packages/bbui/src/Stores/banner.js index 81a9ee2204..ba6d187d97 100644 --- a/packages/bbui/src/Stores/banner.js +++ b/packages/bbui/src/Stores/banner.js @@ -1,7 +1,14 @@ import { writable } from "svelte/store" +export const BANNER_TYPES = { + INFO: "info", + NEGATIVE: "negative", +} + export function createBannerStore() { - const DEFAULT_CONFIG = {} + const DEFAULT_CONFIG = { + messages: [], + } const banner = writable(DEFAULT_CONFIG) @@ -20,17 +27,38 @@ export function createBannerStore() { const showStatus = async () => { const config = { message: "Some systems are experiencing issues", - type: "negative", + type: BANNER_TYPES.NEGATIVE, extraButtonText: "View Status", extraButtonAction: () => window.open("https://status.budibase.com/"), } - await show(config) + await queue([config]) + } + + const queue = async entries => { + const priority = { + [BANNER_TYPES.NEGATIVE]: 0, + [BANNER_TYPES.INFO]: 1, + } + banner.update(store => { + const sorted = [...store.messages, ...entries].sort((a, b) => { + if (priority[a.type] == priority[b.type]) { + return 0 + } + return priority[a.type] < priority[b.type] ? -1 : 1 + }) + return { + ...store, + messages: sorted, + } + }) } return { subscribe: banner.subscribe, showStatus, + show, + queue, } } diff --git a/packages/bbui/src/Tooltip/TooltipWrapper.svelte b/packages/bbui/src/Tooltip/TooltipWrapper.svelte index 92f5c6f474..e7a96f6cd8 100644 --- a/packages/bbui/src/Tooltip/TooltipWrapper.svelte +++ b/packages/bbui/src/Tooltip/TooltipWrapper.svelte @@ -4,6 +4,7 @@ export let tooltip = "" export let size = "M" + export let disabled = true let showTooltip = false @@ -19,7 +20,7 @@ on:mouseleave={() => (showTooltip = false)} on:focus > - + {#if showTooltip}
    @@ -54,7 +55,6 @@ transform: scale(0.75); } .icon-small { - margin-top: -2px; - margin-bottom: -5px; + margin-bottom: -2px; } diff --git a/packages/bbui/src/index.js b/packages/bbui/src/index.js index b45f3e9ed6..ead226bdc3 100644 --- a/packages/bbui/src/index.js +++ b/packages/bbui/src/index.js @@ -34,6 +34,7 @@ export { default as Layout } from "./Layout/Layout.svelte" export { default as Page } from "./Layout/Page.svelte" export { default as Link } from "./Link/Link.svelte" export { default as Tooltip } from "./Tooltip/Tooltip.svelte" +export { default as TooltipWrapper } from "./Tooltip/TooltipWrapper.svelte" export { default as Menu } from "./Menu/Menu.svelte" export { default as MenuSection } from "./Menu/Section.svelte" export { default as MenuSeparator } from "./Menu/Separator.svelte" @@ -94,7 +95,7 @@ export { default as clickOutside } from "./Actions/click_outside" // Stores export { notifications, createNotificationStore } from "./Stores/notifications" -export { banner } from "./Stores/banner" +export { banner, BANNER_TYPES } from "./Stores/banner" // Helpers export * as Helpers from "./helpers" diff --git a/packages/builder/cypress/integration/autoScreensUI.spec.js b/packages/builder/cypress/integration/autoScreensUI.spec.js index 7a5dbef5a5..0253675c5b 100644 --- a/packages/builder/cypress/integration/autoScreensUI.spec.js +++ b/packages/builder/cypress/integration/autoScreensUI.spec.js @@ -82,10 +82,10 @@ filterTests(['smoke', 'all'], () => { }) if (Cypress.env("TEST_ENV")) { - it("should generate data source screens", () => { - // Using MySQL data source for testing this + it("should generate datasource screens", () => { + // Using MySQL datasource for testing this const datasource = "MySQL" - // Select & configure MySQL data source + // Select & configure MySQL datasource cy.selectExternalDatasource(datasource) cy.addDatasourceConfig(datasource) // Create Autogenerated screens from a MySQL table - MySQL contains books table diff --git a/packages/builder/cypress/integration/datasources/mySql.spec.js b/packages/builder/cypress/integration/datasources/mySql.spec.js index 654705a24e..33aa72f0bb 100644 --- a/packages/builder/cypress/integration/datasources/mySql.spec.js +++ b/packages/builder/cypress/integration/datasources/mySql.spec.js @@ -11,8 +11,8 @@ filterTests(["all"], () => { const queryName = "Cypress Test Query" const queryRename = "CT Query Rename" - it("Should add MySQL data source without configuration", () => { - // Select MySQL data source + it("Should add MySQL datasource without configuration", () => { + // Select MySQL datasource cy.selectExternalDatasource(datasource) // Attempt to fetch tables without applying configuration cy.intercept("**/datasources").as("datasource") @@ -35,8 +35,8 @@ filterTests(["all"], () => { cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true }) }) - it("should add MySQL data source and fetch tables", () => { - // Add & configure MySQL data source + it("should add MySQL datasource and fetch tables", () => { + // Add & configure MySQL datasource cy.selectExternalDatasource(datasource) cy.intercept("**/datasources").as("datasource") cy.addDatasourceConfig(datasource) @@ -52,7 +52,7 @@ filterTests(["all"], () => { }) it("should check table fetching error", () => { - // MySQL test data source contains tables without primary keys + // MySQL test datasource contains tables without primary keys cy.get(".spectrum-InLineAlert") .should("contain", "Error fetching tables") .and("contain", "No primary key constraint found") diff --git a/packages/builder/cypress/integration/datasources/oracle.spec.js b/packages/builder/cypress/integration/datasources/oracle.spec.js index 5d92d6b217..ae1ca5cd75 100644 --- a/packages/builder/cypress/integration/datasources/oracle.spec.js +++ b/packages/builder/cypress/integration/datasources/oracle.spec.js @@ -11,8 +11,8 @@ filterTests(["all"], () => { const queryName = "Cypress Test Query" const queryRename = "CT Query Rename" - it("Should add Oracle data source and skip table fetch", () => { - // Select Oracle data source + it("Should add Oracle datasource and skip table fetch", () => { + // Select Oracle datasource cy.selectExternalDatasource(datasource) // Skip table fetch - no config added cy.get(".spectrum-Button") @@ -23,7 +23,7 @@ filterTests(["all"], () => { cy.get(".spectrum-Textfield-input", { timeout: 500 }) .eq(1) .should("have.value", "localhost") - // Add another Oracle data source, configure & skip table fetch + // Add another Oracle datasource, configure & skip table fetch cy.selectExternalDatasource(datasource) cy.addDatasourceConfig(datasource, true) // Confirm config and no tables @@ -33,8 +33,8 @@ filterTests(["all"], () => { cy.get(".spectrum-Body").eq(2).should("contain", "No tables found.") }) - it("Should add Oracle data source and fetch tables without configuration", () => { - // Select Oracle data source + it("Should add Oracle datasource and fetch tables without configuration", () => { + // Select Oracle datasource cy.selectExternalDatasource(datasource) // Attempt to fetch tables without applying configuration cy.intercept("**/datasources").as("datasource") @@ -49,8 +49,8 @@ filterTests(["all"], () => { cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true }) }) - xit("should add Oracle data source and fetch tables", () => { - // Add & configure Oracle data source + xit("should add Oracle datasource and fetch tables", () => { + // Add & configure Oracle datasource cy.selectExternalDatasource(datasource) cy.intercept("**/datasources").as("datasource") cy.addDatasourceConfig(datasource) diff --git a/packages/builder/cypress/integration/datasources/postgreSql.spec.js b/packages/builder/cypress/integration/datasources/postgreSql.spec.js index 622c3ade73..8ef574566e 100644 --- a/packages/builder/cypress/integration/datasources/postgreSql.spec.js +++ b/packages/builder/cypress/integration/datasources/postgreSql.spec.js @@ -11,8 +11,8 @@ filterTests(["all"], () => { const queryName = "Cypress Test Query" const queryRename = "CT Query Rename" - xit("Should add PostgreSQL data source without configuration", () => { - // Select PostgreSQL data source + xit("Should add PostgreSQL datasource without configuration", () => { + // Select PostgreSQL datasource cy.selectExternalDatasource(datasource) // Attempt to fetch tables without applying configuration cy.intercept("**/datasources").as("datasource") @@ -27,8 +27,8 @@ filterTests(["all"], () => { cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true }) }) - it("should add PostgreSQL data source and fetch tables", () => { - // Add & configure PostgreSQL data source + it("should add PostgreSQL datasource and fetch tables", () => { + // Add & configure PostgreSQL datasource cy.selectExternalDatasource(datasource) cy.intercept("**/datasources").as("datasource") cy.addDatasourceConfig(datasource) diff --git a/packages/builder/cypress/integration/datasources/rest.spec.js b/packages/builder/cypress/integration/datasources/rest.spec.js index 7a145049e2..ec9864a47d 100644 --- a/packages/builder/cypress/integration/datasources/rest.spec.js +++ b/packages/builder/cypress/integration/datasources/rest.spec.js @@ -10,8 +10,8 @@ filterTests(["smoke", "all"], () => { const datasource = "REST" const restUrl = "https://api.openbrewerydb.org/breweries" - it("Should add REST data source with incorrect API", () => { - // Select REST data source + it("Should add REST datasource with incorrect API", () => { + // Select REST datasource cy.selectExternalDatasource(datasource) // Enter incorrect api & attempt to send query cy.get(".query-buttons", { timeout: 1000 }).contains("Add query").click({ force: true }) diff --git a/packages/builder/cypress/support/commands.js b/packages/builder/cypress/support/commands.js index a07a22188f..53accfbbe4 100644 --- a/packages/builder/cypress/support/commands.js +++ b/packages/builder/cypress/support/commands.js @@ -763,7 +763,7 @@ Cypress.Commands.add("navigateToDataSection", () => { }) Cypress.Commands.add("navigateToAutogeneratedModal", () => { - // Screen name must already exist within data source + // Screen name must already exist within datasource cy.contains("Design").click() cy.get(".spectrum-Button").contains("Add screen").click({ force: true }) cy.get(".spectrum-Modal").within(() => { @@ -779,7 +779,7 @@ Cypress.Commands.add("navigateToAutogeneratedModal", () => { Cypress.Commands.add("selectExternalDatasource", datasourceName => { // Navigates to Data Section cy.navigateToDataSection() - // Open Data Source modal + // Open Datasource modal cy.get(".nav").within(() => { cy.get(".add-button").click() }) diff --git a/packages/builder/package.json b/packages/builder/package.json index 33ad9c46a8..bd7cb3e881 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/builder", - "version": "1.3.15-alpha.7", + "version": "1.3.19-alpha.6", "license": "GPL-3.0", "private": true, "scripts": { @@ -69,10 +69,10 @@ } }, "dependencies": { - "@budibase/bbui": "1.3.15-alpha.7", - "@budibase/client": "1.3.15-alpha.7", - "@budibase/frontend-core": "1.3.15-alpha.7", - "@budibase/string-templates": "1.3.15-alpha.7", + "@budibase/bbui": "1.3.19-alpha.6", + "@budibase/client": "1.3.19-alpha.6", + "@budibase/frontend-core": "1.3.19-alpha.6", + "@budibase/string-templates": "1.3.19-alpha.6", "@sentry/browser": "5.19.1", "@spectrum-css/page": "^3.0.1", "@spectrum-css/vars": "^3.0.1", diff --git a/packages/builder/src/App.svelte b/packages/builder/src/App.svelte index 0fb0fe59d5..4d193df104 100644 --- a/packages/builder/src/App.svelte +++ b/packages/builder/src/App.svelte @@ -4,6 +4,7 @@ import { NotificationDisplay, BannerDisplay } from "@budibase/bbui" import { parse, stringify } from "qs" import HelpIcon from "components/common/HelpIcon.svelte" + import LicensingOverlays from "components/portal/licensing/LicensingOverlays.svelte" const queryHandler = { parse, stringify } @@ -12,6 +13,9 @@ + + + diff --git a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte index 6235e52916..19946a2386 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte @@ -32,8 +32,8 @@ : [] $: openDataSource = enrichedDataSources.find(x => x.open) $: { - // Ensure the open data source is always included in the list of open - // data sources + // Ensure the open datasource is always included in the list of open + // datasources if (openDataSource) { openNode(openDataSource) } @@ -79,7 +79,7 @@ }) const containsActiveEntity = datasource => { - // If we're view a query then the data source ID is in the URL + // If we're view a query then the datasource ID is in the URL if ($params.selectedDatasource === datasource._id) { return true } diff --git a/packages/builder/src/components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte b/packages/builder/src/components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte index 1c9d3c76a8..40ef294339 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte @@ -126,7 +126,7 @@ - Connect to an external data source + Connect to an external datasource
    {#each Object.entries(integrations).filter(([key, val]) => key !== IntegrationTypes.INTERNAL && !val.custom) as [integrationType, schema]} 0} - Custom data source + Custom datasource
    {#each customIntegrations as [integrationType, schema]} - + {#if $licensing?.usageMetrics?.apps < 100} + + {/if} - Choose the data source that provides the row you would like to duplicate. + Choose the datasource that provides the row you would like to duplicate.
    You can always add or override fields manually.
    - + + import { Modal, ModalContent, Body } from "@budibase/bbui" + import { auth, admin } from "stores/portal" + + export let onDismiss = () => {} + export let onShow = () => {} + + let accountDowngradeModal + + $: accountUrl = $admin.accountPortalUrl + $: upgradeUrl = `${accountUrl}/portal/upgrade` + + export function show() { + accountDowngradeModal.show() + } + + export function hide() { + accountDowngradeModal.hide() + } + + + + { + window.location.href = upgradeUrl + } + : null} + > + + The payment for your subscription has failed and we have downgraded your + account to the Free plan. + + Upgrade to restore full functionality. + {#if !$auth.user.accountPortalAccess} + Please contact the account holder to upgrade. + {/if} + + + + diff --git a/packages/builder/src/components/portal/licensing/AppLimitModal.svelte b/packages/builder/src/components/portal/licensing/AppLimitModal.svelte new file mode 100644 index 0000000000..39f553517e --- /dev/null +++ b/packages/builder/src/components/portal/licensing/AppLimitModal.svelte @@ -0,0 +1,47 @@ + + + + { + window.location.href = upgradeUrl + } + : null} + > + + You are currently on our Free plan. Upgrade + to our Pro plan to get unlimited apps and additional features. + + {#if !$auth.user.accountPortalAccess} + Please contact the account holder to upgrade. + {/if} + + + + diff --git a/packages/builder/src/components/portal/licensing/DayPassWarningModal.svelte b/packages/builder/src/components/portal/licensing/DayPassWarningModal.svelte new file mode 100644 index 0000000000..b1aade2ca5 --- /dev/null +++ b/packages/builder/src/components/portal/licensing/DayPassWarningModal.svelte @@ -0,0 +1,78 @@ + + + + {#if $auth.user.accountPortalAccess} + { + window.location.href = upgradeUrl + }} + > + + You have used {dayPassesUsed}% of + your plans Day Passes with {daysRemaining} day{daysRemaining == 1 + ? "" + : "s"} remaining. + + + + + {dayPassesBody} + + {:else} + + + You have used {dayPassesUsed}% of + your plans Day Passes with {daysRemaining} day{daysRemaining == 1 + ? "" + : "s"} remaining. + + + + + Please contact your account holder to upgrade. + + {/if} + + + diff --git a/packages/builder/src/components/portal/licensing/LicensingOverlays.svelte b/packages/builder/src/components/portal/licensing/LicensingOverlays.svelte new file mode 100644 index 0000000000..8431b860e8 --- /dev/null +++ b/packages/builder/src/components/portal/licensing/LicensingOverlays.svelte @@ -0,0 +1,112 @@ + + + + + diff --git a/packages/builder/src/components/portal/licensing/PaymentFailedModal.svelte b/packages/builder/src/components/portal/licensing/PaymentFailedModal.svelte new file mode 100644 index 0000000000..21a3c2dcd9 --- /dev/null +++ b/packages/builder/src/components/portal/licensing/PaymentFailedModal.svelte @@ -0,0 +1,84 @@ + + + + {#if $auth.user.accountPortalAccess} + { + window.location.href = upgradeUrl + }} + > + The payment for your subscription has failed + + Please upgrade your billing details before your account gets downgraded + to the free plan + + +
    + {`${$licensing.pastDueDaysRemaining} day${ + $licensing.pastDueDaysRemaining == 1 ? "" : "s" + } remaining`} + + + +
    + +
    + {:else} + + The payment for your subscription has failed + + Please upgrade your billing details before your account gets downgraded + to the free plan + + Please contact your account holder. + +
    + {`${$licensing.pastDueDaysRemaining} day${ + $licensing.pastDueDaysRemaining == 1 ? "" : "s" + } remaining`} + + + +
    + +
    + {/if} +
    + + diff --git a/packages/builder/src/components/portal/licensing/constants.js b/packages/builder/src/components/portal/licensing/constants.js new file mode 100644 index 0000000000..57f3a36709 --- /dev/null +++ b/packages/builder/src/components/portal/licensing/constants.js @@ -0,0 +1,15 @@ +export const ExpiringKeys = { + LICENSING_DAYPASS_WARNING_MODAL: "licensing_daypass_warning_90_modal", + LICENSING_DAYPASS_WARNING_BANNER: "licensing_daypass_warning_90_banner", + LICENSING_PAYMENT_FAILED: "licensing_payment_failed", + LICENSING_ACCOUNT_DOWNGRADED_MODAL: "licensing_account_downgraded_modal", + LICENSING_APP_LIMIT_MODAL: "licensing_app_limit_modal", + LICENSING_ROWS_WARNING_BANNER: "licensing_rows_warning_banner", + LICENSING_AUTOMATIONS_WARNING_BANNER: "licensing_automations_warning_banner", + LICENSING_QUERIES_WARNING_BANNER: "licensing_queries_warning_banner", +} + +export const StripeStatus = { + PAST_DUE: "past_due", + ACTIVE: "active", +} diff --git a/packages/builder/src/components/portal/licensing/licensingBanners.js b/packages/builder/src/components/portal/licensing/licensingBanners.js new file mode 100644 index 0000000000..37d8e4a540 --- /dev/null +++ b/packages/builder/src/components/portal/licensing/licensingBanners.js @@ -0,0 +1,156 @@ +import { ExpiringKeys } from "./constants" +import { temporalStore } from "builderStore" +import { admin, auth, licensing } from "stores/portal" +import { get } from "svelte/store" +import { BANNER_TYPES } from "@budibase/bbui" + +const oneDayInSeconds = 86400 + +const defaultCacheFn = key => { + temporalStore.actions.setExpiring(key, {}, oneDayInSeconds) +} + +const defaultAction = key => { + if (!get(auth).user.accountPortalAccess) { + return {} + } + return { + extraButtonText: "Upgrade Plan", + extraButtonAction: () => { + defaultCacheFn(key) + window.location.href = `${get(admin).accountPortalUrl}/portal/upgrade` + }, + } +} + +const buildUsageInfoBanner = ( + metricKey, + metricLabel, + cacheKey, + percentageThreshold, + customMessage +) => { + const appAuth = get(auth) + const appLicensing = get(licensing) + + const displayPercent = + appLicensing?.usageMetrics[metricKey] > 100 + ? 100 + : appLicensing?.usageMetrics[metricKey] + + let bannerConfig = { + key: cacheKey, + type: BANNER_TYPES.INFO, + onChange: () => { + defaultCacheFn(cacheKey) + }, + message: customMessage + ? customMessage + : `You have used ${displayPercent}% of your monthly usage of ${metricLabel} with ${ + appLicensing.quotaResetDaysRemaining + } day${ + appLicensing.quotaResetDaysRemaining == 1 ? "" : "s" + } remaining. ${ + appAuth.user.accountPortalAccess + ? "" + : "Please contact your account holder to upgrade" + }`, + criteria: () => { + return appLicensing?.usageMetrics[metricKey] >= percentageThreshold + }, + tooltip: appLicensing?.quotaResetDate, + } + + return !get(auth).user.accountPortalAccess + ? bannerConfig + : { + ...bannerConfig, + ...defaultAction(cacheKey), + } +} + +const buildDayPassBanner = () => { + const appAuth = get(auth) + const appLicensing = get(licensing) + if (get(licensing)?.usageMetrics["dayPasses"] >= 100) { + return { + key: "max_dayPasses", + type: BANNER_TYPES.NEGATIVE, + criteria: () => { + return true + }, + message: `Your apps are currently offline. You have exceeded your plans limit for Day Passes. ${ + appAuth.user.accountPortalAccess + ? "" + : "Please contact your account holder to upgrade." + }`, + ...defaultAction(), + showCloseButton: false, + } + } + + return buildUsageInfoBanner( + "dayPasses", + "Day Passes", + ExpiringKeys.LICENSING_DAYPASS_WARNING_BANNER, + 90, + `You have used ${ + appLicensing?.usageMetrics["dayPasses"] + }% of your monthly usage of Day Passes with ${ + appLicensing?.quotaResetDaysRemaining + } day${ + get(licensing).quotaResetDaysRemaining == 1 ? "" : "s" + } remaining. All apps will be taken offline if this limit is reached. ${ + appAuth.user.accountPortalAccess + ? "" + : "Please contact your account holder to upgrade." + }` + ) +} + +const buildPaymentFailedBanner = () => { + return { + key: "payment_Failed", + type: BANNER_TYPES.NEGATIVE, + criteria: () => { + return get(licensing)?.accountPastDue && !get(licensing).isFreePlan() + }, + message: `Payment Failed - Please update your billing details or your account will be downgrades in + ${get(licensing)?.pastDueDaysRemaining} day${ + get(licensing)?.pastDueDaysRemaining == 1 ? "" : "s" + }`, + ...defaultAction(), + showCloseButton: false, + tooltip: get(licensing).pastDueEndDate, + } +} + +export const getBanners = () => { + return [ + buildPaymentFailedBanner(), + buildDayPassBanner(ExpiringKeys.LICENSING_DAYPASS_WARNING_BANNER), + buildUsageInfoBanner( + "rows", + "Rows", + ExpiringKeys.LICENSING_ROWS_WARNING_BANNER, + 90 + ), + buildUsageInfoBanner( + "automations", + "Automations", + ExpiringKeys.LICENSING_AUTOMATIONS_WARNING_BANNER, + 90 + ), + buildUsageInfoBanner( + "queries", + "Queries", + ExpiringKeys.LICENSING_QUERIES_WARNING_BANNER, + 90 + ), + ].filter(licensingBanner => { + return ( + !temporalStore.actions.getExpiring(licensingBanner.key) && + licensingBanner.criteria() + ) + }) +} diff --git a/packages/builder/src/components/usage/Usage.svelte b/packages/builder/src/components/usage/Usage.svelte index cd9071785d..49c4205a4c 100644 --- a/packages/builder/src/components/usage/Usage.svelte +++ b/packages/builder/src/components/usage/Usage.svelte @@ -1,10 +1,15 @@
    - +
    + {#if showWarning} + + {/if} +
    + {usage.name} +
    +
    {#if unlimited} - {usage.used} + {usage.used} / Unlimited {:else} {usage.used} / {usage.total} {/if}
    {#if unlimited} - Unlimited + {:else} - + + {/if} + {#if showWarning} + + To get more {usage.name.toLowerCase()} + {#if accountPortalAccess} + upgrade your plan + {:else} + contact your account holder + {/if} + {/if}
    @@ -51,6 +89,13 @@ display: flex; flex-direction: row; justify-content: space-between; - gap: var(--spacing-m); + margin-bottom: 12px; + } + .header-container { + display: flex; + } + .heading { + margin-top: 3px; + margin-left: 5px; } diff --git a/packages/builder/src/components/usage/UsageDashCard.svelte b/packages/builder/src/components/usage/UsageDashCard.svelte new file mode 100644 index 0000000000..6dd0b3d969 --- /dev/null +++ b/packages/builder/src/components/usage/UsageDashCard.svelte @@ -0,0 +1,119 @@ + + +
    +
    +
    + +
    + {description} +
    + {title} + {#if textRows.length} +
    + {#each textRows as row} + {#if row.tooltip} + + {row.message} + + {:else} + {row.message} + {/if} + {/each} +
    + {/if} +
    +
    +
    + {#if secondaryDefined} +
    + +
    + {/if} + {#if primaryDefined} +
    + +
    + {/if} +
    +
    +
    + +
    +
    + + diff --git a/packages/builder/src/components/usage/index.js b/packages/builder/src/components/usage/index.js new file mode 100644 index 0000000000..48c0e2ea2c --- /dev/null +++ b/packages/builder/src/components/usage/index.js @@ -0,0 +1,2 @@ +export { default as Usage } from "./Usage.svelte" +export { default as DashCard } from "./UsageDashCard.svelte" diff --git a/packages/builder/src/constants/index.js b/packages/builder/src/constants/index.js index 151a0cdf8d..49054ae247 100644 --- a/packages/builder/src/constants/index.js +++ b/packages/builder/src/constants/index.js @@ -58,6 +58,13 @@ export const DefaultAppTheme = { navTextColor: "var(--spectrum-global-color-gray-800)", } +export const PlanType = { + FREE: "free", + PRO: "pro", + BUSINESS: "business", + ENTERPRISE: "enterprise", +} + export const PluginSource = { URL: "URL", NPM: "NPM", diff --git a/packages/builder/src/pages/builder/_layout.svelte b/packages/builder/src/pages/builder/_layout.svelte index 2e8ea2ef0a..8d604e8790 100644 --- a/packages/builder/src/pages/builder/_layout.svelte +++ b/packages/builder/src/pages/builder/_layout.svelte @@ -1,6 +1,6 @@ @@ -121,6 +133,7 @@ > + diff --git a/packages/builder/src/stores/portal/licensing.js b/packages/builder/src/stores/portal/licensing.js index 653dab52ed..9d63a9edb5 100644 --- a/packages/builder/src/stores/portal/licensing.js +++ b/packages/builder/src/stores/portal/licensing.js @@ -1,14 +1,22 @@ -import { writable } from "svelte/store" +import { writable, get } from "svelte/store" import { API } from "api" +import { auth } from "stores/portal" +import { Constants } from "@budibase/frontend-core" +import { StripeStatus } from "components/portal/licensing/constants" export const createLicensingStore = () => { const DEFAULT = { plans: {}, } + const oneDayInMilliseconds = 86400000 const store = writable(DEFAULT) const actions = { + init: async () => { + await actions.getQuotaUsage() + await actions.getUsageMetrics() + }, getQuotaUsage: async () => { const quotaUsage = await API.getQuotaUsage() store.update(state => { @@ -18,6 +26,80 @@ export const createLicensingStore = () => { } }) }, + getUsageMetrics: async () => { + const quota = get(store).quotaUsage + const license = get(auth).user.license + const now = new Date() + + const getMetrics = (keys, license, quota) => { + if (!license || !quota || !keys) { + return {} + } + return keys.reduce((acc, key) => { + const quotaLimit = license[key].value + const quotaUsed = (quota[key] / quotaLimit) * 100 + acc[key] = quotaLimit > -1 ? Math.round(quotaUsed) : -1 + return acc + }, {}) + } + const monthlyMetrics = getMetrics( + ["dayPasses", "queries", "automations"], + license.quotas.usage.monthly, + quota.monthly.current + ) + const staticMetrics = getMetrics( + ["apps", "rows"], + license.quotas.usage.static, + quota.usageQuota + ) + + const getDaysBetween = (dateStart, dateEnd) => { + return dateEnd > dateStart + ? Math.round( + (dateEnd.getTime() - dateStart.getTime()) / oneDayInMilliseconds + ) + : 0 + } + + const quotaResetDate = new Date(quota.quotaReset) + const quotaResetDaysRemaining = getDaysBetween(now, quotaResetDate) + + const accountDowngraded = + license?.billing?.subscription?.downgradeAt && + license?.billing?.subscription?.downgradeAt <= now.getTime() && + license?.billing?.subscription?.status === StripeStatus.PAST_DUE && + license?.plan.type === Constants.PlanType.FREE + + const pastDueAtMilliseconds = license?.billing?.subscription?.pastDueAt + const downgradeAtMilliseconds = + license?.billing?.subscription?.downgradeAt + let pastDueDaysRemaining + let pastDueEndDate + + if (pastDueAtMilliseconds && downgradeAtMilliseconds) { + pastDueEndDate = new Date(downgradeAtMilliseconds) + pastDueDaysRemaining = getDaysBetween( + new Date(pastDueAtMilliseconds), + pastDueEndDate + ) + } + + store.update(state => { + return { + ...state, + usageMetrics: { ...monthlyMetrics, ...staticMetrics }, + quotaResetDaysRemaining, + quotaResetDate, + accountDowngraded, + accountPastDue: pastDueAtMilliseconds != null, + pastDueEndDate, + pastDueDaysRemaining, + isFreePlan: () => { + return license?.plan.type === Constants.PlanType.FREE + }, + } + }) + }, } return { diff --git a/packages/cli/package.json b/packages/cli/package.json index 341cc8604a..a034023b82 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/cli", - "version": "1.3.15-alpha.7", + "version": "1.3.19-alpha.6", "description": "Budibase CLI, for developers, self hosting and migrations.", "main": "src/index.js", "bin": { @@ -26,9 +26,9 @@ "outputPath": "build" }, "dependencies": { - "@budibase/backend-core": "1.3.15-alpha.7", - "@budibase/string-templates": "1.3.15-alpha.7", - "@budibase/types": "1.3.15-alpha.7", + "@budibase/backend-core": "1.3.19-alpha.6", + "@budibase/string-templates": "1.3.19-alpha.6", + "@budibase/types": "1.3.19-alpha.6", "axios": "0.21.2", "chalk": "4.1.0", "cli-progress": "3.11.2", diff --git a/packages/cli/src/constants.js b/packages/cli/src/constants.js index 8fe6c8602d..aa49523d4e 100644 --- a/packages/cli/src/constants.js +++ b/packages/cli/src/constants.js @@ -1,3 +1,5 @@ +const { Event } = require("@budibase/types") + exports.CommandWords = { BACKUPS: "backups", HOSTING: "hosting", @@ -15,6 +17,7 @@ exports.AnalyticsEvents = { OptOut: "analytics:opt:out", OptIn: "analytics:opt:in", SelfHostInit: "hosting:init", + PluginInit: Event.PLUGIN_INIT, } exports.POSTHOG_TOKEN = "phc_yGOn4i7jWKaCTapdGR6lfA4AvmuEQ2ijn5zAVSFYPlS" diff --git a/packages/cli/src/events.js b/packages/cli/src/events.js new file mode 100644 index 0000000000..63d4fca1ea --- /dev/null +++ b/packages/cli/src/events.js @@ -0,0 +1,11 @@ +const AnalyticsClient = require("./analytics/Client") + +const client = new AnalyticsClient() + +exports.captureEvent = (event, properties) => { + client.capture({ + distinctId: "cli", + event, + properties, + }) +} diff --git a/packages/cli/src/hosting/index.js b/packages/cli/src/hosting/index.js index 2ebcee43a5..ae62c45992 100644 --- a/packages/cli/src/hosting/index.js +++ b/packages/cli/src/hosting/index.js @@ -13,7 +13,7 @@ const fs = require("fs") const compose = require("docker-compose") const makeEnv = require("./makeEnv") const axios = require("axios") -const AnalyticsClient = require("../analytics/Client") +const { captureEvent } = require("../events") const BUDIBASE_SERVICES = ["app-service", "worker-service", "proxy-service"] const ERROR_FILE = "docker-error.log" @@ -22,8 +22,6 @@ const FILE_URLS = [ ] const DO_USER_DATA_URL = "http://169.254.169.254/metadata/v1/user-data" -const client = new AnalyticsClient() - async function downloadFiles() { const promises = [] for (let url of FILE_URLS) { @@ -72,12 +70,8 @@ async function init(type) { return } } - client.capture({ - distinctId: "cli", - event: AnalyticsEvents.SelfHostInit, - properties: { - type, - }, + captureEvent(AnalyticsEvents.SelfHostInit, { + type, }) await downloadFiles() const config = isQuick ? makeEnv.QUICK_CONFIG : {} diff --git a/packages/cli/src/plugins/index.js b/packages/cli/src/plugins/index.js index d184c6e70a..66cca8c19d 100644 --- a/packages/cli/src/plugins/index.js +++ b/packages/cli/src/plugins/index.js @@ -1,5 +1,5 @@ const Command = require("../structures/Command") -const { CommandWords } = require("../constants") +const { CommandWords, AnalyticsEvents } = require("../constants") const { getSkeleton, fleshOutSkeleton } = require("./skeleton") const questions = require("../questions") const fs = require("fs") @@ -8,6 +8,7 @@ const { validate } = require("@budibase/backend-core/plugins") const { runPkgCommand } = require("../exec") const { join } = require("path") const { success, error, info, moveDirectory } = require("../utils") +const { captureEvent } = require("../events") function checkInPlugin() { if (!fs.existsSync("package.json")) { @@ -58,7 +59,7 @@ async function init(opts) { ) return } - const desc = await questions.string( + const description = await questions.string( "Description", `An amazing Budibase ${type}!` ) @@ -67,7 +68,7 @@ async function init(opts) { // get the skeleton console.log(info("Retrieving project...")) await getSkeleton(type, name) - await fleshOutSkeleton(type, name, desc, version) + await fleshOutSkeleton(type, name, description, version) console.log(info("Installing dependencies...")) await runPkgCommand("install", join(process.cwd(), name)) // if no parent directory desired move to cwd @@ -77,6 +78,12 @@ async function init(opts) { } else { console.log(info(`Plugin created in directory "${name}"`)) } + captureEvent(AnalyticsEvents.PluginInit, { + type, + name, + description, + version, + }) } async function verify() { diff --git a/packages/client/package.json b/packages/client/package.json index c50817dc39..8489c796c5 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/client", - "version": "1.3.15-alpha.7", + "version": "1.3.19-alpha.6", "license": "MPL-2.0", "module": "dist/budibase-client.js", "main": "dist/budibase-client.js", @@ -19,9 +19,9 @@ "dev:builder": "rollup -cw" }, "dependencies": { - "@budibase/bbui": "1.3.15-alpha.7", - "@budibase/frontend-core": "1.3.15-alpha.7", - "@budibase/string-templates": "1.3.15-alpha.7", + "@budibase/bbui": "1.3.19-alpha.6", + "@budibase/frontend-core": "1.3.19-alpha.6", + "@budibase/string-templates": "1.3.19-alpha.6", "@spectrum-css/button": "^3.0.3", "@spectrum-css/card": "^3.0.3", "@spectrum-css/divider": "^1.0.3", diff --git a/packages/client/src/components/MadeInBudibase.svelte b/packages/client/src/components/FreeLogo.svelte similarity index 78% rename from packages/client/src/components/MadeInBudibase.svelte rename to packages/client/src/components/FreeLogo.svelte index 2e5d6336f1..2a5a936cdb 100644 --- a/packages/client/src/components/MadeInBudibase.svelte +++ b/packages/client/src/components/FreeLogo.svelte @@ -1,15 +1,20 @@ -
    + import { Link } from "@budibase/bbui" + + +
    Budibase -

    Made In Budibase

    +

    Made with Budibase

    -
    +