Merge remote-tracking branch 'origin/master' into feature/change-global-admin-to-creator

This commit is contained in:
Dean 2023-10-25 13:27:54 +01:00
commit 536a27541f
38 changed files with 641 additions and 207 deletions

View File

@ -1,5 +1,5 @@
{ {
"version": "2.11.42", "version": "2.11.44",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -1,20 +1,20 @@
import env from "../../environment" import env from "../../environment"
import * as objectStore from "../objectStore" import * as objectStore from "../objectStore"
import * as cloudfront from "../cloudfront" import * as cloudfront from "../cloudfront"
import qs from "querystring"
import { DEFAULT_TENANT_ID, getTenantId } from "../../context"
export function clientLibraryPath(appId: string) {
return `${objectStore.sanitizeKey(appId)}/budibase-client.js`
}
/** /**
* In production the client library is stored in the object store, however in development * Previously we used to serve the client library directly from Cloudfront, however
* we use the symlinked version produced by lerna, located in node modules. We link to this * due to issues with the domain we were unable to continue doing this - keeping
* via a specific endpoint (under /api/assets/client). * incase we are able to switch back to CDN path again in future.
* @param appId In production we need the appId to look up the correct bucket, as the
* version of the client lib may differ between apps.
* @param version The version to retrieve.
* @return The URL to be inserted into appPackage response or server rendered
* app index file.
*/ */
export const clientLibraryUrl = (appId: string, version: string) => { export function clientLibraryCDNUrl(appId: string, version: string) {
if (env.isProd()) { let file = clientLibraryPath(appId)
let file = `${objectStore.sanitizeKey(appId)}/budibase-client.js`
if (env.CLOUDFRONT_CDN) { if (env.CLOUDFRONT_CDN) {
// append app version to bust the cache // append app version to bust the cache
if (version) { if (version) {
@ -26,12 +26,25 @@ export const clientLibraryUrl = (appId: string, version: string) => {
} else { } else {
return objectStore.getPresignedUrl(env.APPS_BUCKET_NAME, file) return objectStore.getPresignedUrl(env.APPS_BUCKET_NAME, file)
} }
} else {
return `/api/assets/client`
}
} }
export const getAppFileUrl = (s3Key: string) => { export function clientLibraryUrl(appId: string, version: string) {
let tenantId, qsParams: { appId: string; version: string; tenantId?: string }
try {
tenantId = getTenantId()
} finally {
qsParams = {
appId,
version,
}
}
if (tenantId && tenantId !== DEFAULT_TENANT_ID) {
qsParams.tenantId = tenantId
}
return `/api/assets/client?${qs.encode(qsParams)}`
}
export function getAppFileUrl(s3Key: string) {
if (env.CLOUDFRONT_CDN) { if (env.CLOUDFRONT_CDN) {
return cloudfront.getPresignedUrl(s3Key) return cloudfront.getPresignedUrl(s3Key)
} else { } else {

View File

@ -6,7 +6,7 @@ import { Plugin } from "@budibase/types"
// URLS // URLS
export const enrichPluginURLs = (plugins: Plugin[]) => { export function enrichPluginURLs(plugins: Plugin[]) {
if (!plugins || !plugins.length) { if (!plugins || !plugins.length) {
return [] return []
} }
@ -17,12 +17,12 @@ export const enrichPluginURLs = (plugins: Plugin[]) => {
}) })
} }
const getPluginJSUrl = (plugin: Plugin) => { function getPluginJSUrl(plugin: Plugin) {
const s3Key = getPluginJSKey(plugin) const s3Key = getPluginJSKey(plugin)
return getPluginUrl(s3Key) return getPluginUrl(s3Key)
} }
const getPluginIconUrl = (plugin: Plugin): string | undefined => { function getPluginIconUrl(plugin: Plugin): string | undefined {
const s3Key = getPluginIconKey(plugin) const s3Key = getPluginIconKey(plugin)
if (!s3Key) { if (!s3Key) {
return return
@ -30,7 +30,7 @@ const getPluginIconUrl = (plugin: Plugin): string | undefined => {
return getPluginUrl(s3Key) return getPluginUrl(s3Key)
} }
const getPluginUrl = (s3Key: string) => { function getPluginUrl(s3Key: string) {
if (env.CLOUDFRONT_CDN) { if (env.CLOUDFRONT_CDN) {
return cloudfront.getPresignedUrl(s3Key) return cloudfront.getPresignedUrl(s3Key)
} else { } else {
@ -40,11 +40,11 @@ const getPluginUrl = (s3Key: string) => {
// S3 KEYS // S3 KEYS
export const getPluginJSKey = (plugin: Plugin) => { export function getPluginJSKey(plugin: Plugin) {
return getPluginS3Key(plugin, "plugin.min.js") return getPluginS3Key(plugin, "plugin.min.js")
} }
export const getPluginIconKey = (plugin: Plugin) => { export function getPluginIconKey(plugin: Plugin) {
// stored iconUrl is deprecated - hardcode to icon.svg in this case // stored iconUrl is deprecated - hardcode to icon.svg in this case
const iconFileName = plugin.iconUrl ? "icon.svg" : plugin.iconFileName const iconFileName = plugin.iconUrl ? "icon.svg" : plugin.iconFileName
if (!iconFileName) { if (!iconFileName) {
@ -53,12 +53,12 @@ export const getPluginIconKey = (plugin: Plugin) => {
return getPluginS3Key(plugin, iconFileName) return getPluginS3Key(plugin, iconFileName)
} }
const getPluginS3Key = (plugin: Plugin, fileName: string) => { function getPluginS3Key(plugin: Plugin, fileName: string) {
const s3Key = getPluginS3Dir(plugin.name) const s3Key = getPluginS3Dir(plugin.name)
return `${s3Key}/${fileName}` return `${s3Key}/${fileName}`
} }
export const getPluginS3Dir = (pluginName: string) => { export function getPluginS3Dir(pluginName: string) {
let s3Key = `${pluginName}` let s3Key = `${pluginName}`
if (env.MULTI_TENANCY) { if (env.MULTI_TENANCY) {
const tenantId = context.getTenantId() const tenantId = context.getTenantId()

View File

@ -1,5 +1,4 @@
import * as app from "../app" import * as app from "../app"
import { getAppFileUrl } from "../app"
import { testEnv } from "../../../../tests/extra" import { testEnv } from "../../../../tests/extra"
describe("app", () => { describe("app", () => {
@ -7,6 +6,15 @@ describe("app", () => {
testEnv.nodeJest() testEnv.nodeJest()
}) })
function baseCheck(url: string, tenantId?: string) {
expect(url).toContain("/api/assets/client")
if (tenantId) {
expect(url).toContain(`tenantId=${tenantId}`)
}
expect(url).toContain("appId=app_123")
expect(url).toContain("version=2.0.0")
}
describe("clientLibraryUrl", () => { describe("clientLibraryUrl", () => {
function getClientUrl() { function getClientUrl() {
return app.clientLibraryUrl("app_123/budibase-client.js", "2.0.0") return app.clientLibraryUrl("app_123/budibase-client.js", "2.0.0")
@ -20,31 +28,19 @@ describe("app", () => {
it("gets url in dev", () => { it("gets url in dev", () => {
testEnv.nodeDev() testEnv.nodeDev()
const url = getClientUrl() const url = getClientUrl()
expect(url).toBe("/api/assets/client") baseCheck(url)
})
it("gets url with embedded minio", () => {
testEnv.withMinio()
const url = getClientUrl()
expect(url).toBe(
"/files/signed/prod-budi-app-assets/app_123/budibase-client.js/budibase-client.js"
)
}) })
it("gets url with custom S3", () => { it("gets url with custom S3", () => {
testEnv.withS3() testEnv.withS3()
const url = getClientUrl() const url = getClientUrl()
expect(url).toBe( baseCheck(url)
"http://s3.example.com/prod-budi-app-assets/app_123/budibase-client.js/budibase-client.js"
)
}) })
it("gets url with cloudfront + s3", () => { it("gets url with cloudfront + s3", () => {
testEnv.withCloudfront() testEnv.withCloudfront()
const url = getClientUrl() const url = getClientUrl()
expect(url).toBe( baseCheck(url)
"http://cf.example.com/app_123/budibase-client.js/budibase-client.js?v=2.0.0"
)
}) })
}) })
@ -57,7 +53,7 @@ describe("app", () => {
testEnv.nodeDev() testEnv.nodeDev()
await testEnv.withTenant(tenantId => { await testEnv.withTenant(tenantId => {
const url = getClientUrl() const url = getClientUrl()
expect(url).toBe("/api/assets/client") baseCheck(url, tenantId)
}) })
}) })
@ -65,9 +61,7 @@ describe("app", () => {
await testEnv.withTenant(tenantId => { await testEnv.withTenant(tenantId => {
testEnv.withMinio() testEnv.withMinio()
const url = getClientUrl() const url = getClientUrl()
expect(url).toBe( baseCheck(url, tenantId)
"/files/signed/prod-budi-app-assets/app_123/budibase-client.js/budibase-client.js"
)
}) })
}) })
@ -75,9 +69,7 @@ describe("app", () => {
await testEnv.withTenant(tenantId => { await testEnv.withTenant(tenantId => {
testEnv.withS3() testEnv.withS3()
const url = getClientUrl() const url = getClientUrl()
expect(url).toBe( baseCheck(url, tenantId)
"http://s3.example.com/prod-budi-app-assets/app_123/budibase-client.js/budibase-client.js"
)
}) })
}) })
@ -85,9 +77,7 @@ describe("app", () => {
await testEnv.withTenant(tenantId => { await testEnv.withTenant(tenantId => {
testEnv.withCloudfront() testEnv.withCloudfront()
const url = getClientUrl() const url = getClientUrl()
expect(url).toBe( baseCheck(url, tenantId)
"http://cf.example.com/app_123/budibase-client.js/budibase-client.js?v=2.0.0"
)
}) })
}) })
}) })

View File

@ -1,6 +1,6 @@
const sanitize = require("sanitize-s3-objectkey") const sanitize = require("sanitize-s3-objectkey")
import AWS from "aws-sdk" import AWS from "aws-sdk"
import stream from "stream" import stream, { Readable } from "stream"
import fetch from "node-fetch" import fetch from "node-fetch"
import tar from "tar-fs" import tar from "tar-fs"
import zlib from "zlib" import zlib from "zlib"
@ -66,10 +66,10 @@ export function sanitizeBucket(input: string) {
* @return an S3 object store object, check S3 Nodejs SDK for usage. * @return an S3 object store object, check S3 Nodejs SDK for usage.
* @constructor * @constructor
*/ */
export const ObjectStore = ( export function ObjectStore(
bucket: string, bucket: string,
opts: { presigning: boolean } = { presigning: false } opts: { presigning: boolean } = { presigning: false }
) => { ) {
const config: any = { const config: any = {
s3ForcePathStyle: true, s3ForcePathStyle: true,
signatureVersion: "v4", signatureVersion: "v4",
@ -104,7 +104,7 @@ export const ObjectStore = (
* Given an object store and a bucket name this will make sure the bucket exists, * Given an object store and a bucket name this will make sure the bucket exists,
* if it does not exist then it will create it. * if it does not exist then it will create it.
*/ */
export const makeSureBucketExists = async (client: any, bucketName: string) => { export async function makeSureBucketExists(client: any, bucketName: string) {
bucketName = sanitizeBucket(bucketName) bucketName = sanitizeBucket(bucketName)
try { try {
await client await client
@ -139,13 +139,13 @@ export const makeSureBucketExists = async (client: any, bucketName: string) => {
* Uploads the contents of a file given the required parameters, useful when * Uploads the contents of a file given the required parameters, useful when
* temp files in use (for example file uploaded as an attachment). * temp files in use (for example file uploaded as an attachment).
*/ */
export const upload = async ({ export async function upload({
bucket: bucketName, bucket: bucketName,
filename, filename,
path, path,
type, type,
metadata, metadata,
}: UploadParams) => { }: UploadParams) {
const extension = filename.split(".").pop() const extension = filename.split(".").pop()
const fileBytes = fs.readFileSync(path) const fileBytes = fs.readFileSync(path)
@ -180,12 +180,12 @@ export const upload = async ({
* Similar to the upload function but can be used to send a file stream * Similar to the upload function but can be used to send a file stream
* through to the object store. * through to the object store.
*/ */
export const streamUpload = async ( export async function streamUpload(
bucketName: string, bucketName: string,
filename: string, filename: string,
stream: any, stream: any,
extra = {} extra = {}
) => { ) {
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName) await makeSureBucketExists(objectStore, bucketName)
@ -215,7 +215,7 @@ export const streamUpload = async (
* retrieves the contents of a file from the object store, if it is a known content type it * retrieves the contents of a file from the object store, if it is a known content type it
* will be converted, otherwise it will be returned as a buffer stream. * will be converted, otherwise it will be returned as a buffer stream.
*/ */
export const retrieve = async (bucketName: string, filepath: string) => { export async function retrieve(bucketName: string, filepath: string) {
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
const params = { const params = {
Bucket: sanitizeBucket(bucketName), Bucket: sanitizeBucket(bucketName),
@ -230,7 +230,7 @@ export const retrieve = async (bucketName: string, filepath: string) => {
} }
} }
export const listAllObjects = async (bucketName: string, path: string) => { export async function listAllObjects(bucketName: string, path: string) {
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
const list = (params: ListParams = {}) => { const list = (params: ListParams = {}) => {
return objectStore return objectStore
@ -261,11 +261,11 @@ export const listAllObjects = async (bucketName: string, path: string) => {
/** /**
* Generate a presigned url with a default TTL of 1 hour * Generate a presigned url with a default TTL of 1 hour
*/ */
export const getPresignedUrl = ( export function getPresignedUrl(
bucketName: string, bucketName: string,
key: string, key: string,
durationSeconds: number = 3600 durationSeconds: number = 3600
) => { ) {
const objectStore = ObjectStore(bucketName, { presigning: true }) const objectStore = ObjectStore(bucketName, { presigning: true })
const params = { const params = {
Bucket: sanitizeBucket(bucketName), Bucket: sanitizeBucket(bucketName),
@ -291,7 +291,7 @@ export const getPresignedUrl = (
/** /**
* Same as retrieval function but puts to a temporary file. * Same as retrieval function but puts to a temporary file.
*/ */
export const retrieveToTmp = async (bucketName: string, filepath: string) => { export async function retrieveToTmp(bucketName: string, filepath: string) {
bucketName = sanitizeBucket(bucketName) bucketName = sanitizeBucket(bucketName)
filepath = sanitizeKey(filepath) filepath = sanitizeKey(filepath)
const data = await retrieve(bucketName, filepath) const data = await retrieve(bucketName, filepath)
@ -300,7 +300,7 @@ export const retrieveToTmp = async (bucketName: string, filepath: string) => {
return outputPath return outputPath
} }
export const retrieveDirectory = async (bucketName: string, path: string) => { export async function retrieveDirectory(bucketName: string, path: string) {
let writePath = join(budibaseTempDir(), v4()) let writePath = join(budibaseTempDir(), v4())
fs.mkdirSync(writePath) fs.mkdirSync(writePath)
const objects = await listAllObjects(bucketName, path) const objects = await listAllObjects(bucketName, path)
@ -324,7 +324,7 @@ export const retrieveDirectory = async (bucketName: string, path: string) => {
/** /**
* Delete a single file. * Delete a single file.
*/ */
export const deleteFile = async (bucketName: string, filepath: string) => { export async function deleteFile(bucketName: string, filepath: string) {
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName) await makeSureBucketExists(objectStore, bucketName)
const params = { const params = {
@ -334,7 +334,7 @@ export const deleteFile = async (bucketName: string, filepath: string) => {
return objectStore.deleteObject(params).promise() return objectStore.deleteObject(params).promise()
} }
export const deleteFiles = async (bucketName: string, filepaths: string[]) => { export async function deleteFiles(bucketName: string, filepaths: string[]) {
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName) await makeSureBucketExists(objectStore, bucketName)
const params = { const params = {
@ -349,10 +349,10 @@ export const deleteFiles = async (bucketName: string, filepaths: string[]) => {
/** /**
* Delete a path, including everything within. * Delete a path, including everything within.
*/ */
export const deleteFolder = async ( export async function deleteFolder(
bucketName: string, bucketName: string,
folder: string folder: string
): Promise<any> => { ): Promise<any> {
bucketName = sanitizeBucket(bucketName) bucketName = sanitizeBucket(bucketName)
folder = sanitizeKey(folder) folder = sanitizeKey(folder)
const client = ObjectStore(bucketName) const client = ObjectStore(bucketName)
@ -383,11 +383,11 @@ export const deleteFolder = async (
} }
} }
export const uploadDirectory = async ( export async function uploadDirectory(
bucketName: string, bucketName: string,
localPath: string, localPath: string,
bucketPath: string bucketPath: string
) => { ) {
bucketName = sanitizeBucket(bucketName) bucketName = sanitizeBucket(bucketName)
let uploads = [] let uploads = []
const files = fs.readdirSync(localPath, { withFileTypes: true }) const files = fs.readdirSync(localPath, { withFileTypes: true })
@ -404,11 +404,11 @@ export const uploadDirectory = async (
return files return files
} }
export const downloadTarballDirect = async ( export async function downloadTarballDirect(
url: string, url: string,
path: string, path: string,
headers = {} headers = {}
) => { ) {
path = sanitizeKey(path) path = sanitizeKey(path)
const response = await fetch(url, { headers }) const response = await fetch(url, { headers })
if (!response.ok) { if (!response.ok) {
@ -418,11 +418,11 @@ export const downloadTarballDirect = async (
await streamPipeline(response.body, zlib.createUnzip(), tar.extract(path)) await streamPipeline(response.body, zlib.createUnzip(), tar.extract(path))
} }
export const downloadTarball = async ( export async function downloadTarball(
url: string, url: string,
bucketName: string, bucketName: string,
path: string path: string
) => { ) {
bucketName = sanitizeBucket(bucketName) bucketName = sanitizeBucket(bucketName)
path = sanitizeKey(path) path = sanitizeKey(path)
const response = await fetch(url) const response = await fetch(url)
@ -438,3 +438,17 @@ export const downloadTarball = async (
// return the temporary path incase there is a use for it // return the temporary path incase there is a use for it
return tmpPath return tmpPath
} }
export async function getReadStream(
bucketName: string,
path: string
): Promise<Readable> {
bucketName = sanitizeBucket(bucketName)
path = sanitizeKey(path)
const client = ObjectStore(bucketName)
const params = {
Bucket: bucketName,
Key: path,
}
return client.getObject(params).createReadStream()
}

View File

@ -21,8 +21,9 @@ import {
User, User,
DatabaseQueryOpts, DatabaseQueryOpts,
} from "@budibase/types" } from "@budibase/types"
import * as context from "../context"
import { getGlobalDB } from "../context" import { getGlobalDB } from "../context"
import * as context from "../context"
import { isCreator } from "./utils"
type GetOpts = { cleanup?: boolean } type GetOpts = { cleanup?: boolean }
@ -286,6 +287,19 @@ export async function getUserCount() {
return response.total_rows return response.total_rows
} }
export async function getCreatorCount() {
let creators = 0
async function iterate(startPage?: string) {
const page = await paginatedUsers({ bookmark: startPage })
creators += page.data.filter(isCreator).length
if (page.hasNextPage) {
await iterate(page.nextPage)
}
}
await iterate()
return creators
}
// used to remove the builder/admin permissions, for processing the // used to remove the builder/admin permissions, for processing the
// user as an app user (they may have some specific role/group // user as an app user (they may have some specific role/group
export function removePortalUserPermissions(user: User | ContextUser) { export function removePortalUserPermissions(user: User | ContextUser) {

View File

@ -10,6 +10,7 @@ import { getAccountByTenantId } from "../accounts"
// extract from shared-core to make easily accessible from backend-core // extract from shared-core to make easily accessible from backend-core
export const isBuilder = sdk.users.isBuilder export const isBuilder = sdk.users.isBuilder
export const isAdmin = sdk.users.isAdmin export const isAdmin = sdk.users.isAdmin
export const isCreator = sdk.users.isCreator
export const isGlobalBuilder = sdk.users.isGlobalBuilder export const isGlobalBuilder = sdk.users.isGlobalBuilder
export const isAdminOrBuilder = sdk.users.isAdminOrBuilder export const isAdminOrBuilder = sdk.users.isAdminOrBuilder
export const hasAdminPermissions = sdk.users.hasAdminPermissions export const hasAdminPermissions = sdk.users.hasAdminPermissions

View File

@ -72,6 +72,11 @@ export function quotas(): Quotas {
value: 1, value: 1,
triggers: [], triggers: [],
}, },
creators: {
name: "Creators",
value: 1,
triggers: [],
},
userGroups: { userGroups: {
name: "User Groups", name: "User Groups",
value: 1, value: 1,

View File

@ -1,6 +1,6 @@
import { MonthlyQuotaName, QuotaUsage } from "@budibase/types" import { MonthlyQuotaName, QuotaUsage } from "@budibase/types"
export const usage = (): QuotaUsage => { export const usage = (users: number = 0, creators: number = 0): QuotaUsage => {
return { return {
_id: "usage_quota", _id: "usage_quota",
quotaReset: new Date().toISOString(), quotaReset: new Date().toISOString(),
@ -58,7 +58,8 @@ export const usage = (): QuotaUsage => {
usageQuota: { usageQuota: {
apps: 0, apps: 0,
plugins: 0, plugins: 0,
users: 0, users,
creators,
userGroups: 0, userGroups: 0,
rows: 0, rows: 0,
triggers: {}, triggers: {},

View File

@ -106,6 +106,13 @@
name: fieldName, name: fieldName,
} }
} }
// Delete numeric only widths as these are grid widths and should be
// ignored
const width = fixedSchema[fieldName].width
if (width != null && `${width}`.trim().match(/^[0-9]+$/)) {
delete fixedSchema[fieldName].width
}
}) })
return fixedSchema return fixedSchema
} }

View File

@ -5,6 +5,7 @@ import {
encodeJSBinding, encodeJSBinding,
findHBSBlocks, findHBSBlocks,
} from "@budibase/string-templates" } from "@budibase/string-templates"
import { capitalise } from "helpers"
/** /**
* Recursively searches for a specific component ID * Recursively searches for a specific component ID
@ -235,3 +236,13 @@ export const makeComponentUnique = component => {
// Recurse on all children // Recurse on all children
return JSON.parse(definition) return JSON.parse(definition)
} }
export const getComponentText = component => {
if (component?._instanceName) {
return component._instanceName
}
const type =
component._component.replace("@budibase/standard-components/", "") ||
"component"
return capitalise(type)
}

View File

@ -16,6 +16,7 @@
export let closeButtonIcon = "Close" export let closeButtonIcon = "Close"
$: customHeaderContent = $$slots["panel-header-content"] $: customHeaderContent = $$slots["panel-header-content"]
$: customTitleContent = $$slots["panel-title-content"]
</script> </script>
<div <div
@ -33,7 +34,11 @@
<Icon name={icon} /> <Icon name={icon} />
{/if} {/if}
<div class="title"> <div class="title">
<Body size="S">{title}</Body> {#if customTitleContent}
<slot name="panel-title-content" />
{:else}
<Body size="S">{title || ""}</Body>
{/if}
</div> </div>
{#if showAddButton} {#if showAddButton}
<div class="add-button" on:click={onClickAddButton}> <div class="add-button" on:click={onClickAddButton}>
@ -134,4 +139,7 @@
.custom-content-wrap { .custom-content-wrap {
border-bottom: var(--border-light); border-bottom: var(--border-light);
} }
.title {
display: flex;
}
</style> </style>

View File

@ -16,7 +16,11 @@
<DrawerContent> <DrawerContent>
<div class="container"> <div class="container">
<Layout noPadding gap="S"> <Layout noPadding gap="S">
<Input bind:value={column.width} label="Width" placeholder="Auto" /> <Input
bind:value={column.width}
label="Width (must include a unit like px or %)"
placeholder="Auto"
/>
<Select <Select
label="Alignment" label="Alignment"
bind:value={column.align} bind:value={column.align}

View File

@ -1,10 +1,12 @@
<script> <script>
import Panel from "components/design/Panel.svelte" import Panel from "components/design/Panel.svelte"
import { store, selectedComponent, selectedScreen } from "builderStore" import { store, selectedComponent, selectedScreen } from "builderStore"
import { getComponentText } from "builderStore/componentUtils"
import ComponentSettingsSection from "./ComponentSettingsSection.svelte" import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
import DesignSection from "./DesignSection.svelte" import DesignSection from "./DesignSection.svelte"
import CustomStylesSection from "./CustomStylesSection.svelte" import CustomStylesSection from "./CustomStylesSection.svelte"
import ConditionalUISection from "./ConditionalUISection.svelte" import ConditionalUISection from "./ConditionalUISection.svelte"
import { notifications } from "@budibase/bbui"
import { import {
getBindableProperties, getBindableProperties,
@ -13,6 +15,14 @@
import { ActionButton } from "@budibase/bbui" import { ActionButton } from "@budibase/bbui"
import { capitalise } from "helpers" import { capitalise } from "helpers"
const onUpdateName = async value => {
try {
await store.actions.components.updateSetting("_instanceName", value)
} catch (error) {
notifications.error("Error updating component name")
}
}
$: componentInstance = $selectedComponent $: componentInstance = $selectedComponent
$: componentDefinition = store.actions.components.getDefinition( $: componentDefinition = store.actions.components.getDefinition(
$selectedComponent?._component $selectedComponent?._component
@ -39,6 +49,22 @@
{#if $selectedComponent} {#if $selectedComponent}
{#key $selectedComponent._id} {#key $selectedComponent._id}
<Panel {title} icon={componentDefinition?.icon} borderLeft wide> <Panel {title} icon={componentDefinition?.icon} borderLeft wide>
<span class="panel-title-content" slot="panel-title-content">
<input
class="input"
value={title}
{title}
placeholder={getComponentText(componentInstance)}
on:keypress={e => {
if (e.key.toLowerCase() === "enter") {
e.target.blur()
}
}}
on:change={e => {
onUpdateName(e.target.value)
}}
/>
</span>
<span slot="panel-header-content"> <span slot="panel-header-content">
<div class="settings-tabs"> <div class="settings-tabs">
{#each tabs as tab} {#each tabs as tab}
@ -90,4 +116,24 @@
padding: 0 var(--spacing-l); padding: 0 var(--spacing-l);
padding-bottom: var(--spacing-l); padding-bottom: var(--spacing-l);
} }
.input {
color: inherit;
font-family: inherit;
font-size: inherit;
background-color: transparent;
border: none;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.panel-title-content {
display: contents;
}
.input:focus {
outline: none;
}
input::placeholder {
color: var(--spectrum-global-color-gray-600);
}
</style> </style>

View File

@ -1,6 +1,6 @@
<script> <script>
import { helpers } from "@budibase/shared-core" import { helpers } from "@budibase/shared-core"
import { Input, DetailSummary, notifications } from "@budibase/bbui" import { DetailSummary, notifications } from "@budibase/bbui"
import { store } from "builderStore" import { store } from "builderStore"
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte" import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte" import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte"
@ -16,7 +16,6 @@
export let isScreen = false export let isScreen = false
export let onUpdateSetting export let onUpdateSetting
export let showSectionTitle = true export let showSectionTitle = true
export let showInstanceName = true
$: sections = getSections(componentInstance, componentDefinition, isScreen) $: sections = getSections(componentInstance, componentDefinition, isScreen)
@ -140,15 +139,6 @@
/> />
{/if} {/if}
<div class="settings"> <div class="settings">
{#if idx === 0 && !componentInstance._component.endsWith("/layout") && !isScreen && showInstanceName}
<PropertyControl
control={Input}
label="Name"
key="_instanceName"
value={componentInstance._instanceName}
onChange={val => updateSetting({ key: "_instanceName" }, val)}
/>
{/if}
{#each section.settings as setting (setting.key)} {#each section.settings as setting (setting.key)}
{#if setting.visible} {#if setting.visible}
<PropertyControl <PropertyControl

View File

@ -2,14 +2,16 @@
import { store, userSelectedResourceMap } from "builderStore" import { store, userSelectedResourceMap } from "builderStore"
import ComponentDropdownMenu from "./ComponentDropdownMenu.svelte" import ComponentDropdownMenu from "./ComponentDropdownMenu.svelte"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import { capitalise } from "helpers"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { import {
selectedComponentPath, selectedComponentPath,
selectedComponent, selectedComponent,
selectedScreen, selectedScreen,
} from "builderStore" } from "builderStore"
import { findComponentPath } from "builderStore/componentUtils" import {
findComponentPath,
getComponentText,
} from "builderStore/componentUtils"
import { get } from "svelte/store" import { get } from "svelte/store"
import { dndStore } from "./dndStore" import { dndStore } from "./dndStore"
@ -35,16 +37,6 @@
return false return false
} }
const getComponentText = component => {
if (component._instanceName) {
return component._instanceName
}
const type =
component._component.replace("@budibase/standard-components/", "") ||
"component"
return capitalise(type)
}
const getComponentIcon = component => { const getComponentIcon = component => {
const def = store.actions.components.getDefinition(component?._component) const def = store.actions.components.getDefinition(component?._component)
return def?.icon return def?.icon

View File

@ -5305,6 +5305,12 @@
"key": "title", "key": "title",
"nested": true "nested": true
}, },
{
"type": "text",
"label": "Description",
"key": "description",
"nested": true
},
{ {
"section": true, "section": true,
"dependsOn": { "dependsOn": {

View File

@ -81,6 +81,7 @@
sortOrder: $fetch.sortOrder, sortOrder: $fetch.sortOrder,
}, },
limit, limit,
primaryDisplay: $fetch.definition?.primaryDisplay,
} }
const createFetch = datasource => { const createFetch = datasource => {

View File

@ -12,6 +12,7 @@
export let fields export let fields
export let labelPosition export let labelPosition
export let title export let title
export let description
export let showDeleteButton export let showDeleteButton
export let showSaveButton export let showSaveButton
export let saveButtonLabel export let saveButtonLabel
@ -98,6 +99,7 @@
fields: fieldsOrDefault, fields: fieldsOrDefault,
labelPosition, labelPosition,
title, title,
description,
saveButtonLabel: saveLabel, saveButtonLabel: saveLabel,
deleteButtonLabel: deleteLabel, deleteButtonLabel: deleteLabel,
schema, schema,

View File

@ -11,6 +11,7 @@
export let fields export let fields
export let labelPosition export let labelPosition
export let title export let title
export let description
export let saveButtonLabel export let saveButtonLabel
export let deleteButtonLabel export let deleteButtonLabel
export let schema export let schema
@ -157,6 +158,14 @@
}} }}
> >
{#if renderHeader} {#if renderHeader}
<BlockComponent
type="container"
props={{
direction: "column",
gap: "S",
}}
order={0}
>
<BlockComponent <BlockComponent
type="container" type="container"
props={{ props={{
@ -211,6 +220,14 @@
</BlockComponent> </BlockComponent>
{/if} {/if}
</BlockComponent> </BlockComponent>
{#if description}
<BlockComponent
type="text"
props={{ text: description }}
order={1}
/>
{/if}
</BlockComponent>
{/if} {/if}
{#key fields} {#key fields}
<BlockComponent type="fieldgroup" props={{ labelPosition }} order={1}> <BlockComponent type="fieldgroup" props={{ labelPosition }} order={1}>

View File

@ -32,7 +32,8 @@
$: loading = dataProvider?.loading ?? false $: loading = dataProvider?.loading ?? false
$: data = dataProvider?.rows || [] $: data = dataProvider?.rows || []
$: fullSchema = dataProvider?.schema ?? {} $: fullSchema = dataProvider?.schema ?? {}
$: fields = getFields(fullSchema, columns, false) $: primaryDisplay = dataProvider?.primaryDisplay
$: fields = getFields(fullSchema, columns, false, primaryDisplay)
$: schema = getFilteredSchema(fullSchema, fields, hasChildren) $: schema = getFilteredSchema(fullSchema, fields, hasChildren)
$: setSorting = getAction( $: setSorting = getAction(
dataProvider?.id, dataProvider?.id,
@ -55,18 +56,13 @@
} }
} }
const getFields = (schema, customColumns, showAutoColumns) => { const getFields = (
// Check for an invalid column selection schema,
let invalid = false customColumns,
customColumns?.forEach(column => { showAutoColumns,
const columnName = typeof column === "string" ? column : column.name primaryDisplay
if (schema[columnName] == null) { ) => {
invalid = true if (customColumns?.length) {
}
})
// Use column selection if it exists
if (!invalid && customColumns?.length) {
return customColumns return customColumns
} }
@ -74,13 +70,38 @@
let columns = [] let columns = []
let autoColumns = [] let autoColumns = []
Object.entries(schema).forEach(([field, fieldSchema]) => { Object.entries(schema).forEach(([field, fieldSchema]) => {
if (fieldSchema.visible === false) {
return
}
if (!fieldSchema?.autocolumn) { if (!fieldSchema?.autocolumn) {
columns.push(field) columns.push(field)
} else if (showAutoColumns) { } else if (showAutoColumns) {
autoColumns.push(field) autoColumns.push(field)
} }
}) })
return columns.concat(autoColumns)
// Sort columns to respect grid metadata
const allCols = columns.concat(autoColumns)
return allCols.sort((a, b) => {
if (a === primaryDisplay) {
return -1
}
if (b === primaryDisplay) {
return 1
}
const aOrder = schema[a].order
const bOrder = schema[b].order
if (aOrder === bOrder) {
return 0
}
if (aOrder == null) {
return 1
}
if (bOrder == null) {
return -1
}
return aOrder < bOrder ? -1 : 1
})
} }
const getFilteredSchema = (schema, fields, hasChildren) => { const getFilteredSchema = (schema, fields, hasChildren) => {

@ -1 +1 @@
Subproject commit f7e7cffe422086d9449c2075a74a378c16caff9d Subproject commit d24c0dc3a30014cbe61860252aa48104cad36376

View File

@ -16,7 +16,7 @@ import AWS from "aws-sdk"
import fs from "fs" import fs from "fs"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import * as pro from "@budibase/pro" import * as pro from "@budibase/pro"
import { App } from "@budibase/types" import { App, Ctx } from "@budibase/types"
const send = require("koa-send") const send = require("koa-send")
@ -39,7 +39,7 @@ async function prepareUpload({ s3Key, bucket, metadata, file }: any) {
} }
} }
export const toggleBetaUiFeature = async function (ctx: any) { export const toggleBetaUiFeature = async function (ctx: Ctx) {
const cookieName = `beta:${ctx.params.feature}` const cookieName = `beta:${ctx.params.feature}`
if (ctx.cookies.get(cookieName)) { if (ctx.cookies.get(cookieName)) {
@ -67,16 +67,14 @@ export const toggleBetaUiFeature = async function (ctx: any) {
} }
} }
export const serveBuilder = async function (ctx: any) { export const serveBuilder = async function (ctx: Ctx) {
const builderPath = join(TOP_LEVEL_PATH, "builder") const builderPath = join(TOP_LEVEL_PATH, "builder")
await send(ctx, ctx.file, { root: builderPath }) await send(ctx, ctx.file, { root: builderPath })
} }
export const uploadFile = async function (ctx: any) { export const uploadFile = async function (ctx: Ctx) {
let files = const file = ctx.request?.files?.file
ctx.request.files.file.length > 1 let files = file && Array.isArray(file) ? Array.from(file) : [file]
? Array.from(ctx.request.files.file)
: [ctx.request.files.file]
const uploads = files.map(async (file: any) => { const uploads = files.map(async (file: any) => {
const fileExtension = [...file.name.split(".")].pop() const fileExtension = [...file.name.split(".")].pop()
@ -93,14 +91,14 @@ export const uploadFile = async function (ctx: any) {
ctx.body = await Promise.all(uploads) ctx.body = await Promise.all(uploads)
} }
export const deleteObjects = async function (ctx: any) { export const deleteObjects = async function (ctx: Ctx) {
ctx.body = await objectStore.deleteFiles( ctx.body = await objectStore.deleteFiles(
ObjectStoreBuckets.APPS, ObjectStoreBuckets.APPS,
ctx.request.body.keys ctx.request.body.keys
) )
} }
export const serveApp = async function (ctx: any) { export const serveApp = async function (ctx: Ctx) {
const bbHeaderEmbed = const bbHeaderEmbed =
ctx.request.get("x-budibase-embed")?.toLowerCase() === "true" ctx.request.get("x-budibase-embed")?.toLowerCase() === "true"
@ -181,7 +179,7 @@ export const serveApp = async function (ctx: any) {
} }
} }
export const serveBuilderPreview = async function (ctx: any) { export const serveBuilderPreview = async function (ctx: Ctx) {
const db = context.getAppDB({ skip_setup: true }) const db = context.getAppDB({ skip_setup: true })
const appInfo = await db.get<App>(DocumentType.APP_METADATA) const appInfo = await db.get<App>(DocumentType.APP_METADATA)
@ -197,18 +195,30 @@ export const serveBuilderPreview = async function (ctx: any) {
} }
} }
export const serveClientLibrary = async function (ctx: any) { export const serveClientLibrary = async function (ctx: Ctx) {
const appId = context.getAppId() || (ctx.request.query.appId as string)
let rootPath = join(NODE_MODULES_PATH, "@budibase", "client", "dist") let rootPath = join(NODE_MODULES_PATH, "@budibase", "client", "dist")
// incase running from TS directly if (!appId) {
if (env.isDev() && !fs.existsSync(rootPath)) { ctx.throw(400, "No app ID provided - cannot fetch client library.")
rootPath = join(require.resolve("@budibase/client"), "..")
} }
if (env.isProd()) {
ctx.body = await objectStore.getReadStream(
ObjectStoreBuckets.APPS,
objectStore.clientLibraryPath(appId!)
)
ctx.set("Content-Type", "application/javascript")
} else if (env.isDev()) {
// incase running from TS directly
const tsPath = join(require.resolve("@budibase/client"), "..")
return send(ctx, "budibase-client.js", { return send(ctx, "budibase-client.js", {
root: rootPath, root: !fs.existsSync(rootPath) ? tsPath : rootPath,
}) })
} else {
ctx.throw(500, "Unable to retrieve client library.")
}
} }
export const getSignedUploadURL = async function (ctx: any) { export const getSignedUploadURL = async function (ctx: Ctx) {
// Ensure datasource is valid // Ensure datasource is valid
let datasource let datasource
try { try {
@ -247,7 +257,7 @@ export const getSignedUploadURL = async function (ctx: any) {
const params = { Bucket: bucket, Key: key } const params = { Bucket: bucket, Key: key }
signedUrl = s3.getSignedUrl("putObject", params) signedUrl = s3.getSignedUrl("putObject", params)
publicUrl = `https://${bucket}.s3.${awsRegion}.amazonaws.com/${key}` publicUrl = `https://${bucket}.s3.${awsRegion}.amazonaws.com/${key}`
} catch (error) { } catch (error: any) {
ctx.throw(400, error) ctx.throw(400, error)
} }
} }

View File

@ -27,15 +27,9 @@ router.param("file", async (file: any, ctx: any, next: any) => {
return next() return next()
}) })
// only used in development for retrieving the client library,
// in production the client lib is always stored in the object store.
if (env.isDev()) {
router.get("/api/assets/client", controller.serveClientLibrary)
}
router router
// TODO: for now this builder endpoint is not authorized/secured, will need to be
.get("/builder/:file*", controller.serveBuilder) .get("/builder/:file*", controller.serveBuilder)
.get("/api/assets/client", controller.serveClientLibrary)
.post("/api/attachments/process", authorized(BUILDER), controller.uploadFile) .post("/api/attachments/process", authorized(BUILDER), controller.uploadFile)
.post( .post(
"/api/attachments/delete", "/api/attachments/delete",

View File

@ -3,6 +3,7 @@ import * as syncApps from "./usageQuotas/syncApps"
import * as syncRows from "./usageQuotas/syncRows" import * as syncRows from "./usageQuotas/syncRows"
import * as syncPlugins from "./usageQuotas/syncPlugins" import * as syncPlugins from "./usageQuotas/syncPlugins"
import * as syncUsers from "./usageQuotas/syncUsers" import * as syncUsers from "./usageQuotas/syncUsers"
import * as syncCreators from "./usageQuotas/syncCreators"
/** /**
* Synchronise quotas to the state of the db. * Synchronise quotas to the state of the db.
@ -13,5 +14,6 @@ export const run = async () => {
await syncRows.run() await syncRows.run()
await syncPlugins.run() await syncPlugins.run()
await syncUsers.run() await syncUsers.run()
await syncCreators.run()
}) })
} }

View File

@ -0,0 +1,13 @@
import { users } from "@budibase/backend-core"
import { quotas } from "@budibase/pro"
import { QuotaUsageType, StaticQuotaName } from "@budibase/types"
export const run = async () => {
const creatorCount = await users.getCreatorCount()
console.log(`Syncing creator count: ${creatorCount}`)
await quotas.setUsage(
creatorCount,
StaticQuotaName.CREATORS,
QuotaUsageType.STATIC
)
}

View File

@ -0,0 +1,26 @@
import TestConfig from "../../../../tests/utilities/TestConfiguration"
import * as syncCreators from "../syncCreators"
import { quotas } from "@budibase/pro"
describe("syncCreators", () => {
let config = new TestConfig(false)
beforeEach(async () => {
await config.init()
})
afterAll(config.end)
it("syncs creators", async () => {
return config.doInContext(null, async () => {
await config.createUser({ admin: true })
await syncCreators.run()
const usageDoc = await quotas.getQuotaUsage()
// default + additional creator
const creatorsCount = 2
expect(usageDoc.usageQuota.creators).toBe(creatorsCount)
})
})
})

View File

@ -6,6 +6,7 @@ import {
InternalTable, InternalTable,
} from "@budibase/types" } from "@budibase/types"
import { getProdAppID } from "./applications" import { getProdAppID } from "./applications"
import * as _ from "lodash/fp"
// checks if a user is specifically a builder, given an app ID // checks if a user is specifically a builder, given an app ID
export function isBuilder(user: User | ContextUser, appId?: string): boolean { export function isBuilder(user: User | ContextUser, appId?: string): boolean {
@ -58,6 +59,18 @@ export function hasAppBuilderPermissions(user?: User | ContextUser): boolean {
return !isGlobalBuilder && appLength != null && appLength > 0 return !isGlobalBuilder && appLength != null && appLength > 0
} }
export function hasAppCreatorPermissions(user?: User | ContextUser): boolean {
if (!user) {
return false
}
return _.flow(
_.get("roles"),
_.values,
_.find(x => ["CREATOR", "ADMIN"].includes(x)),
x => !!x
)(user)
}
// checks if a user is capable of building any app // checks if a user is capable of building any app
export function hasBuilderPermissions(user?: User | ContextUser): boolean { export function hasBuilderPermissions(user?: User | ContextUser): boolean {
if (!user) { if (!user) {
@ -74,6 +87,18 @@ export function hasAdminPermissions(user?: User | ContextUser): boolean {
return !!user.admin?.global return !!user.admin?.global
} }
export function isCreator(user?: User | ContextUser): boolean {
if (!user) {
return false
}
return (
isGlobalBuilder(user) ||
hasAdminPermissions(user) ||
hasAppBuilderPermissions(user) ||
hasAppCreatorPermissions(user)
)
}
export function getGlobalUserID(userId?: string): string | undefined { export function getGlobalUserID(userId?: string): string | undefined {
if (typeof userId !== "string") { if (typeof userId !== "string") {
return userId return userId

View File

@ -32,6 +32,7 @@ export interface StaticUsage {
[StaticQuotaName.APPS]: number [StaticQuotaName.APPS]: number
[StaticQuotaName.PLUGINS]: number [StaticQuotaName.PLUGINS]: number
[StaticQuotaName.USERS]: number [StaticQuotaName.USERS]: number
[StaticQuotaName.CREATORS]: number
[StaticQuotaName.USER_GROUPS]: number [StaticQuotaName.USER_GROUPS]: number
[StaticQuotaName.ROWS]: number [StaticQuotaName.ROWS]: number
triggers: { triggers: {

View File

@ -14,6 +14,7 @@ export enum StaticQuotaName {
ROWS = "rows", ROWS = "rows",
APPS = "apps", APPS = "apps",
USERS = "users", USERS = "users",
CREATORS = "creators",
USER_GROUPS = "userGroups", USER_GROUPS = "userGroups",
PLUGINS = "plugins", PLUGINS = "plugins",
} }
@ -67,6 +68,7 @@ export type StaticQuotas = {
[StaticQuotaName.ROWS]: Quota [StaticQuotaName.ROWS]: Quota
[StaticQuotaName.APPS]: Quota [StaticQuotaName.APPS]: Quota
[StaticQuotaName.USERS]: Quota [StaticQuotaName.USERS]: Quota
[StaticQuotaName.CREATORS]: Quota
[StaticQuotaName.USER_GROUPS]: Quota [StaticQuotaName.USER_GROUPS]: Quota
[StaticQuotaName.PLUGINS]: Quota [StaticQuotaName.PLUGINS]: Quota
} }

View File

@ -46,7 +46,7 @@ export enum MigrationName {
GLOBAL_INFO_SYNC_USERS = "global_info_sync_users", GLOBAL_INFO_SYNC_USERS = "global_info_sync_users",
TABLE_SETTINGS_LINKS_TO_ACTIONS = "table_settings_links_to_actions", TABLE_SETTINGS_LINKS_TO_ACTIONS = "table_settings_links_to_actions",
// increment this number to re-activate this migration // increment this number to re-activate this migration
SYNC_QUOTAS = "sync_quotas_1", SYNC_QUOTAS = "sync_quotas_2",
} }
export interface MigrationDefinition { export interface MigrationDefinition {

View File

@ -17,7 +17,7 @@
"test:notify": "node scripts/testResultsWebhook", "test:notify": "node scripts/testResultsWebhook",
"test:cloud:prod": "yarn run test --testPathIgnorePatterns=\\.integration\\.", "test:cloud:prod": "yarn run test --testPathIgnorePatterns=\\.integration\\.",
"test:cloud:qa": "yarn run test", "test:cloud:qa": "yarn run test",
"test:self:ci": "yarn run test --testPathIgnorePatterns=\\.integration\\. \\.cloud\\.", "test:self:ci": "yarn run test --testPathIgnorePatterns=\\.integration\\. \\.cloud\\. \\.license\\.",
"serve:test:self:ci": "start-server-and-test dev:built http://localhost:4001/health test:self:ci", "serve:test:self:ci": "start-server-and-test dev:built http://localhost:4001/health test:self:ci",
"serve": "start-server-and-test dev:built http://localhost:4001/health", "serve": "start-server-and-test dev:built http://localhost:4001/health",
"dev:built": "cd ../ && yarn dev:built" "dev:built": "cd ../ && yarn dev:built"

View File

@ -1,5 +1,5 @@
import AccountInternalAPIClient from "./AccountInternalAPIClient" import AccountInternalAPIClient from "./AccountInternalAPIClient"
import { AccountAPI, LicenseAPI, AuthAPI } from "./apis" import { AccountAPI, LicenseAPI, AuthAPI, StripeAPI } from "./apis"
import { State } from "../../types" import { State } from "../../types"
export default class AccountInternalAPI { export default class AccountInternalAPI {
@ -8,11 +8,13 @@ export default class AccountInternalAPI {
auth: AuthAPI auth: AuthAPI
accounts: AccountAPI accounts: AccountAPI
licenses: LicenseAPI licenses: LicenseAPI
stripe: StripeAPI
constructor(state: State) { constructor(state: State) {
this.client = new AccountInternalAPIClient(state) this.client = new AccountInternalAPIClient(state)
this.auth = new AuthAPI(this.client) this.auth = new AuthAPI(this.client)
this.accounts = new AccountAPI(this.client) this.accounts = new AccountAPI(this.client)
this.licenses = new LicenseAPI(this.client) this.licenses = new LicenseAPI(this.client)
this.stripe = new StripeAPI(this.client)
} }
} }

View File

@ -2,21 +2,19 @@ import AccountInternalAPIClient from "../AccountInternalAPIClient"
import { import {
Account, Account,
CreateOfflineLicenseRequest, CreateOfflineLicenseRequest,
GetLicenseKeyResponse,
GetOfflineLicenseResponse, GetOfflineLicenseResponse,
UpdateLicenseRequest, UpdateLicenseRequest,
} from "@budibase/types" } from "@budibase/types"
import { Response } from "node-fetch" import { Response } from "node-fetch"
import BaseAPI from "./BaseAPI" import BaseAPI from "./BaseAPI"
import { APIRequestOpts } from "../../../types" import { APIRequestOpts } from "../../../types"
export default class LicenseAPI extends BaseAPI { export default class LicenseAPI extends BaseAPI {
client: AccountInternalAPIClient client: AccountInternalAPIClient
constructor(client: AccountInternalAPIClient) { constructor(client: AccountInternalAPIClient) {
super() super()
this.client = client this.client = client
} }
async updateLicense( async updateLicense(
accountId: string, accountId: string,
body: UpdateLicenseRequest, body: UpdateLicenseRequest,
@ -29,9 +27,7 @@ export default class LicenseAPI extends BaseAPI {
}) })
}, opts) }, opts)
} }
// TODO: Better approach for setting tenant id header // TODO: Better approach for setting tenant id header
async createOfflineLicense( async createOfflineLicense(
accountId: string, accountId: string,
tenantId: string, tenantId: string,
@ -51,7 +47,6 @@ export default class LicenseAPI extends BaseAPI {
expect(response.status).toBe(opts.status ? opts.status : 201) expect(response.status).toBe(opts.status ? opts.status : 201)
return response return response
} }
async getOfflineLicense( async getOfflineLicense(
accountId: string, accountId: string,
tenantId: string, tenantId: string,
@ -69,4 +64,74 @@ export default class LicenseAPI extends BaseAPI {
expect(response.status).toBe(opts.status ? opts.status : 200) expect(response.status).toBe(opts.status ? opts.status : 200)
return [response, json] return [response, json]
} }
async getLicenseKey(
opts: { status?: number } = {}
): Promise<[Response, GetLicenseKeyResponse]> {
const [response, json] = await this.client.get(`/api/license/key`)
expect(response.status).toBe(opts.status || 200)
return [response, json]
}
async activateLicense(
apiKey: string,
tenantId: string,
licenseKey: string,
opts: APIRequestOpts = { status: 200 }
) {
return this.doRequest(() => {
return this.client.post(`/api/license/activate`, {
body: {
apiKey: apiKey,
tenantId: tenantId,
licenseKey: licenseKey,
},
})
}, opts)
}
async regenerateLicenseKey(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.post(`/api/license/key/regenerate`, {})
}, opts)
}
async getPlans(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.get(`/api/plans`)
}, opts)
}
async updatePlan(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.put(`/api/license/plan`)
}, opts)
}
async refreshAccountLicense(
accountId: string,
opts: { status?: number } = {}
): Promise<Response> {
const [response, json] = await this.client.post(
`/api/accounts/${accountId}/license/refresh`,
{
internal: true,
}
)
expect(response.status).toBe(opts.status ? opts.status : 201)
return response
}
async getLicenseUsage(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.get(`/api/license/usage`)
}, opts)
}
async licenseUsageTriggered(
opts: { status?: number } = {}
): Promise<Response> {
const [response, json] = await this.client.post(
`/api/license/usage/triggered`
)
expect(response.status).toBe(opts.status ? opts.status : 201)
return response
}
} }

View File

@ -0,0 +1,64 @@
import AccountInternalAPIClient from "../AccountInternalAPIClient"
import BaseAPI from "./BaseAPI"
import { APIRequestOpts } from "../../../types"
export default class StripeAPI extends BaseAPI {
client: AccountInternalAPIClient
constructor(client: AccountInternalAPIClient) {
super()
this.client = client
}
async createCheckoutSession(
priceId: string,
opts: APIRequestOpts = { status: 200 }
) {
return this.doRequest(() => {
return this.client.post(`/api/stripe/checkout-session`, {
body: { priceId },
})
}, opts)
}
async checkoutSuccess(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.post(`/api/stripe/checkout-success`)
}, opts)
}
async createPortalSession(
stripeCustomerId: string,
opts: APIRequestOpts = { status: 200 }
) {
return this.doRequest(() => {
return this.client.post(`/api/stripe/portal-session`, {
body: { stripeCustomerId },
})
}, opts)
}
async linkStripeCustomer(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.post(`/api/stripe/link`)
}, opts)
}
async getInvoices(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.get(`/api/stripe/invoices`)
}, opts)
}
async getUpcomingInvoice(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.get(`/api/stripe/upcoming-invoice`)
}, opts)
}
async getStripeCustomers(opts: APIRequestOpts = { status: 200 }) {
return this.doRequest(() => {
return this.client.get(`/api/stripe/customers`)
}, opts)
}
}

View File

@ -1,3 +1,4 @@
export { default as AuthAPI } from "./AuthAPI" export { default as AuthAPI } from "./AuthAPI"
export { default as AccountAPI } from "./AccountAPI" export { default as AccountAPI } from "./AccountAPI"
export { default as LicenseAPI } from "./LicenseAPI" export { default as LicenseAPI } from "./LicenseAPI"
export { default as StripeAPI } from "./StripeAPI"

View File

@ -0,0 +1,68 @@
import TestConfiguration from "../../config/TestConfiguration"
import * as fixures from "../../fixtures"
import { Feature, Hosting } from "@budibase/types"
describe("license activation", () => {
const config = new TestConfiguration()
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
it("creates, activates and deletes online license - self host", async () => {
// Remove existing license key
await config.internalApi.license.deleteLicenseKey()
// Verify license key not found
await config.internalApi.license.getLicenseKey({ status: 404 })
// Create self host account
const createAccountRequest = fixures.accounts.generateAccount({
hosting: Hosting.SELF,
})
const [createAccountRes, account] =
await config.accountsApi.accounts.create(createAccountRequest, {
autoVerify: true,
})
let licenseKey: string = " "
await config.doInNewState(async () => {
await config.loginAsAccount(createAccountRequest)
// Retrieve license key
const [res, body] = await config.accountsApi.licenses.getLicenseKey()
licenseKey = body.licenseKey
})
const accountId = account.accountId!
// Update license to have paid feature
const [res, acc] = await config.accountsApi.licenses.updateLicense(
accountId,
{
overrides: {
features: [Feature.APP_BACKUPS],
},
}
)
// Activate license key
await config.internalApi.license.activateLicenseKey({ licenseKey })
// Verify license updated with new feature
await config.doInNewState(async () => {
await config.loginAsAccount(createAccountRequest)
const [selfRes, body] = await config.api.accounts.self()
expect(body.license.features[0]).toBe("appBackups")
})
// Remove license key
await config.internalApi.license.deleteLicenseKey()
// Verify license key not found
await config.internalApi.license.getLicenseKey({ status: 404 })
})
})

View File

@ -1,17 +1,19 @@
import { Response } from "node-fetch" import { Response } from "node-fetch"
import { import {
ActivateLicenseKeyRequest,
ActivateOfflineLicenseTokenRequest, ActivateOfflineLicenseTokenRequest,
GetLicenseKeyResponse,
GetOfflineIdentifierResponse, GetOfflineIdentifierResponse,
GetOfflineLicenseTokenResponse, GetOfflineLicenseTokenResponse,
} from "@budibase/types" } from "@budibase/types"
import BudibaseInternalAPIClient from "../BudibaseInternalAPIClient" import BudibaseInternalAPIClient from "../BudibaseInternalAPIClient"
import BaseAPI from "./BaseAPI" import BaseAPI from "./BaseAPI"
import { APIRequestOpts } from "../../../types"
export default class LicenseAPI extends BaseAPI { export default class LicenseAPI extends BaseAPI {
constructor(client: BudibaseInternalAPIClient) { constructor(client: BudibaseInternalAPIClient) {
super(client) super(client)
} }
async getOfflineLicenseToken( async getOfflineLicenseToken(
opts: { status?: number } = {} opts: { status?: number } = {}
): Promise<[Response, GetOfflineLicenseTokenResponse]> { ): Promise<[Response, GetOfflineLicenseTokenResponse]> {
@ -21,19 +23,16 @@ export default class LicenseAPI extends BaseAPI {
) )
return [response, body] return [response, body]
} }
async deleteOfflineLicenseToken(): Promise<[Response]> { async deleteOfflineLicenseToken(): Promise<[Response]> {
const [response] = await this.del(`/global/license/offline`, 204) const [response] = await this.del(`/global/license/offline`, 204)
return [response] return [response]
} }
async activateOfflineLicenseToken( async activateOfflineLicenseToken(
body: ActivateOfflineLicenseTokenRequest body: ActivateOfflineLicenseTokenRequest
): Promise<[Response]> { ): Promise<[Response]> {
const [response] = await this.post(`/global/license/offline`, body) const [response] = await this.post(`/global/license/offline`, body)
return [response] return [response]
} }
async getOfflineIdentifier(): Promise< async getOfflineIdentifier(): Promise<
[Response, GetOfflineIdentifierResponse] [Response, GetOfflineIdentifierResponse]
> { > {
@ -42,4 +41,23 @@ export default class LicenseAPI extends BaseAPI {
) )
return [response, body] return [response, body]
} }
async getLicenseKey(
opts: { status?: number } = {}
): Promise<[Response, GetLicenseKeyResponse]> {
const [response, body] = await this.get(`/global/license/key`, opts.status)
return [response, body]
}
async activateLicenseKey(
body: ActivateLicenseKeyRequest
): Promise<[Response]> {
const [response] = await this.post(`/global/license/key`, body)
return [response]
}
async deleteLicenseKey(): Promise<[Response]> {
const [response] = await this.del(`/global/license/key`, 204)
return [response]
}
} }