Merge branch 'develop' of github.com:Budibase/budibase into public-api-sdk

This commit is contained in:
Andrew Kingston 2022-09-16 11:54:18 +01:00
commit 8a219fa0e6
213 changed files with 6576 additions and 1267 deletions

View File

@ -59,3 +59,9 @@ jobs:
with: with:
install: false install: false
command: yarn test:e2e:ci command: yarn test:e2e:ci
- name: QA Core Integration Tests
run: |
cd qa-core
yarn
yarn api:test:ci

1
.gitignore vendored
View File

@ -63,6 +63,7 @@ typings/
# dotenv environment variables file # dotenv environment variables file
.env .env
!qa-core/.env
!hosting/.env !hosting/.env
hosting/.generated-nginx.dev.conf hosting/.generated-nginx.dev.conf
hosting/proxy/.generated-nginx.prod.conf hosting/proxy/.generated-nginx.prod.conf

View File

@ -65,7 +65,7 @@ Budibase is open-source - licensed as GPL v3. This should fill you with confiden
<br /><br /> <br /><br />
### Load data or start from scratch ### 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).
<p align="center"> <p align="center">
<img alt="Budibase data" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970242/Out%20of%20beta%20launch/data_n1tlhf.png"> <img alt="Budibase data" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970242/Out%20of%20beta%20launch/data_n1tlhf.png">

View File

@ -348,7 +348,7 @@ export interface paths {
} }
} }
responses: { 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: { 200: {
content: { content: {
"application/json": components["schemas"]["tableOutput"] "application/json": components["schemas"]["tableOutput"]
@ -959,7 +959,7 @@ export interface components {
query: { query: {
/** @description The ID of the query. */ /** @description The ID of the query. */
_id: string _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 datasourceId?: string
/** @description The bindings which are required to perform this query. */ /** @description The bindings which are required to perform this query. */
parameters?: string[] parameters?: string[]
@ -983,7 +983,7 @@ export interface components {
data: { data: {
/** @description The ID of the query. */ /** @description The ID of the query. */
_id: string _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 datasourceId?: string
/** @description The bindings which are required to perform this query. */ /** @description The bindings which are required to perform this query. */
parameters?: string[] parameters?: string[]

View File

@ -9,7 +9,11 @@ if [[ "${TARGETBUILD}" = "aas" ]]; then
chown -R couchdb:couchdb $DATA_DIR/couch/ chown -R couchdb:couchdb $DATA_DIR/couch/
apt update apt update
apt-get install -y openssh-server 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 /etc/init.d/ssh restart
sed -i "s#DATA_DIR#/home#g" /opt/clouseau/clouseau.ini sed -i "s#DATA_DIR#/home#g" /opt/clouseau/clouseau.ini
sed -i "s#DATA_DIR#/home#g" /opt/couchdb/etc/local.ini sed -i "s#DATA_DIR#/home#g" /opt/couchdb/etc/local.ini

View File

@ -29,23 +29,8 @@ ENV TARGETBUILD $TARGETBUILD
COPY --from=build /app /app COPY --from=build /app /app
COPY --from=build /worker /worker COPY --from=build /worker /worker
ENV \ # ENV CUSTOM_DOMAIN=budi001.custom.com \
APP_PORT=4001 \ # See runner.sh for Env Vars
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
# These secret env variables are generated by the runner at startup # These secret env variables are generated by the runner at startup
# their values can be overriden by the user, they will be written # their values can be overriden by the user, they will be written
# to the .env file in the /data directory for use later on # 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 # Script below sets the path for storing data based on $DATA_DIR
# For Azure App Service install SSH & point data locations to /home # 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 RUN /build-target-paths.sh
# cleanup cache # cleanup cache
@ -124,6 +111,8 @@ RUN yarn cache clean -f
EXPOSE 80 EXPOSE 80
EXPOSE 443 EXPOSE 443
# Expose port 2222 for SSH on Azure App Service build
EXPOSE 2222
VOLUME /data VOLUME /data
# setup letsencrypt certificate # setup letsencrypt certificate

View File

@ -1,6 +1,21 @@
#!/bin/bash #!/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 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 # Azure App Service customisations
if [[ "${TARGETBUILD}" = "aas" ]]; then if [[ "${TARGETBUILD}" = "aas" ]]; then
DATA_DIR=/home DATA_DIR=/home
@ -10,9 +25,10 @@ else
fi fi
if [ -f "${DATA_DIR}/.env" ]; then 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 fi
# first randomise any unset environment variables # randomise any unset environment variables
for ENV_VAR in "${ENV_VARS[@]}" for ENV_VAR in "${ENV_VARS[@]}"
do do
temp=$(eval "echo \$$ENV_VAR") temp=$(eval "echo \$$ENV_VAR")
@ -30,11 +46,18 @@ if [ ! -f "${DATA_DIR}/.env" ]; then
temp=$(eval "echo \$$ENV_VAR") temp=$(eval "echo \$$ENV_VAR")
echo "$ENV_VAR=$temp" >> ${DATA_DIR}/.env echo "$ENV_VAR=$temp" >> ${DATA_DIR}/.env
done 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 echo "COUCH_DB_URL=${COUCH_DB_URL}" >> ${DATA_DIR}/.env
fi 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 # make these directories in runner, incase of mount
mkdir -p ${DATA_DIR}/couch/{dbs,views} mkdir -p ${DATA_DIR}/couch/{dbs,views}
mkdir -p ${DATA_DIR}/minio mkdir -p ${DATA_DIR}/minio

View File

@ -0,0 +1,8 @@
#!/bin/sh
ssh-keygen -A
#prepare run dir
if [ ! -d "/var/run/sshd" ]; then
mkdir -p /var/run/sshd
fi

View File

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

View File

@ -1,5 +1,5 @@
{ {
"version": "1.3.15-alpha.7", "version": "1.3.19-alpha.6",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -45,8 +45,8 @@
"lint:eslint": "eslint packages", "lint:eslint": "eslint packages",
"lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\"", "lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\"",
"lint": "yarn run lint:eslint && yarn run lint:prettier", "lint": "yarn run lint:eslint && yarn run lint:prettier",
"lint:fix:eslint": "eslint --fix packages", "lint:fix:eslint": "eslint --fix packages qa-core",
"lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\"", "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", "lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint",
"test:e2e": "lerna run cy:test --stream", "test:e2e": "lerna run cy:test --stream",
"test:e2e:ci": "lerna run cy:ci --stream", "test:e2e:ci": "lerna run cy:ci --stream",

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/backend-core", "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", "description": "Budibase backend core libraries used in server and worker",
"main": "dist/src/index.js", "main": "dist/src/index.js",
"types": "dist/src/index.d.ts", "types": "dist/src/index.d.ts",
@ -20,7 +20,7 @@
"test:watch": "jest --watchAll" "test:watch": "jest --watchAll"
}, },
"dependencies": { "dependencies": {
"@budibase/types": "1.3.15-alpha.7", "@budibase/types": "1.3.19-alpha.6",
"@shopify/jest-koa-mocks": "5.0.1", "@shopify/jest-koa-mocks": "5.0.1",
"@techpass/passport-openidconnect": "0.3.2", "@techpass/passport-openidconnect": "0.3.2",
"aws-sdk": "2.1030.0", "aws-sdk": "2.1030.0",

View File

@ -7,6 +7,7 @@ exports.Cookies = {
CurrentApp: "budibase:currentapp", CurrentApp: "budibase:currentapp",
Auth: "budibase:auth", Auth: "budibase:auth",
Init: "budibase:init", Init: "budibase:init",
ACCOUNT_RETURN_URL: "budibase:account:returnurl",
DatasourceAuth: "budibase:datasourceauth", DatasourceAuth: "budibase:datasourceauth",
OIDC_CONFIG: "budibase:oidc:config", OIDC_CONFIG: "budibase:oidc:config",
} }

View File

@ -19,6 +19,7 @@ export enum ViewName {
ROUTING = "screen_routes", ROUTING = "screen_routes",
AUTOMATION_LOGS = "automation_logs", AUTOMATION_LOGS = "automation_logs",
ACCOUNT_BY_EMAIL = "account_by_email", ACCOUNT_BY_EMAIL = "account_by_email",
PLATFORM_USERS_LOWERCASE = "platform_users_lowercase",
} }
export const DeprecatedViews = { export const DeprecatedViews = {

View File

@ -2,7 +2,8 @@ import { newid } from "../hashing"
import { DEFAULT_TENANT_ID, Configs } from "../constants" import { DEFAULT_TENANT_ID, Configs } from "../constants"
import env from "../environment" import env from "../environment"
import { SEPARATOR, DocumentType, UNICODE_MAX, ViewName } from "./constants" 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 fetch from "node-fetch"
import { doWithDB, allDbs } from "./index" import { doWithDB, allDbs } from "./index"
import { getCouchInfo } from "./pouch" import { getCouchInfo } from "./pouch"

View File

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

View File

@ -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<DesignDocument>(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<DesignDocument>(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<DesignDocument>("_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<DesignDocument>(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 <T>(
viewName: ViewName,
params: PouchDB.Query.Options<T, T>,
db: PouchDB.Database,
CreateFuncByName: any,
opts?: QueryViewOptions
): Promise<T[] | T | undefined> => {
try {
let response = await db.query<T, T>(`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 <T>(
viewName: ViewName,
params: PouchDB.Query.Options<T, T>,
opts?: QueryViewOptions
): Promise<T[] | T | undefined> => {
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 <T>(
viewName: ViewName,
params: PouchDB.Query.Options<T, T>,
db?: PouchDB.Database,
opts?: QueryViewOptions
): Promise<T[] | T | undefined> => {
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)
}

View File

@ -37,7 +37,7 @@ const env = {
MULTI_TENANCY: process.env.MULTI_TENANCY, MULTI_TENANCY: process.env.MULTI_TENANCY,
ACCOUNT_PORTAL_URL: ACCOUNT_PORTAL_URL:
process.env.ACCOUNT_PORTAL_URL || "https://account.budibase.app", 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, DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL,
SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED || ""), SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED || ""),
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN, COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,

View File

@ -1,11 +0,0 @@
class BudibaseError extends Error {
constructor(message, code, type) {
super(message)
this.code = code
this.type = type
}
}
module.exports = {
BudibaseError,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
const http = require("./http") import { HTTPError } from "./http"
const licensing = require("./licensing") import { UsageLimitError, FeatureDisabledError } from "./licensing"
import * as licensing from "./licensing"
const codes = { const codes = {
...licensing.codes, ...licensing.codes,
@ -11,7 +12,7 @@ const context = {
...licensing.context, ...licensing.context,
} }
const getPublicError = err => { const getPublicError = (err: any) => {
let error let error
if (err.code || err.type) { if (err.code || err.type) {
// add generic error information // add generic error information
@ -32,13 +33,15 @@ const getPublicError = err => {
return error return error
} }
module.exports = { const pkg = {
codes, codes,
types, types,
errors: { errors: {
UsageLimitError: licensing.UsageLimitError, UsageLimitError,
FeatureDisabledError: licensing.FeatureDisabledError, FeatureDisabledError,
HTTPError: http.HTTPError, HTTPError,
}, },
getPublicError, getPublicError,
} }
export = pkg

View File

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

View File

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

View File

@ -5,8 +5,15 @@ import {
DatasourceCreatedEvent, DatasourceCreatedEvent,
DatasourceUpdatedEvent, DatasourceUpdatedEvent,
DatasourceDeletedEvent, DatasourceDeletedEvent,
SourceName,
} from "@budibase/types" } 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( export async function created(
datasource: Datasource, datasource: Datasource,
timestamp?: string | number timestamp?: string | number
@ -14,6 +21,7 @@ export async function created(
const properties: DatasourceCreatedEvent = { const properties: DatasourceCreatedEvent = {
datasourceId: datasource._id as string, datasourceId: datasource._id as string,
source: datasource.source, source: datasource.source,
custom: isCustom(datasource),
} }
await publishEvent(Event.DATASOURCE_CREATED, properties, timestamp) await publishEvent(Event.DATASOURCE_CREATED, properties, timestamp)
} }
@ -22,6 +30,7 @@ export async function updated(datasource: Datasource) {
const properties: DatasourceUpdatedEvent = { const properties: DatasourceUpdatedEvent = {
datasourceId: datasource._id as string, datasourceId: datasource._id as string,
source: datasource.source, source: datasource.source,
custom: isCustom(datasource),
} }
await publishEvent(Event.DATASOURCE_UPDATED, properties) await publishEvent(Event.DATASOURCE_UPDATED, properties)
} }
@ -30,6 +39,7 @@ export async function deleted(datasource: Datasource) {
const properties: DatasourceDeletedEvent = { const properties: DatasourceDeletedEvent = {
datasourceId: datasource._id as string, datasourceId: datasource._id as string,
source: datasource.source, source: datasource.source,
custom: isCustom(datasource),
} }
await publishEvent(Event.DATASOURCE_DELETED, properties) await publishEvent(Event.DATASOURCE_DELETED, properties)
} }

View File

@ -18,3 +18,4 @@ export * as view from "./view"
export * as installation from "./installation" export * as installation from "./installation"
export * as backfill from "./backfill" export * as backfill from "./backfill"
export * as group from "./group" export * as group from "./group"
export * as plugin from "./plugin"

View File

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

View File

@ -31,20 +31,26 @@ const TENANT_FEATURE_FLAGS = getFeatureFlags()
exports.isEnabled = featureFlag => { exports.isEnabled = featureFlag => {
const tenantId = tenancy.getTenantId() const tenantId = tenancy.getTenantId()
const flags = exports.getTenantFeatureFlags(tenantId)
return ( return flags.includes(featureFlag)
TENANT_FEATURE_FLAGS &&
TENANT_FEATURE_FLAGS[tenantId] &&
TENANT_FEATURE_FLAGS[tenantId].includes(featureFlag)
)
} }
exports.getTenantFeatureFlags = tenantId => { exports.getTenantFeatureFlags = tenantId => {
if (TENANT_FEATURE_FLAGS && TENANT_FEATURE_FLAGS[tenantId]) { const flags = []
return TENANT_FEATURE_FLAGS[tenantId]
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 = { exports.FeatureFlag = {

View File

@ -1,5 +1,4 @@
import errors from "./errors" import errors from "./errors"
const errorClasses = errors.errors const errorClasses = errors.errors
import * as events from "./events" import * as events from "./events"
import * as migrations from "./migrations" import * as migrations from "./migrations"
@ -15,7 +14,7 @@ import deprovisioning from "./context/deprovision"
import auth from "./auth" import auth from "./auth"
import constants from "./constants" import constants from "./constants"
import * as dbConstants from "./db/constants" import * as dbConstants from "./db/constants"
import logging from "./logging" import * as logging from "./logging"
import pino from "./pino" import pino from "./pino"
import * as middleware from "./middleware" import * as middleware from "./middleware"
import plugins from "./plugin" import plugins from "./plugin"

View File

@ -106,6 +106,7 @@ export = (
user = await getUser(userId, session.tenantId) user = await getUser(userId, session.tenantId)
} }
user.csrfToken = session.csrfToken user.csrfToken = session.csrfToken
if (session?.lastAccessedAt < timeMinusOneMinute()) { if (session?.lastAccessedAt < timeMinusOneMinute()) {
// make sure we denote that the session is still in use // make sure we denote that the session is still in use
await updateSessionTTL(session) await updateSessionTTL(session)

View File

@ -17,14 +17,6 @@ export const DEFINITIONS: MigrationDefinition[] = [
type: MigrationType.APP, type: MigrationType.APP,
name: MigrationName.APP_URLS, name: MigrationName.APP_URLS,
}, },
{
type: MigrationType.GLOBAL,
name: MigrationName.DEVELOPER_QUOTA,
},
{
type: MigrationType.GLOBAL,
name: MigrationName.PUBLISHED_APP_QUOTA,
},
{ {
type: MigrationType.APP, type: MigrationType.APP,
name: MigrationName.EVENT_APP_BACKFILL, name: MigrationName.EVENT_APP_BACKFILL,

View File

@ -2,28 +2,12 @@ const redis = require("../redis/init")
const { v4: uuidv4 } = require("uuid") const { v4: uuidv4 } = require("uuid")
const { logWarn } = require("../logging") const { logWarn } = require("../logging")
const env = require("../environment") const env = require("../environment")
import {
interface CreateSession { Session,
sessionId: string ScannedSession,
tenantId: string SessionKey,
csrfToken?: string CreateSession,
} } from "@budibase/types"
interface Session extends CreateSession {
userId: string
lastAccessedAt: string
createdAt: string
// make optional attributes required
csrfToken: string
}
interface SessionKey {
key: string
}
interface ScannedSession {
value: Session
}
// a week in seconds // a week in seconds
const EXPIRY_SECONDS = 86400 * 7 const EXPIRY_SECONDS = 86400 * 7

View File

@ -1,9 +1,11 @@
import * as context from "../context" import * as context from "../context"
import * as tenancy from "./tenancy" import * as tenancy from "./tenancy"
import * as utils from "./utils"
const pkg = { const pkg = {
...context, ...context,
...tenancy, ...tenancy,
...utils,
} }
export = pkg export = pkg

View File

@ -1,6 +1,7 @@
import { doWithDB } from "../db" import { doWithDB } from "../db"
import { StaticDatabases } from "../db/constants" import { queryPlatformView } from "../db/views"
import { baseGlobalDBName } from "./utils" import { StaticDatabases, ViewName } from "../db/constants"
import { getGlobalDBName } from "./utils"
import { import {
getTenantId, getTenantId,
DEFAULT_TENANT_ID, DEFAULT_TENANT_ID,
@ -8,6 +9,7 @@ import {
getTenantIDFromAppID, getTenantIDFromAppID,
} from "../context" } from "../context"
import env from "../environment" import env from "../environment"
import { PlatformUser, PlatformUserByEmail } from "@budibase/types"
const TENANT_DOC = StaticDatabases.PLATFORM_INFO.docs.tenants const TENANT_DOC = StaticDatabases.PLATFORM_INFO.docs.tenants
const PLATFORM_INFO_DB = StaticDatabases.PLATFORM_INFO.name 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) => { export const doWithGlobalDB = (tenantId: string, cb: any) => {
return doWithDB(getGlobalDBName(tenantId), cb) 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 // lookup, could be email or userId, either will return a doc
export const getTenantUser = async (identifier: string) => { export const getTenantUser = async (
return doWithDB(PLATFORM_INFO_DB, async (db: any) => { identifier: string
try { ): Promise<PlatformUser | null> => {
return await db.get(identifier) // use the view here and allow to find anyone regardless of casing
} catch (err) { // Use lowercase to ensure email login is case insensitive
return null const response = queryPlatformView(ViewName.PLATFORM_USERS_LOWERCASE, {
} keys: [identifier.toLowerCase()],
}) include_docs: true,
}) as Promise<PlatformUser>
return response
} }
export const isUserInAppTenant = (appId: string, user: any) => { export const isUserInAppTenant = (appId: string, user: any) => {

View File

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

View File

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

View File

@ -1,29 +1,39 @@
const { import {
ViewName, ViewName,
getUsersByAppParams, getUsersByAppParams,
getProdAppID, getProdAppID,
generateAppUserID, generateAppUserID,
} = require("./db/utils") } from "./db/utils"
const { queryGlobalView } = require("./db/views") import { queryGlobalView } from "./db/views"
const { UNICODE_MAX } = require("./db/constants") import { UNICODE_MAX } from "./db/constants"
import { User } from "@budibase/types"
/** /**
* Given an email address this will use a view to search through * Given an email address this will use a view to search through
* all the users to find one with this email address. * all the users to find one with this email address.
* @param {string} email the email to lookup the user by. * @param {string} email the email to lookup the user by.
*/ */
exports.getGlobalUserByEmail = async email => { export const getGlobalUserByEmail = async (
email: String
): Promise<User | undefined> => {
if (email == null) { if (email == null) {
throw "Must supply an email address to view" throw "Must supply an email address to view"
} }
return await queryGlobalView(ViewName.USER_BY_EMAIL, { const response = await queryGlobalView<User>(ViewName.USER_BY_EMAIL, {
key: email.toLowerCase(), key: email.toLowerCase(),
include_docs: true, 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") { if (typeof appId !== "string") {
throw new Error("Must provide a string based app ID") 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] return Array.isArray(response) ? response : [response]
} }
exports.getGlobalUserByAppPage = (appId, user) => { export const getGlobalUserByAppPage = (appId: string, user: User) => {
if (!user) { if (!user) {
return return
} }
return generateAppUserID(getProdAppID(appId), user._id) return generateAppUserID(getProdAppID(appId), user._id!)
} }
/** /**
* Performs a starts with search on the global email view. * 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") { if (typeof email !== "string") {
throw new Error("Must provide a string to search by") throw new Error("Must provide a string to search by")
} }
const lcEmail = email.toLowerCase() const lcEmail = email.toLowerCase()
// handle if passing up startkey for pagination // handle if passing up startkey for pagination
const startkey = opts && opts.startkey ? opts.startkey : lcEmail const startkey = opts && opts.startkey ? opts.startkey : lcEmail
let response = await queryGlobalView(ViewName.USER_BY_EMAIL, { let response = await queryGlobalView<User>(ViewName.USER_BY_EMAIL, {
...opts, ...opts,
startkey, startkey,
endkey: `${lcEmail}${UNICODE_MAX}`, endkey: `${lcEmail}${UNICODE_MAX}`,

View File

@ -42,6 +42,18 @@ async function resolveAppUrl(ctx) {
return app && app.appId ? app.appId : undefined 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 * 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. * @param {object} ctx The main request body to look through.

View File

@ -1377,6 +1377,11 @@ bcrypt@5.0.1:
"@mapbox/node-pre-gyp" "^1.0.0" "@mapbox/node-pre-gyp" "^1.0.0"
node-addon-api "^3.1.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: binary-extensions@^2.0.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/bbui", "name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.", "description": "A UI solution used in the different Budibase projects.",
"version": "1.3.15-alpha.7", "version": "1.3.19-alpha.6",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"module": "dist/bbui.es.js", "module": "dist/bbui.es.js",
@ -38,7 +38,7 @@
], ],
"dependencies": { "dependencies": {
"@adobe/spectrum-css-workflow-icons": "^1.2.1", "@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/actionbutton": "^1.0.1",
"@spectrum-css/actiongroup": "^1.0.1", "@spectrum-css/actiongroup": "^1.0.1",
"@spectrum-css/avatar": "^3.0.2", "@spectrum-css/avatar": "^3.0.2",

View File

@ -4,22 +4,32 @@
import { banner } from "../Stores/banner" import { banner } from "../Stores/banner"
import Banner from "./Banner.svelte" import Banner from "./Banner.svelte"
import { fly } from "svelte/transition" import { fly } from "svelte/transition"
import TooltipWrapper from "../Tooltip/TooltipWrapper.svelte"
</script> </script>
<Portal target=".banner-container"> <Portal target=".banner-container">
<div class="banner"> <div class="banner">
{#if $banner.message} {#each $banner.messages as message}
<div transition:fly={{ y: -30 }}> <div transition:fly={{ y: -30 }}>
<Banner <Banner
type={$banner.type} type={message.type}
extraButtonText={$banner.extraButtonText} extraButtonText={message.extraButtonText}
extraButtonAction={$banner.extraButtonAction} extraButtonAction={message.extraButtonAction}
on:change={$banner.onChange} on:change={() => {
if (message.onChange) {
message.onChange()
}
}}
showCloseButton={typeof message.showCloseButton === "boolean"
? message.showCloseButton
: true}
> >
{$banner.message} <TooltipWrapper tooltip={message.tooltip} disabled={false}>
{message.message}
</TooltipWrapper>
</Banner> </Banner>
</div> </div>
{/if} {/each}
</div> </div>
</Portal> </Portal>

View File

@ -6,6 +6,7 @@
export let header = "" export let header = ""
export let message = "" export let message = ""
export let onConfirm = undefined export let onConfirm = undefined
export let buttonText = ""
$: icon = selectIcon(type) $: icon = selectIcon(type)
// if newlines used, convert them to different elements // if newlines used, convert them to different elements
@ -39,13 +40,16 @@
<div class="spectrum-InLineAlert-content">{splitMsg}</div> <div class="spectrum-InLineAlert-content">{splitMsg}</div>
{/each} {/each}
{#if onConfirm} {#if onConfirm}
<div class="spectrum-InLineAlert-footer"> <div class="spectrum-InLineAlert-footer button">
<Button secondary on:click={onConfirm}>OK</Button> <Button secondary on:click={onConfirm}>{buttonText || "OK"}</Button>
</div> </div>
{/if} {/if}
</div> </div>
<style> <style>
.button {
margin-top: 10px;
}
.spectrum-InLineAlert { .spectrum-InLineAlert {
--spectrum-semantic-negative-border-color: #e34850; --spectrum-semantic-negative-border-color: #e34850;
--spectrum-semantic-positive-border-color: #2d9d78; --spectrum-semantic-positive-border-color: #2d9d78;

View File

@ -8,6 +8,8 @@
export let duration = 1000 export let duration = 1000
export let width = false export let width = false
export let sideLabel = false export let sideLabel = false
export let hidePercentage = true
export let color // red, green, default = blue
export let size = "M" export let size = "M"
@ -37,7 +39,7 @@
<slot /> <slot />
</div> </div>
{/if} {/if}
{#if value || value === 0} {#if !hidePercentage && (value || value === 0)}
<div <div
class="spectrum-FieldLabel spectrum-ProgressBar-percentage spectrum-FieldLabel--size{size}" class="spectrum-FieldLabel spectrum-ProgressBar-percentage spectrum-FieldLabel--size{size}"
> >
@ -47,8 +49,19 @@
<div class="spectrum-ProgressBar-track"> <div class="spectrum-ProgressBar-track">
<div <div
class="spectrum-ProgressBar-fill" class="spectrum-ProgressBar-fill"
class:color-green={color === "green"}
class:color-red={color === "red"}
style={value || value === 0 ? `width: ${$progress}%` : ""} style={value || value === 0 ? `width: ${$progress}%` : ""}
/> />
</div> </div>
<div class="spectrum-ProgressBar-label" hidden="" /> <div class="spectrum-ProgressBar-label" hidden="" />
</div> </div>
<style>
.color-green {
background: #009562;
}
.color-red {
background: #dd2019;
}
</style>

View File

@ -1,6 +1,7 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
const multilevel = getContext("sidenav-type") const multilevel = getContext("sidenav-type")
import Badge from "../Badge/Badge.svelte"
export let href = "" export let href = ""
export let external = false export let external = false
export let heading = "" export let heading = ""
@ -8,6 +9,7 @@
export let selected = false export let selected = false
export let disabled = false export let disabled = false
export let dataCy export let dataCy
export let badge = ""
</script> </script>
<li <li
@ -38,10 +40,22 @@
</svg> </svg>
{/if} {/if}
<slot /> <slot />
{#if badge}
<div class="badge">
<Badge active size="S">{badge}</Badge>
</div>
{/if}
</a> </a>
{#if multilevel && $$slots.subnav} {#if multilevel && $$slots.subnav}
<ul class="spectrum-SideNav"> <ul class="spectrum-SideNav">
<slot name="subnav" /> <slot name="subnav" />
</ul> </ul>
{/if} {/if}
</li> </li>
<style>
.badge {
margin-left: 10px;
}
</style>

View File

@ -1,7 +1,14 @@
import { writable } from "svelte/store" import { writable } from "svelte/store"
export const BANNER_TYPES = {
INFO: "info",
NEGATIVE: "negative",
}
export function createBannerStore() { export function createBannerStore() {
const DEFAULT_CONFIG = {} const DEFAULT_CONFIG = {
messages: [],
}
const banner = writable(DEFAULT_CONFIG) const banner = writable(DEFAULT_CONFIG)
@ -20,17 +27,38 @@ export function createBannerStore() {
const showStatus = async () => { const showStatus = async () => {
const config = { const config = {
message: "Some systems are experiencing issues", message: "Some systems are experiencing issues",
type: "negative", type: BANNER_TYPES.NEGATIVE,
extraButtonText: "View Status", extraButtonText: "View Status",
extraButtonAction: () => window.open("https://status.budibase.com/"), 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 { return {
subscribe: banner.subscribe, subscribe: banner.subscribe,
showStatus, showStatus,
show,
queue,
} }
} }

View File

@ -4,6 +4,7 @@
export let tooltip = "" export let tooltip = ""
export let size = "M" export let size = "M"
export let disabled = true
let showTooltip = false let showTooltip = false
</script> </script>
@ -19,7 +20,7 @@
on:mouseleave={() => (showTooltip = false)} on:mouseleave={() => (showTooltip = false)}
on:focus on:focus
> >
<Icon name="InfoOutline" size="S" disabled={true} /> <Icon name="InfoOutline" size="S" {disabled} />
</div> </div>
{#if showTooltip} {#if showTooltip}
<div class="tooltip"> <div class="tooltip">
@ -54,7 +55,6 @@
transform: scale(0.75); transform: scale(0.75);
} }
.icon-small { .icon-small {
margin-top: -2px; margin-bottom: -2px;
margin-bottom: -5px;
} }
</style> </style>

View File

@ -34,6 +34,7 @@ export { default as Layout } from "./Layout/Layout.svelte"
export { default as Page } from "./Layout/Page.svelte" export { default as Page } from "./Layout/Page.svelte"
export { default as Link } from "./Link/Link.svelte" export { default as Link } from "./Link/Link.svelte"
export { default as Tooltip } from "./Tooltip/Tooltip.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 Menu } from "./Menu/Menu.svelte"
export { default as MenuSection } from "./Menu/Section.svelte" export { default as MenuSection } from "./Menu/Section.svelte"
export { default as MenuSeparator } from "./Menu/Separator.svelte" export { default as MenuSeparator } from "./Menu/Separator.svelte"
@ -94,7 +95,7 @@ export { default as clickOutside } from "./Actions/click_outside"
// Stores // Stores
export { notifications, createNotificationStore } from "./Stores/notifications" export { notifications, createNotificationStore } from "./Stores/notifications"
export { banner } from "./Stores/banner" export { banner, BANNER_TYPES } from "./Stores/banner"
// Helpers // Helpers
export * as Helpers from "./helpers" export * as Helpers from "./helpers"

View File

@ -82,10 +82,10 @@ filterTests(['smoke', 'all'], () => {
}) })
if (Cypress.env("TEST_ENV")) { if (Cypress.env("TEST_ENV")) {
it("should generate data source screens", () => { it("should generate datasource screens", () => {
// Using MySQL data source for testing this // Using MySQL datasource for testing this
const datasource = "MySQL" const datasource = "MySQL"
// Select & configure MySQL data source // Select & configure MySQL datasource
cy.selectExternalDatasource(datasource) cy.selectExternalDatasource(datasource)
cy.addDatasourceConfig(datasource) cy.addDatasourceConfig(datasource)
// Create Autogenerated screens from a MySQL table - MySQL contains books table // Create Autogenerated screens from a MySQL table - MySQL contains books table

View File

@ -11,8 +11,8 @@ filterTests(["all"], () => {
const queryName = "Cypress Test Query" const queryName = "Cypress Test Query"
const queryRename = "CT Query Rename" const queryRename = "CT Query Rename"
it("Should add MySQL data source without configuration", () => { it("Should add MySQL datasource without configuration", () => {
// Select MySQL data source // Select MySQL datasource
cy.selectExternalDatasource(datasource) cy.selectExternalDatasource(datasource)
// Attempt to fetch tables without applying configuration // Attempt to fetch tables without applying configuration
cy.intercept("**/datasources").as("datasource") cy.intercept("**/datasources").as("datasource")
@ -35,8 +35,8 @@ filterTests(["all"], () => {
cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true }) cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true })
}) })
it("should add MySQL data source and fetch tables", () => { it("should add MySQL datasource and fetch tables", () => {
// Add & configure MySQL data source // Add & configure MySQL datasource
cy.selectExternalDatasource(datasource) cy.selectExternalDatasource(datasource)
cy.intercept("**/datasources").as("datasource") cy.intercept("**/datasources").as("datasource")
cy.addDatasourceConfig(datasource) cy.addDatasourceConfig(datasource)
@ -52,7 +52,7 @@ filterTests(["all"], () => {
}) })
it("should check table fetching error", () => { 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") cy.get(".spectrum-InLineAlert")
.should("contain", "Error fetching tables") .should("contain", "Error fetching tables")
.and("contain", "No primary key constraint found") .and("contain", "No primary key constraint found")

View File

@ -11,8 +11,8 @@ filterTests(["all"], () => {
const queryName = "Cypress Test Query" const queryName = "Cypress Test Query"
const queryRename = "CT Query Rename" const queryRename = "CT Query Rename"
it("Should add Oracle data source and skip table fetch", () => { it("Should add Oracle datasource and skip table fetch", () => {
// Select Oracle data source // Select Oracle datasource
cy.selectExternalDatasource(datasource) cy.selectExternalDatasource(datasource)
// Skip table fetch - no config added // Skip table fetch - no config added
cy.get(".spectrum-Button") cy.get(".spectrum-Button")
@ -23,7 +23,7 @@ filterTests(["all"], () => {
cy.get(".spectrum-Textfield-input", { timeout: 500 }) cy.get(".spectrum-Textfield-input", { timeout: 500 })
.eq(1) .eq(1)
.should("have.value", "localhost") .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.selectExternalDatasource(datasource)
cy.addDatasourceConfig(datasource, true) cy.addDatasourceConfig(datasource, true)
// Confirm config and no tables // Confirm config and no tables
@ -33,8 +33,8 @@ filterTests(["all"], () => {
cy.get(".spectrum-Body").eq(2).should("contain", "No tables found.") cy.get(".spectrum-Body").eq(2).should("contain", "No tables found.")
}) })
it("Should add Oracle data source and fetch tables without configuration", () => { it("Should add Oracle datasource and fetch tables without configuration", () => {
// Select Oracle data source // Select Oracle datasource
cy.selectExternalDatasource(datasource) cy.selectExternalDatasource(datasource)
// Attempt to fetch tables without applying configuration // Attempt to fetch tables without applying configuration
cy.intercept("**/datasources").as("datasource") cy.intercept("**/datasources").as("datasource")
@ -49,8 +49,8 @@ filterTests(["all"], () => {
cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true }) cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true })
}) })
xit("should add Oracle data source and fetch tables", () => { xit("should add Oracle datasource and fetch tables", () => {
// Add & configure Oracle data source // Add & configure Oracle datasource
cy.selectExternalDatasource(datasource) cy.selectExternalDatasource(datasource)
cy.intercept("**/datasources").as("datasource") cy.intercept("**/datasources").as("datasource")
cy.addDatasourceConfig(datasource) cy.addDatasourceConfig(datasource)

View File

@ -11,8 +11,8 @@ filterTests(["all"], () => {
const queryName = "Cypress Test Query" const queryName = "Cypress Test Query"
const queryRename = "CT Query Rename" const queryRename = "CT Query Rename"
xit("Should add PostgreSQL data source without configuration", () => { xit("Should add PostgreSQL datasource without configuration", () => {
// Select PostgreSQL data source // Select PostgreSQL datasource
cy.selectExternalDatasource(datasource) cy.selectExternalDatasource(datasource)
// Attempt to fetch tables without applying configuration // Attempt to fetch tables without applying configuration
cy.intercept("**/datasources").as("datasource") cy.intercept("**/datasources").as("datasource")
@ -27,8 +27,8 @@ filterTests(["all"], () => {
cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true }) cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true })
}) })
it("should add PostgreSQL data source and fetch tables", () => { it("should add PostgreSQL datasource and fetch tables", () => {
// Add & configure PostgreSQL data source // Add & configure PostgreSQL datasource
cy.selectExternalDatasource(datasource) cy.selectExternalDatasource(datasource)
cy.intercept("**/datasources").as("datasource") cy.intercept("**/datasources").as("datasource")
cy.addDatasourceConfig(datasource) cy.addDatasourceConfig(datasource)

View File

@ -10,8 +10,8 @@ filterTests(["smoke", "all"], () => {
const datasource = "REST" const datasource = "REST"
const restUrl = "https://api.openbrewerydb.org/breweries" const restUrl = "https://api.openbrewerydb.org/breweries"
it("Should add REST data source with incorrect API", () => { it("Should add REST datasource with incorrect API", () => {
// Select REST data source // Select REST datasource
cy.selectExternalDatasource(datasource) cy.selectExternalDatasource(datasource)
// Enter incorrect api & attempt to send query // Enter incorrect api & attempt to send query
cy.get(".query-buttons", { timeout: 1000 }).contains("Add query").click({ force: true }) cy.get(".query-buttons", { timeout: 1000 }).contains("Add query").click({ force: true })

View File

@ -763,7 +763,7 @@ Cypress.Commands.add("navigateToDataSection", () => {
}) })
Cypress.Commands.add("navigateToAutogeneratedModal", () => { Cypress.Commands.add("navigateToAutogeneratedModal", () => {
// Screen name must already exist within data source // Screen name must already exist within datasource
cy.contains("Design").click() cy.contains("Design").click()
cy.get(".spectrum-Button").contains("Add screen").click({ force: true }) cy.get(".spectrum-Button").contains("Add screen").click({ force: true })
cy.get(".spectrum-Modal").within(() => { cy.get(".spectrum-Modal").within(() => {
@ -779,7 +779,7 @@ Cypress.Commands.add("navigateToAutogeneratedModal", () => {
Cypress.Commands.add("selectExternalDatasource", datasourceName => { Cypress.Commands.add("selectExternalDatasource", datasourceName => {
// Navigates to Data Section // Navigates to Data Section
cy.navigateToDataSection() cy.navigateToDataSection()
// Open Data Source modal // Open Datasource modal
cy.get(".nav").within(() => { cy.get(".nav").within(() => {
cy.get(".add-button").click() cy.get(".add-button").click()
}) })

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "1.3.15-alpha.7", "version": "1.3.19-alpha.6",
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -69,10 +69,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "1.3.15-alpha.7", "@budibase/bbui": "1.3.19-alpha.6",
"@budibase/client": "1.3.15-alpha.7", "@budibase/client": "1.3.19-alpha.6",
"@budibase/frontend-core": "1.3.15-alpha.7", "@budibase/frontend-core": "1.3.19-alpha.6",
"@budibase/string-templates": "1.3.15-alpha.7", "@budibase/string-templates": "1.3.19-alpha.6",
"@sentry/browser": "5.19.1", "@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1", "@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1", "@spectrum-css/vars": "^3.0.1",

View File

@ -4,6 +4,7 @@
import { NotificationDisplay, BannerDisplay } from "@budibase/bbui" import { NotificationDisplay, BannerDisplay } from "@budibase/bbui"
import { parse, stringify } from "qs" import { parse, stringify } from "qs"
import HelpIcon from "components/common/HelpIcon.svelte" import HelpIcon from "components/common/HelpIcon.svelte"
import LicensingOverlays from "components/portal/licensing/LicensingOverlays.svelte"
const queryHandler = { parse, stringify } const queryHandler = { parse, stringify }
</script> </script>
@ -12,6 +13,9 @@
<BannerDisplay /> <BannerDisplay />
<NotificationDisplay /> <NotificationDisplay />
<LicensingOverlays />
<Router {routes} config={{ queryHandler }} /> <Router {routes} config={{ queryHandler }} />
<div class="modal-container" /> <div class="modal-container" />
<HelpIcon /> <HelpIcon />

View File

@ -27,7 +27,7 @@ export async function saveDatasource(config, skipFetch = false) {
// Create datasource // Create datasource
const resp = await datasources.save(datasource, !skipFetch && datasource.plus) const resp = await datasources.save(datasource, !skipFetch && datasource.plus)
// update the tables incase data source plus // update the tables incase datasource plus
await tables.fetch() await tables.fetch()
await datasources.select(resp._id) await datasources.select(resp._id)
return resp return resp

View File

@ -1,5 +1,6 @@
import { getFrontendStore } from "./store/frontend" import { getFrontendStore } from "./store/frontend"
import { getAutomationStore } from "./store/automation" import { getAutomationStore } from "./store/automation"
import { getTemporalStore } from "./store/temporal"
import { getThemeStore } from "./store/theme" import { getThemeStore } from "./store/theme"
import { derived } from "svelte/store" import { derived } from "svelte/store"
import { findComponent, findComponentPath } from "./componentUtils" import { findComponent, findComponentPath } from "./componentUtils"
@ -8,6 +9,7 @@ import { RoleUtils } from "@budibase/frontend-core"
export const store = getFrontendStore() export const store = getFrontendStore()
export const automationStore = getAutomationStore() export const automationStore = getAutomationStore()
export const themeStore = getThemeStore() export const themeStore = getThemeStore()
export const temporalStore = getTemporalStore()
export const selectedScreen = derived(store, $store => { export const selectedScreen = derived(store, $store => {
return $store.screens.find(screen => screen._id === $store.selectedScreenId) return $store.screens.find(screen => screen._id === $store.selectedScreenId)

View File

@ -0,0 +1,43 @@
import { createLocalStorageStore } from "@budibase/frontend-core"
import { get } from "svelte/store"
export const getTemporalStore = () => {
const initialValue = {}
const localStorageKey = `bb-temporal`
const store = createLocalStorageStore(localStorageKey, initialValue)
const setExpiring = (key, data, duration) => {
const updated = {
...data,
expiry: Date.now() + duration * 1000,
}
store.update(state => ({
...state,
[key]: updated,
}))
}
const getExpiring = key => {
const entry = get(store)[key]
if (!entry) {
return
}
const currentExpiry = entry.expiry
if (currentExpiry < Date.now()) {
store.update(state => {
delete state[key]
return state
})
return null
} else {
return entry
}
}
return {
subscribe: store.subscribe,
actions: { setExpiring, getExpiring },
}
}

View File

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

View File

@ -32,8 +32,8 @@
: [] : []
$: openDataSource = enrichedDataSources.find(x => x.open) $: openDataSource = enrichedDataSources.find(x => x.open)
$: { $: {
// Ensure the open data source is always included in the list of open // Ensure the open datasource is always included in the list of open
// data sources // datasources
if (openDataSource) { if (openDataSource) {
openNode(openDataSource) openNode(openDataSource)
} }
@ -79,7 +79,7 @@
}) })
const containsActiveEntity = datasource => { 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) { if ($params.selectedDatasource === datasource._id) {
return true return true
} }

View File

@ -126,7 +126,7 @@
<Modal bind:this={modal}> <Modal bind:this={modal}>
<ModalContent <ModalContent
disabled={!Object.keys(integration).length} disabled={!Object.keys(integration).length}
title="Add data source" title="Add datasource"
confirmText="Continue" confirmText="Continue"
showSecondaryButton={showImportButton} showSecondaryButton={showImportButton}
secondaryButtonText="Import" secondaryButtonText="Import"
@ -155,7 +155,7 @@
</Layout> </Layout>
<Layout noPadding gap="XS"> <Layout noPadding gap="XS">
<Body size="S">Connect to an external data source</Body> <Body size="S">Connect to an external datasource</Body>
<div class="item-list"> <div class="item-list">
{#each Object.entries(integrations).filter(([key, val]) => key !== IntegrationTypes.INTERNAL && !val.custom) as [integrationType, schema]} {#each Object.entries(integrations).filter(([key, val]) => key !== IntegrationTypes.INTERNAL && !val.custom) as [integrationType, schema]}
<DatasourceCard <DatasourceCard
@ -170,7 +170,7 @@
{#if customIntegrations.length > 0} {#if customIntegrations.length > 0}
<Layout noPadding gap="XS"> <Layout noPadding gap="XS">
<Body size="S">Custom data source</Body> <Body size="S">Custom datasource</Body>
<div class="item-list"> <div class="item-list">
{#each customIntegrations as [integrationType, schema]} {#each customIntegrations as [integrationType, schema]}
<DatasourceCard <DatasourceCard

View File

@ -10,6 +10,7 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import TemplateCard from "components/common/TemplateCard.svelte" import TemplateCard from "components/common/TemplateCard.svelte"
import CreateAppModal from "components/start/CreateAppModal.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte"
import { licensing } from "stores/portal"
export let templates export let templates
@ -96,15 +97,17 @@
backgroundColour={templateEntry.background} backgroundColour={templateEntry.background}
icon={templateEntry.icon} icon={templateEntry.icon}
> >
<Button {#if $licensing?.usageMetrics?.apps < 100}
cta <Button
on:click={() => { cta
template = templateEntry on:click={() => {
creationModal.show() template = templateEntry
}} creationModal.show()
> }}
Use template >
</Button> Use template
</Button>
{/if}
<a <a
href={templateEntry.url} href={templateEntry.url}
target="_blank" target="_blank"

View File

@ -73,13 +73,13 @@
<div class="root"> <div class="root">
<Body size="S"> <Body size="S">
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.
<br /> <br />
You can always add or override fields manually. You can always add or override fields manually.
</Body> </Body>
<div class="params"> <div class="params">
<Label small>Data Source</Label> <Label small>Datasource</Label>
<Select <Select
bind:value={parameters.providerId} bind:value={parameters.providerId}
options={providerOptions} options={providerOptions}

View File

@ -71,13 +71,13 @@
<div class="root"> <div class="root">
<Body size="S"> <Body size="S">
Choosing a Data Source will automatically use the data it provides, but it's Choosing a Datasource will automatically use the data it provides, but it's
optional.<br /> optional.<br />
You can always add or override fields manually. You can always add or override fields manually.
</Body> </Body>
<div class="params"> <div class="params">
<Label small>Data Source</Label> <Label small>Datasource</Label>
<Select <Select
bind:value={parameters.providerId} bind:value={parameters.providerId}
options={providerOptions} options={providerOptions}

View File

@ -0,0 +1,49 @@
<script>
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()
}
</script>
<Modal bind:this={accountDowngradeModal} on:show={onShow} on:hide={onDismiss}>
<ModalContent
title="Your account is now on the Free plan"
size="M"
showCancelButton={$auth.user.accountPortalAccess}
confirmText={$auth.user.accountPortalAccess ? "Upgrade" : "Confirm"}
onConfirm={$auth.user.accountPortalAccess
? () => {
window.location.href = upgradeUrl
}
: null}
>
<Body>
The payment for your subscription has failed and we have downgraded your
account to the <span class="free-plan">Free plan</span>.
</Body>
<Body>Upgrade to restore full functionality.</Body>
{#if !$auth.user.accountPortalAccess}
<Body>Please contact the account holder to upgrade.</Body>
{/if}
</ModalContent>
</Modal>
<style>
.free-plan {
font-weight: 600;
}
</style>

View File

@ -0,0 +1,47 @@
<script>
import { Modal, ModalContent, Body } from "@budibase/bbui"
import { auth, admin } from "stores/portal"
export let onDismiss = () => {}
let appLimitModal
$: accountUrl = $admin.accountPortalUrl
$: upgradeUrl = `${accountUrl}/portal/upgrade`
export function show() {
appLimitModal.show()
}
export function hide() {
appLimitModal.hide()
}
</script>
<Modal bind:this={appLimitModal} on:hide={onDismiss}>
<ModalContent
title="Upgrade to get more apps "
size="M"
showCancelButton={false}
confirmText={$auth.user.accountPortalAccess ? "Upgrade" : "Confirm"}
onConfirm={$auth.user.accountPortalAccess
? () => {
window.location.href = upgradeUrl
}
: null}
>
<Body>
You are currently on our <span class="free-plan">Free plan</span>. Upgrade
to our Pro plan to get unlimited apps and additional features.
</Body>
{#if !$auth.user.accountPortalAccess}
<Body>Please contact the account holder to upgrade.</Body>
{/if}
</ModalContent>
</Modal>
<style>
.free-plan {
font-weight: 600;
}
</style>

View File

@ -0,0 +1,78 @@
<script>
import { Modal, ModalContent, Body, TooltipWrapper } from "@budibase/bbui"
import { licensing, auth, admin } from "stores/portal"
export let onDismiss = () => {}
export let onShow = () => {}
let dayPassModal
$: accountUrl = $admin.accountPortalUrl
$: upgradeUrl = `${accountUrl}/portal/upgrade`
$: daysRemaining = $licensing.quotaResetDaysRemaining
$: quotaResetDate = $licensing.quotaResetDate
$: dayPassesUsed = $licensing.usageMetrics?.dayPasses
$: dayPassesTitle =
dayPassesUsed >= 100
? "You have run out of Day Passes"
: "You are almost out of Day Passes"
$: dayPassesBody =
dayPassesUsed >= 100
? "Upgrade your account to bring your apps back online."
: "Upgrade your account to prevent your apps from going offline."
export function show() {
dayPassModal.show()
}
export function hide() {
dayPassModal.hide()
}
</script>
<Modal bind:this={dayPassModal} on:show={onShow} on:hide={onDismiss}>
{#if $auth.user.accountPortalAccess}
<ModalContent
title={dayPassesTitle}
size="M"
confirmText="Upgrade"
onConfirm={() => {
window.location.href = upgradeUrl
}}
>
<Body>
You have used <span class="daypass_percent">{dayPassesUsed}%</span> of
your plans Day Passes with {daysRemaining} day{daysRemaining == 1
? ""
: "s"} remaining.
<span class="tooltip">
<TooltipWrapper tooltip={quotaResetDate} size="S" />
</span>
</Body>
<Body>{dayPassesBody}</Body>
</ModalContent>
{:else}
<ModalContent title={dayPassesTitle} size="M" showCancelButton={false}>
<Body>
You have used <span class="daypass_percent">{dayPassesUsed}%</span> of
your plans Day Passes with {daysRemaining} day{daysRemaining == 1
? ""
: "s"} remaining.
<span class="tooltip">
<TooltipWrapper tooltip={quotaResetDate} size="S" />
</span>
</Body>
<Body>Please contact your account holder to upgrade.</Body>
</ModalContent>
{/if}
</Modal>
<style>
.tooltip {
display: inline-block;
}
.tooltip :global(.icon-container) {
margin: 0px;
}
</style>

View File

@ -0,0 +1,112 @@
<script>
import { licensing, auth } from "stores/portal"
import { temporalStore } from "builderStore"
import { onMount } from "svelte"
import DayPassWarningModal from "./DayPassWarningModal.svelte"
import PaymentFailedModal from "./PaymentFailedModal.svelte"
import AccountDowngradedModal from "./AccountDowngradedModal.svelte"
import { ExpiringKeys } from "./constants"
import { getBanners } from "./licensingBanners"
import { banner } from "@budibase/bbui"
const oneDayInSeconds = 86400
let queuedBanners = []
let queuedModals = []
let dayPassModal
let paymentFailedModal
let accountDowngradeModal
let userLoaded = false
let loaded = false
let licensingLoaded = false
let currentModalCfg = null
const processModals = () => {
const defaultCacheFn = key => {
temporalStore.actions.setExpiring(key, {}, oneDayInSeconds)
}
const dismissableModals = [
{
key: ExpiringKeys.LICENSING_DAYPASS_WARNING_MODAL,
criteria: () => {
return $licensing?.usageMetrics?.dayPasses >= 90
},
action: () => {
dayPassModal.show()
},
cache: () => {
defaultCacheFn(ExpiringKeys.LICENSING_DAYPASS_WARNING_MODAL)
},
},
{
key: ExpiringKeys.LICENSING_PAYMENT_FAILED,
criteria: () => {
return $licensing.accountPastDue && !$licensing.isFreePlan()
},
action: () => {
paymentFailedModal.show()
},
cache: () => {
defaultCacheFn(ExpiringKeys.LICENSING_PAYMENT_FAILED)
},
},
{
key: ExpiringKeys.LICENSING_ACCOUNT_DOWNGRADED_MODAL,
criteria: () => {
return $licensing?.accountDowngraded
},
action: () => {
accountDowngradeModal.show()
},
cache: () => {
defaultCacheFn(ExpiringKeys.LICENSING_ACCOUNT_DOWNGRADED_MODAL)
},
},
]
return dismissableModals.filter(modal => {
return !temporalStore.actions.getExpiring(modal.key) && modal.criteria()
})
}
const showNextModal = () => {
if (currentModalCfg) {
currentModalCfg.cache()
}
if (queuedModals.length) {
currentModalCfg = queuedModals.shift()
currentModalCfg.action()
} else {
currentModalCfg = null
}
}
$: if (userLoaded && licensingLoaded && loaded) {
queuedModals = processModals()
queuedBanners = getBanners()
showNextModal()
banner.queue(queuedBanners)
}
onMount(async () => {
auth.subscribe(state => {
if (state.user && !userLoaded) {
userLoaded = true
}
})
licensing.subscribe(state => {
if (state.usageMetrics && !licensingLoaded) {
licensingLoaded = true
}
})
loaded = true
})
</script>
<DayPassWarningModal bind:this={dayPassModal} onDismiss={showNextModal} />
<PaymentFailedModal bind:this={paymentFailedModal} onDismiss={showNextModal} />
<AccountDowngradedModal
bind:this={accountDowngradeModal}
onDismiss={showNextModal}
/>

View File

@ -0,0 +1,84 @@
<script>
import { Modal, ModalContent, Body, TooltipWrapper } from "@budibase/bbui"
import { auth, admin, licensing } from "stores/portal"
import { onMount } from "svelte"
export let onDismiss = () => {}
export let onShow = () => {}
let paymentFailedModal
let pastDueEndDate
const paymentFailedTitle = "Payment failed"
$: accountUrl = $admin.accountPortalUrl
$: upgradeUrl = `${accountUrl}/portal/upgrade`
export function show() {
paymentFailedModal.show()
}
export function hide() {
paymentFailedModal.hide()
}
onMount(() => {
licensing.subscribe(state => {
pastDueEndDate = state.pastDueEndDate
})
})
</script>
<Modal bind:this={paymentFailedModal} on:show={onShow} on:hide={onDismiss}>
{#if $auth.user.accountPortalAccess}
<ModalContent
title={paymentFailedTitle}
size="M"
confirmText="Upgrade"
onConfirm={() => {
window.location.href = upgradeUrl
}}
>
<Body>The payment for your subscription has failed</Body>
<Body>
Please upgrade your billing details before your account gets downgraded
to the free plan
</Body>
<Body weight={800}>
<div class="tooltip-root">
{`${$licensing.pastDueDaysRemaining} day${
$licensing.pastDueDaysRemaining == 1 ? "" : "s"
} remaining`}
<span class="tooltip">
<TooltipWrapper tooltip={pastDueEndDate} size="S" />
</span>
</div>
</Body>
</ModalContent>
{:else}
<ModalContent title={paymentFailedTitle} size="M" showCancelButton={false}>
<Body>The payment for your subscription has failed</Body>
<Body>
Please upgrade your billing details before your account gets downgraded
to the free plan
</Body>
<Body>Please contact your account holder.</Body>
<Body weight={800}>
<div class="tooltip-root">
{`${$licensing.pastDueDaysRemaining} day${
$licensing.pastDueDaysRemaining == 1 ? "" : "s"
} remaining`}
<span class="tooltip">
<TooltipWrapper tooltip={pastDueEndDate} size="S" />
</span>
</div>
</Body>
</ModalContent>
{/if}
</Modal>
<style>
.tooltip-root {
display: flex;
align-items: center;
}
</style>

View File

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

View File

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

View File

@ -1,10 +1,15 @@
<script> <script>
import { Body, ProgressBar, Label } from "@budibase/bbui" import { Body, ProgressBar, Heading, Icon, Link } from "@budibase/bbui"
import { admin, auth } from "../../stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
export let usage export let usage
export let warnWhenFull = false
let percentage let percentage
let unlimited = false let unlimited = false
let showWarning = false
$: accountPortalAccess = $auth?.user?.accountPortalAccess
const isUnlimited = () => { const isUnlimited = () => {
if (usage.total === -1) { if (usage.total === -1) {
@ -14,29 +19,62 @@
} }
const getPercentage = () => { const getPercentage = () => {
return Math.min(Math.ceil((usage.used / usage.total) * 100), 100) return (usage.used / usage.total) * 100
} }
const upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
onMount(() => { onMount(() => {
unlimited = isUnlimited() unlimited = isUnlimited()
percentage = getPercentage() percentage = getPercentage()
if (warnWhenFull && percentage === 100) {
showWarning = true
}
}) })
</script> </script>
<div class="usage"> <div class="usage">
<div class="info"> <div class="info">
<Label size="XL">{usage.name}</Label> <div class="header-container">
{#if showWarning}
<Icon name="Alert" />
{/if}
<div class="heading header-item">
<Heading size="XS" weight="light">{usage.name}</Heading>
</div>
</div>
{#if unlimited} {#if unlimited}
<Body size="S">{usage.used}</Body> <Body size="S">{usage.used} / Unlimited</Body>
{:else} {:else}
<Body size="S">{usage.used} / {usage.total}</Body> <Body size="S">{usage.used} / {usage.total}</Body>
{/if} {/if}
</div> </div>
<div> <div>
{#if unlimited} {#if unlimited}
<Body size="S">Unlimited</Body> <ProgressBar
showPercentage={false}
width={"100%"}
duration={1}
value={100}
/>
{:else} {:else}
<ProgressBar width={"100%"} duration={1} value={percentage} /> <ProgressBar
color={showWarning ? "red" : "green"}
showPercentage={false}
width={"100%"}
duration={1}
value={percentage}
/>
{/if}
{#if showWarning}
<Body size="S">
To get more {usage.name.toLowerCase()}
{#if accountPortalAccess}
<Link href={upgradeUrl}>upgrade your plan</Link>
{:else}
contact your account holder
{/if}
</Body>
{/if} {/if}
</div> </div>
</div> </div>
@ -51,6 +89,13 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
gap: var(--spacing-m); margin-bottom: 12px;
}
.header-container {
display: flex;
}
.heading {
margin-top: 3px;
margin-left: 5px;
} }
</style> </style>

View File

@ -0,0 +1,119 @@
<script>
import {
Detail,
Button,
Heading,
Layout,
Body,
TooltipWrapper,
} from "@budibase/bbui"
export let description = ""
export let title = ""
export let primaryAction
export let secondaryAction
export let primaryActionText
export let secondaryActionText
export let primaryCta = true
export let textRows = []
$: primaryDefined = primaryAction && primaryActionText
$: secondaryDefined = secondaryAction && secondaryActionText
</script>
<div class="dash-card">
<div class="dash-card-header">
<div class="header-info">
<Layout gap="XS">
<div class="dash-card-title">
<Detail size="M">{description}</Detail>
</div>
<Heading size="M">{title}</Heading>
{#if textRows.length}
<div class="text-rows">
{#each textRows as row}
{#if row.tooltip}
<TooltipWrapper tooltip={row.tooltip}>
<Body>{row.message}</Body>
</TooltipWrapper>
{:else}
<Body>{row.message}</Body>
{/if}
{/each}
</div>
{/if}
</Layout>
</div>
<div class="header-actions">
{#if secondaryDefined}
<div>
<Button newStyles secondary on:click={secondaryAction}
>{secondaryActionText}</Button
>
</div>
{/if}
{#if primaryDefined}
<div class="primary-button">
<Button cta={primaryCta} on:click={primaryAction}
>{primaryActionText}</Button
>
</div>
{/if}
</div>
</div>
<div class="dash-card-body">
<slot />
</div>
</div>
<style>
.dash-card {
background: var(--spectrum-alias-background-color-primary);
border-radius: var(--border-radius-s);
overflow: hidden;
min-height: 150px;
}
.dash-card-header {
padding: 15px 25px 20px;
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
display: flex;
flex-direction: row;
justify-content: space-between;
}
.dash-card-body {
padding: 25px 30px;
}
.dash-card-title :global(.spectrum-Detail) {
color: var(
--spectrum-sidenav-heading-text-color,
var(--spectrum-global-color-gray-700)
);
display: inline-block;
}
.header-info {
flex: 1;
}
.header-actions {
flex: 1;
margin-top: 15px;
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.header-actions :global(:first-child) {
margin-right: 5px;
}
.text-rows {
margin-top: 10px;
}
@media only screen and (max-width: 900px) {
.dash-card-header {
flex-direction: column;
}
.header-actions {
justify-content: flex-start "bul";
}
}
</style>

View File

@ -0,0 +1,2 @@
export { default as Usage } from "./Usage.svelte"
export { default as DashCard } from "./UsageDashCard.svelte"

View File

@ -58,6 +58,13 @@ export const DefaultAppTheme = {
navTextColor: "var(--spectrum-global-color-gray-800)", navTextColor: "var(--spectrum-global-color-gray-800)",
} }
export const PlanType = {
FREE: "free",
PRO: "pro",
BUSINESS: "business",
ENTERPRISE: "enterprise",
}
export const PluginSource = { export const PluginSource = {
URL: "URL", URL: "URL",
NPM: "NPM", NPM: "NPM",

View File

@ -1,6 +1,6 @@
<script> <script>
import { isActive, redirect, params } from "@roxi/routify" import { isActive, redirect, params } from "@roxi/routify"
import { admin, auth } from "stores/portal" import { admin, auth, licensing } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import { CookieUtils, Constants } from "@budibase/frontend-core" import { CookieUtils, Constants } from "@budibase/frontend-core"
import { API } from "api" import { API } from "api"
@ -63,6 +63,10 @@
await auth.getSelf() await auth.getSelf()
await admin.init() await admin.init()
if ($auth.user) {
await licensing.init()
}
// Set init info if present // Set init info if present
if ($params["?template"]) { if ($params["?template"]) {
await auth.setInitInfo({ init_template: $params["?template"] }) await auth.setInitInfo({ init_template: $params["?template"] })

View File

@ -23,7 +23,8 @@
"dataprovider", "dataprovider",
"repeater", "repeater",
"table", "table",
"dynamicfilter" "dynamicfilter",
"daterangepicker"
] ]
}, },
{ {
@ -60,7 +61,6 @@
"booleanfield", "booleanfield",
"longformfield", "longformfield",
"attachmentfield", "attachmentfield",
"daterangepicker",
"jsonfield", "jsonfield",
"relationshipfield", "relationshipfield",
"datetimefield", "datetimefield",
@ -81,4 +81,3 @@
] ]
} }
] ]

View File

@ -77,7 +77,7 @@
size="L" size="L"
> >
<Body size="S"> <Body size="S">
Select which data source you would like to use to create your screens Select which datasource you would like to use to create your screens
</Body> </Body>
<Layout noPadding gap="S"> <Layout noPadding gap="S">
{#each filteredSources as datasource} {#each filteredSources as datasource}

View File

@ -66,7 +66,7 @@
<Heading size="XS">Autogenerated screens</Heading> <Heading size="XS">Autogenerated screens</Heading>
<Body size="S"> <Body size="S">
Add autogenerated screens with CRUD functionality to get a working Add autogenerated screens with CRUD functionality to get a working
app quickly! (Requires a data source) app quickly! (Requires a datasource)
</Body> </Body>
</div> </div>
</div> </div>

View File

@ -13,13 +13,14 @@
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import { apps, organisation, auth, groups } from "stores/portal" import { apps, organisation, auth, groups, licensing } from "stores/portal"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { gradient } from "actions" import { gradient } from "actions"
import UpdateUserInfoModal from "components/settings/UpdateUserInfoModal.svelte" import UpdateUserInfoModal from "components/settings/UpdateUserInfoModal.svelte"
import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte" import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
import Spaceman from "assets/bb-space-man.svg"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
let loaded = false let loaded = false
@ -91,7 +92,7 @@
<div class="content"> <div class="content">
<Layout noPadding> <Layout noPadding>
<div class="header"> <div class="header">
<img alt="logo" src={$organisation.logoUrl || Logo} /> <img class="logo" alt="logo" src={$organisation.logoUrl || Logo} />
<ActionMenu align="right" dataCy="user-menu"> <ActionMenu align="right" dataCy="user-menu">
<div slot="control" class="avatar"> <div slot="control" class="avatar">
<Avatar <Avatar
@ -131,7 +132,17 @@
</Body> </Body>
</Layout> </Layout>
<Divider /> <Divider />
{#if userApps.length} {#if $licensing.usageMetrics.dayPasses >= 100}
<div>
<Layout gap="S" justifyItems="center">
<img class="spaceman" alt="spaceman" src={Spaceman} />
<Heading size="M">
{"Your apps are currently offline."}
</Heading>
Please contact the account holder to get them back online.
</Layout>
</div>
{:else if userApps.length}
<Heading>Apps</Heading> <Heading>Apps</Heading>
<div class="group"> <div class="group">
<Layout gap="S" noPadding> <Layout gap="S" noPadding>
@ -194,10 +205,13 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
img { img.logo {
width: 40px; width: 40px;
margin-bottom: -12px; margin-bottom: -12px;
} }
img.spaceman {
width: 100px;
}
.avatar { .avatar {
display: grid; display: grid;
grid-template-columns: auto auto; grid-template-columns: auto auto;

View File

@ -13,8 +13,7 @@
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import ConfigChecklist from "components/common/ConfigChecklist.svelte" import ConfigChecklist from "components/common/ConfigChecklist.svelte"
import { organisation, auth } from "stores/portal" import { organisation, auth, admin as adminStore } from "stores/portal"
import { admin as adminStore } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import UpdateUserInfoModal from "components/settings/UpdateUserInfoModal.svelte" import UpdateUserInfoModal from "components/settings/UpdateUserInfoModal.svelte"
import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte" import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte"
@ -37,14 +36,6 @@
href: "/builder/portal/apps", href: "/builder/portal/apps",
}, },
] ]
if (isEnabled(FEATURE_FLAGS.LICENSING)) {
menu = menu.concat([
{
title: "Usage",
href: "/builder/portal/settings/usage",
},
])
}
if (admin) { if (admin) {
menu = menu.concat([ menu = menu.concat([
@ -53,9 +44,20 @@
href: "/builder/portal/manage/users", href: "/builder/portal/manage/users",
heading: "Manage", heading: "Manage",
}, },
isEnabled(FEATURE_FLAGS.USER_GROUPS)
? {
title: "User Groups",
href: "/builder/portal/manage/groups",
badge: "New",
}
: undefined,
{ title: "Auth", href: "/builder/portal/manage/auth" }, { title: "Auth", href: "/builder/portal/manage/auth" },
{ title: "Email", href: "/builder/portal/manage/email" }, { title: "Email", href: "/builder/portal/manage/email" },
{ title: "Plugins", href: "/builder/portal/manage/plugins" }, {
title: "Plugins",
href: "/builder/portal/manage/plugins",
badge: "New",
},
{ {
title: "Organisation", title: "Organisation",
@ -68,15 +70,6 @@
}, },
]) ])
if (isEnabled(FEATURE_FLAGS.USER_GROUPS)) {
let item = {
title: "User Groups",
href: "/builder/portal/manage/groups",
}
menu.splice(2, 0, item)
}
if (!$adminStore.cloud) { if (!$adminStore.cloud) {
menu = menu.concat([ menu = menu.concat([
{ {
@ -84,13 +77,6 @@
href: "/builder/portal/settings/update", href: "/builder/portal/settings/update",
}, },
]) ])
if (isEnabled(FEATURE_FLAGS.LICENSING)) {
menu = menu.concat({
title: "Upgrade",
href: "/builder/portal/settings/upgrade",
})
}
} }
} else { } else {
menu = menu.concat([ menu = menu.concat([
@ -103,14 +89,62 @@
} }
// add link to account portal if the user has access // add link to account portal if the user has access
if ($auth?.user?.accountPortalAccess) { let accountSectionAdded = false
// link out to account-portal if account holder in cloud or always in self-host
if ($auth?.user?.accountPortalAccess || (!$adminStore.cloud && admin)) {
accountSectionAdded = true
menu = menu.concat([ menu = menu.concat([
{ {
title: "Account", title: "Account",
href: $adminStore.accountPortalUrl, href: $adminStore.accountPortalUrl,
heading: "Account",
}, },
]) ])
} }
if (isEnabled(FEATURE_FLAGS.LICENSING)) {
// always show usage in self-host or cloud if licensing enabled
menu = menu.concat([
{
title: "Usage",
href: "/builder/portal/settings/usage",
heading: accountSectionAdded ? "" : "Account",
},
])
// show the relevant hosting upgrade page
if ($adminStore.cloud && $auth?.user?.accountPortalAccess) {
menu = menu.concat([
{
title: "Upgrade",
href: $adminStore.accountPortalUrl + "/portal/upgrade",
badge: "New",
},
])
} else if (!$adminStore.cloud && admin) {
menu = menu.concat({
title: "Upgrade",
href: "/builder/portal/settings/upgrade",
badge: "New",
})
}
// show the billing page to licensed account holders in cloud
if (
$auth?.user?.accountPortalAccess &&
$auth.user.account.stripeCustomerId
) {
menu = menu.concat([
{
title: "Billing",
href: $adminStore.accountPortalUrl + "/portal/billing",
},
])
}
}
menu = menu.filter(item => !!item)
return menu return menu
} }
@ -161,11 +195,12 @@
</div> </div>
<div class="menu"> <div class="menu">
<Navigation> <Navigation>
{#each menu as { title, href, heading }} {#each menu as { title, href, heading, badge }}
<Item <Item
on:click={hideMobileMenu} on:click={hideMobileMenu}
selected={$isActive(href)} selected={$isActive(href)}
{href} {href}
{badge}
{heading}>{title}</Item {heading}>{title}</Item
> >
{/each} {/each}

View File

@ -13,12 +13,14 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import CreateAppModal from "components/start/CreateAppModal.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte"
import TemplateDisplay from "components/common/TemplateDisplay.svelte" import TemplateDisplay from "components/common/TemplateDisplay.svelte"
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import { onMount } from "svelte" import { onMount } from "svelte"
import { templates } from "stores/portal" import { templates, licensing } from "stores/portal"
let loaded = $templates?.length let loaded = $templates?.length
let template let template
let creationModal = false let creationModal = false
let appLimitModal
let creatingApp = false let creatingApp = false
const welcomeBody = const welcomeBody =
@ -29,6 +31,8 @@
onMount(async () => { onMount(async () => {
try { try {
await templates.load() await templates.load()
await licensing.getQuotaUsage()
await licensing.getUsageMetrics()
if ($templates?.length === 0) { if ($templates?.length === 0) {
notifications.error( notifications.error(
"There was a problem loading quick start templates." "There was a problem loading quick start templates."
@ -41,9 +45,13 @@
}) })
const initiateAppCreation = () => { const initiateAppCreation = () => {
template = null if ($licensing.usageMetrics.apps >= 100) {
creationModal.show() appLimitModal.show()
creatingApp = true } else {
template = null
creationModal.show()
creatingApp = true
}
} }
const stopAppCreation = () => { const stopAppCreation = () => {
@ -52,9 +60,13 @@
} }
const initiateAppImport = () => { const initiateAppImport = () => {
template = { fromFile: true } if ($licensing.usageMetrics.apps >= 100) {
creationModal.show() appLimitModal.show()
creatingApp = true } else {
template = { fromFile: true }
creationModal.show()
creatingApp = true
}
} }
</script> </script>
@ -121,6 +133,7 @@
> >
<CreateAppModal {template} /> <CreateAppModal {template} />
</Modal> </Modal>
<AppLimitModal bind:this={appLimitModal} />
<style> <style>
.title .welcome > .buttons { .title .welcome > .buttons {

View File

@ -15,11 +15,12 @@
import Spinner from "components/common/Spinner.svelte" import Spinner from "components/common/Spinner.svelte"
import CreateAppModal from "components/start/CreateAppModal.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte"
import UpdateAppModal from "components/start/UpdateAppModal.svelte" import UpdateAppModal from "components/start/UpdateAppModal.svelte"
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import { store, automationStore } from "builderStore" import { store, automationStore } from "builderStore"
import { API } from "api" import { API } from "api"
import { onMount } from "svelte" import { onMount } from "svelte"
import { apps, auth, admin, templates } from "stores/portal" import { apps, auth, admin, templates, licensing } from "stores/portal"
import download from "downloadjs" import download from "downloadjs"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import AppRow from "components/start/AppRow.svelte" import AppRow from "components/start/AppRow.svelte"
@ -32,6 +33,7 @@
let selectedApp let selectedApp
let creationModal let creationModal
let updatingModal let updatingModal
let appLimitModal
let creatingApp = false let creatingApp = false
let loaded = $apps?.length || $templates?.length let loaded = $apps?.length || $templates?.length
let searchTerm = "" let searchTerm = ""
@ -124,8 +126,10 @@
return `${app.name} - Automation error (${errorCount(errors)})` return `${app.name} - Automation error (${errorCount(errors)})`
} }
const initiateAppCreation = () => { const initiateAppCreation = async () => {
if ($apps?.length) { if ($licensing.usageMetrics.apps >= 100) {
appLimitModal.show()
} else if ($apps?.length) {
$goto("/builder/portal/apps/create") $goto("/builder/portal/apps/create")
} else { } else {
template = null template = null
@ -225,6 +229,10 @@
try { try {
await apps.load() await apps.load()
await templates.load() await templates.load()
await licensing.getQuotaUsage()
await licensing.getUsageMetrics()
if ($templates?.length === 0) { if ($templates?.length === 0) {
notifications.error( notifications.error(
"There was a problem loading quick start templates." "There was a problem loading quick start templates."
@ -405,6 +413,8 @@
<UpdateAppModal app={selectedApp} /> <UpdateAppModal app={selectedApp} />
</Modal> </Modal>
<AppLimitModal bind:this={appLimitModal} />
<style> <style>
.appTable { .appTable {
border-top: var(--border-light); border-top: var(--border-light);

View File

@ -38,7 +38,11 @@
try { try {
await groups.actions.save(group) await groups.actions.save(group)
} catch (error) { } catch (error) {
notifications.error(`Failed to save group`) if (error.status === 400) {
notifications.error(error.message)
} else {
notifications.error(`Failed to save group`)
}
} }
} }

View File

@ -20,6 +20,8 @@
? plugin.schema.schema.icon || "Book" ? plugin.schema.schema.icon || "Book"
: plugin.schema.schema.icon || "Beaker" : plugin.schema.schema.icon || "Beaker"
$: friendlyName = plugin?.schema?.schema?.friendlyName
function pluginDeleted() { function pluginDeleted() {
if (detailsModal) { if (detailsModal) {
detailsModal.hide() detailsModal.hide()
@ -41,6 +43,9 @@
> >
{plugin.name} {plugin.name}
</Body> </Body>
<Body size="XS" color="var(--spectrum-global-color-gray-900)">
{friendlyName}
</Body>
</div> </div>
</div> </div>
</div> </div>
@ -65,6 +70,11 @@
<Input disabled value={plugin.name} /> <Input disabled value={plugin.name} />
</div> </div>
<div class="details-row">
<Label size="M">Friendly name</Label>
<Input disabled value={friendlyName} />
</div>
<div class="details-row"> <div class="details-row">
<Label size="M">Type</Label> <Label size="M">Type</Label>
<Input <Input
@ -127,7 +137,7 @@
.details-row { .details-row {
display: grid; display: grid;
grid-template-columns: 60px 1fr; grid-template-columns: 70px 1fr;
grid-gap: var(--spacing-l) var(--spacing-l); grid-gap: var(--spacing-l) var(--spacing-l);
align-items: center; align-items: center;
} }

View File

@ -88,14 +88,14 @@
<Heading size="S">Information</Heading> <Heading size="S">Information</Heading>
<Body size="S">Here you can update your logo and organization name.</Body> <Body size="S">Here you can update your logo and organization name.</Body>
</Layout> </Layout>
<div className="fields"> <div class="fields">
<div className="field"> <div class="field">
<Label size="L">Org. name</Label> <Label size="L">Org. name</Label>
<Input thin bind:value={$values.company} /> <Input thin bind:value={$values.company} />
</div> </div>
<div className="field logo"> <div class="field logo">
<Label size="L">Logo</Label> <Label size="L">Logo</Label>
<div className="file"> <div class="file">
<Dropzone <Dropzone
value={[$values.logo]} value={[$values.logo]}
on:change={e => { on:change={e => {
@ -115,13 +115,14 @@
<Heading size="S">Platform</Heading> <Heading size="S">Platform</Heading>
<Body size="S">Here you can set up general platform settings.</Body> <Body size="S">Here you can set up general platform settings.</Body>
</Layout> </Layout>
<div className="fields"> <div class="fields">
<div className="field"> <div class="field">
<Label <Label
size="L" size="L"
tooltip={"Update the Platform URL to match your Budibase web URL. This keeps email templates and authentication configs up to date."} tooltip={"Update the Platform URL to match your Budibase web URL. This keeps email templates and authentication configs up to date."}
>Platform URL</Label
> >
Platform URL
</Label>
<Input thin bind:value={$values.platformUrl} /> <Input thin bind:value={$values.platformUrl} />
</div> </div>
</div> </div>

View File

@ -35,10 +35,14 @@
} }
const activate = async () => { const activate = async () => {
await API.activateLicenseKey({ licenseKey }) try {
await auth.getSelf() await API.activateLicenseKey({ licenseKey })
await setLicenseInfo() await auth.getSelf()
notifications.success("Successfully activated") await setLicenseInfo()
notifications.success("Successfully activated")
} catch (e) {
notifications.error(e.message)
}
} }
const refresh = async () => { const refresh = async () => {

View File

@ -5,49 +5,74 @@
Heading, Heading,
Layout, Layout,
notifications, notifications,
Detail,
Link, Link,
TooltipWrapper,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import { admin, auth, licensing } from "stores/portal" import { admin, auth, licensing } from "../../../../stores/portal"
import Usage from "components/usage/Usage.svelte" import { PlanType } from "../../../../constants"
import { DashCard, Usage } from "../../../../components/usage"
let staticUsage = [] let staticUsage = []
let monthlyUsage = [] let monthlyUsage = []
let cancelAt
let loaded = false let loaded = false
let textRows = []
let daysRemainingInMonth
let primaryActionText
const upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
const manageUrl = `${$admin.accountPortalUrl}/portal/billing`
const WARN_USAGE = ["Queries", "Automations", "Rows", "Day Passes"]
const EXCLUDE_QUOTAS = ["Queries"]
$: quotaUsage = $licensing.quotaUsage $: quotaUsage = $licensing.quotaUsage
$: license = $auth.user?.license $: license = $auth.user?.license
$: accountPortalAccess = $auth?.user?.accountPortalAccess
const upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade` $: quotaReset = quotaUsage?.quotaReset
const setMonthlyUsage = () => { const setMonthlyUsage = () => {
monthlyUsage = [] monthlyUsage = []
if (quotaUsage.monthly) { if (quotaUsage.monthly) {
for (let [key, value] of Object.entries(license.quotas.usage.monthly)) { for (let [key, value] of Object.entries(license.quotas.usage.monthly)) {
if (EXCLUDE_QUOTAS.includes(value.name)) {
continue
}
const used = quotaUsage.monthly.current[key] const used = quotaUsage.monthly.current[key]
if (used !== undefined) { if (value.value !== 0) {
monthlyUsage.push({ monthlyUsage.push({
name: value.name, name: value.name,
used: used, used: used ? used : 0,
total: value.value, total: value.value,
}) })
} }
} }
} }
monthlyUsage = monthlyUsage.sort((a, b) => a.name.localeCompare(b.name))
} }
const setStaticUsage = () => { const setStaticUsage = () => {
staticUsage = [] staticUsage = []
for (let [key, value] of Object.entries(license.quotas.usage.static)) { for (let [key, value] of Object.entries(license.quotas.usage.static)) {
if (EXCLUDE_QUOTAS.includes(value.name)) {
continue
}
const used = quotaUsage.usageQuota[key] const used = quotaUsage.usageQuota[key]
if (used !== undefined) { if (value.value !== 0) {
staticUsage.push({ staticUsage.push({
name: value.name, name: value.name,
used: used, used: used ? used : 0,
total: value.value, total: value.value,
}) })
} }
} }
staticUsage = staticUsage.sort((a, b) => a.name.localeCompare(b.name))
}
const setCancelAt = () => {
cancelAt = license?.billing?.subscription?.cancelAt
} }
const capitalise = string => { const capitalise = string => {
@ -56,6 +81,70 @@
} }
} }
const planTitle = () => {
return capitalise(license?.plan.type)
}
const getDaysRemaining = timestamp => {
if (!timestamp) {
return
}
const now = new Date()
now.setHours(0)
now.setMinutes(0)
const thenDate = new Date(timestamp)
thenDate.setHours(0)
thenDate.setMinutes(0)
const difference = thenDate.getTime() - now
// return the difference in days
return (difference / (1000 * 3600 * 24)).toFixed(0)
}
const setTextRows = () => {
textRows = []
if (cancelAt) {
textRows.push({ message: "Subscription has been cancelled" })
textRows.push({
message: `${getDaysRemaining(cancelAt * 1000)} days remaining`,
tooltip: new Date(cancelAt * 1000),
})
}
}
const setDaysRemainingInMonth = () => {
const resetDate = new Date(quotaReset)
const now = new Date()
const difference = resetDate.getTime() - now.getTime()
// return the difference in days
daysRemainingInMonth = (difference / (1000 * 3600 * 24)).toFixed(0)
}
const goToAccountPortal = () => {
if (license?.plan.type === PlanType.FREE) {
window.location.href = upgradeUrl
} else {
window.location.href = manageUrl
}
}
const setPrimaryActionText = () => {
if (license?.plan.type === PlanType.FREE) {
primaryActionText = "Upgrade"
return
}
if (cancelAt) {
primaryActionText = "Renew"
} else {
primaryActionText = "Manage"
}
}
const init = async () => { const init = async () => {
try { try {
await licensing.getQuotaUsage() await licensing.getQuotaUsage()
@ -71,69 +160,98 @@
}) })
$: { $: {
if (license && quotaUsage) { if (license) {
setMonthlyUsage() setPrimaryActionText()
setStaticUsage() setCancelAt()
setTextRows()
setDaysRemainingInMonth()
if (quotaUsage) {
setMonthlyUsage()
setStaticUsage()
}
} }
} }
</script> </script>
{#if loaded} {#if loaded}
<Layout> <Layout>
<Heading>Usage</Heading> <Layout noPadding gap="S">
<Body <Heading>Usage</Heading>
>Get information about your current usage within Budibase. <Body
{#if $admin.cloud} >Get information about your current usage within Budibase.
{#if $auth.user?.accountPortalAccess} {#if accountPortalAccess}
To upgrade your plan and usage limits visit your <Link To upgrade your plan and usage limits visit your <Link
size="L" on:click={goToAccountPortal}
href={upgradeUrl}>Account</Link size="L">Account</Link
>. >
{:else} {:else}
Contact your account holder to upgrade your usage limits. To upgrade your plan and usage limits contact your account holder
{/if} {/if}
{/if} </Body>
</Body>
</Layout>
<Layout gap="S">
<Divider size="S" />
</Layout>
<Layout gap="S" noPadding>
<Layout gap="XS">
<Body size="S">YOUR PLAN</Body>
<Heading size="S">{capitalise(license?.plan.type)}</Heading>
</Layout> </Layout>
<Layout gap="S"> <Divider />
<Body size="S">USAGE</Body> <DashCard
<div class="usages"> description="YOUR CURRENT PLAN"
{#each staticUsage as usage} title={planTitle()}
<div class="usage"> {primaryActionText}
<Usage {usage} /> primaryAction={accountPortalAccess ? goToAccountPortal : undefined}
{textRows}
>
<Layout gap="S" noPadding>
<Layout gap="S">
<div class="usages">
<Layout noPadding>
{#each staticUsage as usage}
<div class="usage">
<Usage
{usage}
warnWhenFull={WARN_USAGE.includes(usage.name)}
/>
</div>
{/each}
</Layout>
</div> </div>
{/each} </Layout>
</div> {#if monthlyUsage.length}
</Layout> <div class="monthly-container">
{#if monthlyUsage.length} <Layout gap="S">
<Layout gap="S"> <Heading size="S" weight="light">Monthly</Heading>
<Body size="S">MONTHLY</Body> <div class="detail">
<div class="usages"> <TooltipWrapper tooltip={new Date(quotaReset)}>
{#each monthlyUsage as usage} <Detail size="M">Resets in {daysRemainingInMonth} days</Detail
<div class="usage"> >
<Usage {usage} /> </TooltipWrapper>
</div> </div>
{/each} <div class="usages">
</div> <Layout noPadding>
{#each monthlyUsage as usage}
<div class="usage">
<Usage
{usage}
warnWhenFull={WARN_USAGE.includes(usage.name)}
/>
</div>
{/each}
</Layout>
</div>
</Layout>
</div>
{/if}
</Layout> </Layout>
<div /> </DashCard>
{/if}
</Layout> </Layout>
{/if} {/if}
<style> <style>
.usages { .usages {
display: grid; display: flex;
column-gap: 60px; flex-direction: column;
row-gap: 50px; }
grid-template-columns: 1fr 1fr 1fr; .detail :global(.spectrum-Detail) {
color: var(--spectrum-global-color-gray-700);
}
.detail :global(.icon) {
margin-bottom: 0;
} }
</style> </style>

View File

@ -1,14 +1,22 @@
import { writable } from "svelte/store" import { writable, get } from "svelte/store"
import { API } from "api" 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 = () => { export const createLicensingStore = () => {
const DEFAULT = { const DEFAULT = {
plans: {}, plans: {},
} }
const oneDayInMilliseconds = 86400000
const store = writable(DEFAULT) const store = writable(DEFAULT)
const actions = { const actions = {
init: async () => {
await actions.getQuotaUsage()
await actions.getUsageMetrics()
},
getQuotaUsage: async () => { getQuotaUsage: async () => {
const quotaUsage = await API.getQuotaUsage() const quotaUsage = await API.getQuotaUsage()
store.update(state => { 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 { return {

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/cli", "name": "@budibase/cli",
"version": "1.3.15-alpha.7", "version": "1.3.19-alpha.6",
"description": "Budibase CLI, for developers, self hosting and migrations.", "description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js", "main": "src/index.js",
"bin": { "bin": {
@ -26,9 +26,9 @@
"outputPath": "build" "outputPath": "build"
}, },
"dependencies": { "dependencies": {
"@budibase/backend-core": "1.3.15-alpha.7", "@budibase/backend-core": "1.3.19-alpha.6",
"@budibase/string-templates": "1.3.15-alpha.7", "@budibase/string-templates": "1.3.19-alpha.6",
"@budibase/types": "1.3.15-alpha.7", "@budibase/types": "1.3.19-alpha.6",
"axios": "0.21.2", "axios": "0.21.2",
"chalk": "4.1.0", "chalk": "4.1.0",
"cli-progress": "3.11.2", "cli-progress": "3.11.2",

View File

@ -1,3 +1,5 @@
const { Event } = require("@budibase/types")
exports.CommandWords = { exports.CommandWords = {
BACKUPS: "backups", BACKUPS: "backups",
HOSTING: "hosting", HOSTING: "hosting",
@ -15,6 +17,7 @@ exports.AnalyticsEvents = {
OptOut: "analytics:opt:out", OptOut: "analytics:opt:out",
OptIn: "analytics:opt:in", OptIn: "analytics:opt:in",
SelfHostInit: "hosting:init", SelfHostInit: "hosting:init",
PluginInit: Event.PLUGIN_INIT,
} }
exports.POSTHOG_TOKEN = "phc_yGOn4i7jWKaCTapdGR6lfA4AvmuEQ2ijn5zAVSFYPlS" exports.POSTHOG_TOKEN = "phc_yGOn4i7jWKaCTapdGR6lfA4AvmuEQ2ijn5zAVSFYPlS"

View File

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

View File

@ -13,7 +13,7 @@ const fs = require("fs")
const compose = require("docker-compose") const compose = require("docker-compose")
const makeEnv = require("./makeEnv") const makeEnv = require("./makeEnv")
const axios = require("axios") const axios = require("axios")
const AnalyticsClient = require("../analytics/Client") const { captureEvent } = require("../events")
const BUDIBASE_SERVICES = ["app-service", "worker-service", "proxy-service"] const BUDIBASE_SERVICES = ["app-service", "worker-service", "proxy-service"]
const ERROR_FILE = "docker-error.log" 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 DO_USER_DATA_URL = "http://169.254.169.254/metadata/v1/user-data"
const client = new AnalyticsClient()
async function downloadFiles() { async function downloadFiles() {
const promises = [] const promises = []
for (let url of FILE_URLS) { for (let url of FILE_URLS) {
@ -72,12 +70,8 @@ async function init(type) {
return return
} }
} }
client.capture({ captureEvent(AnalyticsEvents.SelfHostInit, {
distinctId: "cli", type,
event: AnalyticsEvents.SelfHostInit,
properties: {
type,
},
}) })
await downloadFiles() await downloadFiles()
const config = isQuick ? makeEnv.QUICK_CONFIG : {} const config = isQuick ? makeEnv.QUICK_CONFIG : {}

View File

@ -1,5 +1,5 @@
const Command = require("../structures/Command") const Command = require("../structures/Command")
const { CommandWords } = require("../constants") const { CommandWords, AnalyticsEvents } = require("../constants")
const { getSkeleton, fleshOutSkeleton } = require("./skeleton") const { getSkeleton, fleshOutSkeleton } = require("./skeleton")
const questions = require("../questions") const questions = require("../questions")
const fs = require("fs") const fs = require("fs")
@ -8,6 +8,7 @@ const { validate } = require("@budibase/backend-core/plugins")
const { runPkgCommand } = require("../exec") const { runPkgCommand } = require("../exec")
const { join } = require("path") const { join } = require("path")
const { success, error, info, moveDirectory } = require("../utils") const { success, error, info, moveDirectory } = require("../utils")
const { captureEvent } = require("../events")
function checkInPlugin() { function checkInPlugin() {
if (!fs.existsSync("package.json")) { if (!fs.existsSync("package.json")) {
@ -58,7 +59,7 @@ async function init(opts) {
) )
return return
} }
const desc = await questions.string( const description = await questions.string(
"Description", "Description",
`An amazing Budibase ${type}!` `An amazing Budibase ${type}!`
) )
@ -67,7 +68,7 @@ async function init(opts) {
// get the skeleton // get the skeleton
console.log(info("Retrieving project...")) console.log(info("Retrieving project..."))
await getSkeleton(type, name) await getSkeleton(type, name)
await fleshOutSkeleton(type, name, desc, version) await fleshOutSkeleton(type, name, description, version)
console.log(info("Installing dependencies...")) console.log(info("Installing dependencies..."))
await runPkgCommand("install", join(process.cwd(), name)) await runPkgCommand("install", join(process.cwd(), name))
// if no parent directory desired move to cwd // if no parent directory desired move to cwd
@ -77,6 +78,12 @@ async function init(opts) {
} else { } else {
console.log(info(`Plugin created in directory "${name}"`)) console.log(info(`Plugin created in directory "${name}"`))
} }
captureEvent(AnalyticsEvents.PluginInit, {
type,
name,
description,
version,
})
} }
async function verify() { async function verify() {

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/client", "name": "@budibase/client",
"version": "1.3.15-alpha.7", "version": "1.3.19-alpha.6",
"license": "MPL-2.0", "license": "MPL-2.0",
"module": "dist/budibase-client.js", "module": "dist/budibase-client.js",
"main": "dist/budibase-client.js", "main": "dist/budibase-client.js",
@ -19,9 +19,9 @@
"dev:builder": "rollup -cw" "dev:builder": "rollup -cw"
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "1.3.15-alpha.7", "@budibase/bbui": "1.3.19-alpha.6",
"@budibase/frontend-core": "1.3.15-alpha.7", "@budibase/frontend-core": "1.3.19-alpha.6",
"@budibase/string-templates": "1.3.15-alpha.7", "@budibase/string-templates": "1.3.19-alpha.6",
"@spectrum-css/button": "^3.0.3", "@spectrum-css/button": "^3.0.3",
"@spectrum-css/card": "^3.0.3", "@spectrum-css/card": "^3.0.3",
"@spectrum-css/divider": "^1.0.3", "@spectrum-css/divider": "^1.0.3",

View File

@ -1,15 +1,20 @@
<a <script>
import { Link } from "@budibase/bbui"
</script>
<Link
href="https://www.budibase.com/?utm_source=budibase-apps-public-screens&utm_medium=badge&utm_campaign=made-in-budibase" href="https://www.budibase.com/?utm_source=budibase-apps-public-screens&utm_medium=badge&utm_campaign=made-in-budibase"
target="_blank"
> >
<div> <div>
<img src="https://i.imgur.com/Xhdt1YP.png" alt="Budibase" /> <img src="https://i.imgur.com/Xhdt1YP.png" alt="Budibase" />
<p>Made In Budibase</p> <p>Made with Budibase</p>
</div> </div>
</a> </Link>
<style> <style>
div { div {
position: absolute; position: fixed;
right: 20px; right: 20px;
bottom: 20px; bottom: 20px;
padding: 10px; padding: 10px;
@ -27,12 +32,7 @@
p { p {
text-decoration: none; text-decoration: none;
color: var(--spectrum-heading-m-text-color); color: var(--spectrum-global-color-gray-900);
}
a:visited {
text-decoration: none;
color: var(--spectrum-heading-m-text-color);
} }
img { img {

View File

@ -5,9 +5,18 @@
import { FieldTypes } from "constants" import { FieldTypes } from "constants"
import active from "svelte-spa-router/active" import active from "svelte-spa-router/active"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
import FreeLogo from "../FreeLogo.svelte"
import licensing from "../../licensing"
const sdk = getContext("sdk") const sdk = getContext("sdk")
const { routeStore, styleable, linkable, builderStore, currentRole } = sdk const {
routeStore,
styleable,
linkable,
builderStore,
currentRole,
environmentStore,
} = sdk
const component = getContext("component") const component = getContext("component")
const context = getContext("context") const context = getContext("context")
@ -225,6 +234,11 @@
</div> </div>
</div> </div>
{/if} {/if}
{#if !$builderStore.inBuilder && licensing.logoEnabled() && $environmentStore.cloud}
<FreeLogo />
{/if}
<div class="main-wrapper"> <div class="main-wrapper">
<div class="main size--{pageWidthClass}"> <div class="main size--{pageWidthClass}">
<slot /> <slot />

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