merge develop

This commit is contained in:
Keviin Åberg Kultalahti 2021-02-12 14:24:38 +01:00
commit c81fb5e8be
67 changed files with 1323 additions and 8262 deletions

View File

@ -1,3 +1,5 @@
Copyright 2019-2021, Budibase Ltd
Each Budibase package has its own license:
builder: AGPLv3

8068
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Copyright 2019-2021, Budibase Ltd
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.

View File

@ -5,7 +5,6 @@
const rimraf = require("rimraf")
const { join, resolve } = require("path")
// const run = require("../../cli/src/commands/run/runHandler")
const initialiseBudibase = require("../../server/src/utilities/initialiseBudibase")
const homedir = join(require("os").homedir(), ".budibase")

View File

@ -63,7 +63,7 @@
}
},
"dependencies": {
"@budibase/bbui": "^1.58.4",
"@budibase/bbui": "^1.58.5",
"@budibase/client": "^0.7.6",
"@budibase/colorpicker": "1.0.1",
"@budibase/string-templates": "^0.7.6",

View File

@ -153,8 +153,6 @@ export const getContextBindings = (rootComponent, componentId) => {
// datasource options, based on bindable properties
fieldSchema,
providerId: component._id,
// tableId: table._id,
// field: key,
})
})
})
@ -186,8 +184,6 @@ export const getContextBindings = (rootComponent, componentId) => {
// datasource options, based on bindable properties
fieldSchema,
providerId: "user",
// tableId: TableNames.USERS,
// field: key,
})
})
@ -215,7 +211,9 @@ export const getSchemaForDatasource = (datasource, isForm = false) => {
schema = {}
const params = table.parameters || []
params.forEach(param => {
schema[param.name] = { ...param, type: "string" }
if (param?.name) {
schema[param.name] = { ...param, type: "string" }
}
})
} else {
schema = cloneDeep(table.schema)

View File

@ -232,7 +232,7 @@ export const getBackendUiStore = () => {
return state
})
},
saveField: ({ originalName, field, primaryDisplay = false, oneToMany = false }) => {
saveField: ({ originalName, field, primaryDisplay = false, indexes, oneToMany = false }) => {
store.update(state => {
console.log(state)
// delete the original if renaming
@ -254,6 +254,10 @@ export const getBackendUiStore = () => {
state.draftTable.oneToMany = field.name
}
if (indexes) {
state.draftTable.indexes = indexes
}
state.draftTable.schema[field.name] = cloneDeep(field)
store.actions.tables.save(state.draftTable)
return state

View File

@ -6,7 +6,7 @@ import {
makeMainForm,
makeTitleContainer,
makeSaveButton,
makeTableFormComponents,
makeDatasourceFormComponents,
} from "./utils/commonComponents"
export default function(tables) {
@ -51,7 +51,8 @@ const createScreen = table => {
})
// Add all form fields from this schema to the field group
makeTableFormComponents(table._id).forEach(component => {
const datasource = { type: "table", tableId: table._id }
makeDatasourceFormComponents(datasource).forEach(component => {
fieldGroup.addChild(component)
})

View File

@ -7,9 +7,9 @@ import {
makeBreadcrumbContainer,
makeTitleContainer,
makeSaveButton,
makeTableFormComponents,
makeMainForm,
spectrumColor,
makeDatasourceFormComponents,
} from "./utils/commonComponents"
export default function(tables) {
@ -109,7 +109,8 @@ const createScreen = table => {
})
// Add all form fields from this schema to the field group
makeTableFormComponents(table._id).forEach(component => {
const datasource = { type: "table", tableId: table._id }
makeDatasourceFormComponents(datasource).forEach(component => {
fieldGroup.addChild(component)
})

View File

@ -1,7 +1,6 @@
import { get } from "svelte/store"
import { Component } from "./Component"
import { rowListUrl } from "../rowListScreen"
import { backendUiStore } from "builderStore"
import { getSchemaForDatasource } from "../../../dataBinding"
export function spectrumColor(number) {
// Acorn throws a parsing error in this file if the word g-l-o-b-a-l is found
@ -174,37 +173,15 @@ const fieldTypeToComponentMap = {
link: "relationshipfield",
}
export function makeTableFormComponents(tableId) {
const tables = get(backendUiStore).tables
const schema = tables.find(table => table._id === tableId)?.schema ?? {}
return makeSchemaFormComponents(schema)
}
export function makeQueryFormComponents(queryId) {
const queries = get(backendUiStore).queries
const params = queries.find(query => query._id === queryId)?.parameters ?? []
let schema = {}
params.forEach(param => {
schema[param.name] = { ...param, type: "string" }
})
return makeSchemaFormComponents(schema)
}
export function makeDatasourceFormComponents(datasource) {
if (!datasource) {
return []
}
return datasource.type === "table"
? makeTableFormComponents(datasource.tableId)
: makeQueryFormComponents(datasource._id)
}
function makeSchemaFormComponents(schema) {
const { schema } = getSchemaForDatasource(datasource, true)
let components = []
let fields = Object.keys(schema || {})
fields.forEach(field => {
const fieldSchema = schema[field]
const componentType = fieldTypeToComponentMap[fieldSchema.type]
const fieldType =
typeof fieldSchema === "object" ? fieldSchema.type : fieldSchema
const componentType = fieldTypeToComponentMap[fieldType]
const fullComponentType = `@budibase/standard-components/${componentType}`
if (componentType) {
const component = new Component(fullComponentType)
@ -214,10 +191,10 @@ function makeSchemaFormComponents(schema) {
label: field,
placeholder: field,
})
if (fieldSchema.type === "options") {
if (fieldType === "options") {
component.customProps({ placeholder: "Choose an option " })
}
if (fieldSchema.type === "boolean") {
if (fieldType === "boolean") {
component.customProps({ text: field, label: "" })
}
components.push(component)

View File

@ -1,5 +1,12 @@
<script>
import { Input, Button, TextButton, Select, Toggle } from "@budibase/bbui"
import {
Input,
Button,
Label,
TextButton,
Select,
Toggle,
} from "@budibase/bbui"
import { cloneDeep } from "lodash/fp"
import { backendUiStore } from "builderStore"
import { TableNames, UNEDITABLE_USER_FIELDS } from "constants"
@ -24,7 +31,9 @@
let primaryDisplay =
$backendUiStore.selectedTable.primaryDisplay == null ||
$backendUiStore.selectedTable.primaryDisplay === field.name
let oneToMany = false;
let indexes = [...($backendUiStore.selectedTable.indexes || [])]
let confirmDeleteDialog
let deletion
@ -42,7 +51,11 @@
originalName,
field,
primaryDisplay,
<<<<<<< HEAD
oneToMany,
=======
indexes,
>>>>>>> develop
})
return state
})
@ -81,6 +94,18 @@
}
}
function onChangePrimaryIndex(e) {
indexes = e.target.checked ? [field.name] : []
}
function onChangeSecondaryIndex(e) {
if (e.target.checked) {
indexes[1] = field.name
} else {
indexes = indexes.slice(0, 1)
}
}
function confirmDelete() {
confirmDeleteDialog.show()
deletion = true
@ -122,6 +147,20 @@
on:change={onChangePrimaryDisplay}
thin
text="Use as table display column" />
<Label gray small>Search Indexes</Label>
<Toggle
checked={indexes[0] === field.name}
disabled={indexes[1] === field.name}
on:change={onChangePrimaryIndex}
thin
text="Primary" />
<Toggle
checked={indexes[1] === field.name}
disabled={!indexes[0] || indexes[0] === field.name}
on:change={onChangeSecondaryIndex}
thin
text="Secondary" />
{/if}
{#if field.type === 'string'}

View File

@ -6,7 +6,7 @@
import ErrorsBox from "components/common/ErrorsBox.svelte"
import { backendUiStore } from "builderStore"
let permissions = []
let basePermissions = []
let selectedRole = {}
let errors = []
let builtInRoles = ["Admin", "Power", "Basic", "Public"]
@ -16,9 +16,9 @@
)
$: isCreating = selectedRoleId == null || selectedRoleId === ""
const fetchPermissions = async () => {
const permissionsResponse = await api.get("/api/permissions")
permissions = await permissionsResponse.json()
const fetchBasePermissions = async () => {
const permissionsResponse = await api.get("/api/permission/builtin")
basePermissions = await permissionsResponse.json()
}
// Changes the selected role
@ -81,7 +81,7 @@
}
}
onMount(fetchPermissions)
onMount(fetchBasePermissions)
</script>
<ModalContent
@ -121,11 +121,11 @@
<Select
thin
secondary
label="Permissions"
label="Base Permissions"
bind:value={selectedRole.permissionId}>
<option value="">Choose permissions</option>
{#each permissions as permission}
<option value={permission._id}>{permission.name}</option>
{#each basePermissions as basePerm}
<option value={basePerm._id}>{basePerm.name}</option>
{/each}
</Select>
{/if}

View File

@ -41,7 +41,7 @@
.icon {
right: 2px;
top: 26px;
top: 5px;
bottom: 2px;
position: absolute;
align-items: center;

View File

@ -3,6 +3,7 @@
"datagrid",
"list",
"button",
"search",
{
"name": "Form",
"icon": "ri-file-edit-line",

View File

@ -46,6 +46,11 @@
innerVal = value.target.value
}
}
if (type === "number") {
innerVal = parseInt(innerVal)
}
if (typeof innerVal === "string") {
onChange(replaceBindings(innerVal))
} else {
@ -72,6 +77,7 @@
value={safeValue}
on:change={handleChange}
onChange={handleChange}
{type}
{...props}
name={key} />
</div>

View File

@ -842,10 +842,10 @@
lodash "^4.17.19"
to-fast-properties "^2.0.0"
"@budibase/bbui@^1.58.4":
version "1.58.4"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.58.4.tgz#a74d66b3dd715b0a9861a0f86bc0b863fd8f1d44"
integrity sha512-1oEVt7zMREM594CAUIXqOtiuP4Sx4FbfgPBHTZ+t4RhFfbFqvU7yyakqPZM2LhTAmO5Rfa+c+dfFLh+y1++KaA==
"@budibase/bbui@^1.58.5":
version "1.58.5"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.58.5.tgz#c9ce712941760825c7774a1de77594e989db4561"
integrity sha512-0j1I7BetJ2GzB1BXKyvvlkuFphLmADJh2U/Ihubwxx5qUDY8REoVzLgAB4c24zt0CGVTF9VMmOoMLd0zD0QwdQ==
dependencies:
markdown-it "^12.0.2"
quill "^1.3.7"

View File

@ -1,6 +1,8 @@
Mozilla Public License Version 2.0
==================================
Copyright 2019-2021, Budibase Ltd
1. Definitions
--------------

View File

@ -16,3 +16,19 @@ export const fetchTableData = async tableId => {
const rows = await API.get({ url: `/api/${tableId}/rows` })
return await enrichRows(rows, tableId)
}
/**
* Perform a mango query against an internal table
* @param {String} tableId - id of the table to search
* @param {Object} search - Mango Compliant search object
*/
export const searchTableData = async ({ tableId, search, pagination }) => {
const rows = await API.post({
url: `/api/${tableId}/rows/search`,
body: {
query: search,
pagination,
},
})
return await enrichRows(rows, tableId)
}

View File

@ -11,17 +11,13 @@
// Clone and create new data context for this component tree
const context = getContext("context")
const component = getContext("component")
const newContext = createContextStore()
const newContext = createContextStore(context)
setContext("context", newContext)
let initiated = false
$: providerKey = key || $component.id
// Add data context
$: {
newContext.actions.provideData(providerKey, $context, data)
initiated = true
}
$: newContext.actions.provideData(providerKey, data)
// Instance ID is unique to each instance of a provider
let instanceId
@ -56,6 +52,4 @@
})
</script>
{#if initiated}
<slot />
{/if}
<slot />

View File

@ -1,20 +1,27 @@
import { writable } from "svelte/store"
import { writable, derived } from "svelte/store"
export const createContextStore = () => {
const store = writable({})
export const createContextStore = oldContext => {
const newContext = writable({})
const contexts = oldContext ? [oldContext, newContext] : [newContext]
const totalContext = derived(contexts, $contexts => {
return $contexts.reduce((total, context) => ({ ...total, ...context }), {})
})
// Adds a data context layer to the tree
const provideData = (providerId, context, data) => {
let newData = { ...context }
if (providerId && data !== undefined) {
newData[providerId] = data
const provideData = (providerId, data) => {
if (!providerId || data === undefined) {
return
}
newContext.update(state => {
state[providerId] = data
// Keep track of the closest component ID so we can later hydrate a "data" prop.
// This is only required for legacy bindings that used "data" rather than a
// component ID.
newData.closestComponentId = providerId
}
store.set(newData)
state.closestComponentId = providerId
return state
})
}
// Adds an action context layer to the tree
@ -22,14 +29,14 @@ export const createContextStore = () => {
if (!providerId || !actionType) {
return
}
store.update(state => {
newContext.update(state => {
state[`${providerId}_${actionType}`] = callback
return state
})
}
return {
subscribe: store.subscribe,
subscribe: totalContext.subscribe,
actions: { provideData, provideAction },
}
}

View File

@ -1,4 +1,5 @@
import { writable, get } from "svelte/store"
import { notificationStore } from "./notification"
export const createDatasourceStore = () => {
const store = writable([])
@ -66,6 +67,9 @@ export const createDatasourceStore = () => {
const relatedInstances = get(store).filter(instance => {
return instance.datasourceId === datasourceId
})
if (relatedInstances?.length) {
notificationStore.blockNotifications(1000)
}
relatedInstances?.forEach(instance => {
instance.refresh()
})

View File

@ -5,13 +5,22 @@ const NOTIFICATION_TIMEOUT = 3000
const createNotificationStore = () => {
const _notifications = writable([])
let block = false
const send = (message, type = "default") => {
if (block) {
return
}
_notifications.update(state => {
return [...state, { id: generate(), type, message }]
})
}
const blockNotifications = (timeout = 1000) => {
block = true
setTimeout(() => (block = false), timeout)
}
const notifications = derived(_notifications, ($_notifications, set) => {
set($_notifications)
if ($_notifications.length > 0) {
@ -36,6 +45,7 @@ const createNotificationStore = () => {
warning: msg => send(msg, "warning"),
info: msg => send(msg, "info"),
success: msg => send(msg, "success"),
blockNotifications,
}
}

View File

@ -1,7 +1,7 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Copyright 2019-2021, Budibase Ltd
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.

View File

@ -91,6 +91,7 @@
"pino-pretty": "4.0.0",
"pouchdb": "7.2.1",
"pouchdb-all-dbs": "1.0.2",
"pouchdb-find": "^7.2.2",
"pouchdb-replication-stream": "1.2.9",
"sanitize-s3-objectkey": "0.0.1",
"server-destroy": "1.0.1",

View File

@ -14,6 +14,7 @@ exports.fetchInfo = async ctx => {
}
exports.save = async ctx => {
console.trace("DID A SAVE!")
const db = new CouchDB(BUILDER_CONFIG_DB)
const { type } = ctx.request.body
if (type === HostingTypes.CLOUD && ctx.request.body._rev) {

View File

@ -1,6 +1,154 @@
const { BUILTIN_PERMISSIONS } = require("../../utilities/security/permissions")
const {
BUILTIN_PERMISSIONS,
PermissionLevels,
higherPermission,
} = require("../../utilities/security/permissions")
const {
isBuiltin,
getDBRoleID,
getExternalRoleID,
BUILTIN_ROLES,
} = require("../../utilities/security/roles")
const { getRoleParams } = require("../../db/utils")
const CouchDB = require("../../db")
const { cloneDeep } = require("lodash/fp")
exports.fetch = async function(ctx) {
// TODO: need to build out custom permissions
const PermissionUpdateType = {
REMOVE: "remove",
ADD: "add",
}
// utility function to stop this repetition - permissions always stored under roles
async function getAllDBRoles(db) {
const body = await db.allDocs(
getRoleParams(null, {
include_docs: true,
})
)
return body.rows.map(row => row.doc)
}
async function updatePermissionOnRole(
appId,
{ roleId, resourceId, level },
updateType
) {
const db = new CouchDB(appId)
const remove = updateType === PermissionUpdateType.REMOVE
const isABuiltin = isBuiltin(roleId)
const dbRoleId = getDBRoleID(roleId)
const dbRoles = await getAllDBRoles(db)
const docUpdates = []
// the permission is for a built in, make sure it exists
if (isABuiltin && !dbRoles.some(role => role._id === dbRoleId)) {
const builtin = cloneDeep(BUILTIN_ROLES[roleId])
builtin._id = getDBRoleID(builtin._id)
dbRoles.push(builtin)
}
// now try to find any roles which need updated, e.g. removing the
// resource from another role and then adding to the new role
for (let role of dbRoles) {
let updated = false
const rolePermissions = role.permissions ? role.permissions : {}
// handle the removal/updating the role which has this permission first
// the updating (role._id !== dbRoleId) is required because a resource/level can
// only be permitted in a single role (this reduces hierarchy confusion and simplifies
// the general UI for this, rather than needing to show everywhere it is used)
if (
(role._id !== dbRoleId || remove) &&
rolePermissions[resourceId] === level
) {
delete rolePermissions[resourceId]
updated = true
}
// handle the adding, we're on the correct role, at it to this
if (!remove && role._id === dbRoleId) {
rolePermissions[resourceId] = level
updated = true
}
// handle the update, add it to bulk docs to perform at end
if (updated) {
role.permissions = rolePermissions
docUpdates.push(role)
}
}
const response = await db.bulkDocs(docUpdates)
return response.map(resp => {
resp._id = getExternalRoleID(resp.id)
delete resp.id
return resp
})
}
exports.fetchBuiltin = function(ctx) {
ctx.body = Object.values(BUILTIN_PERMISSIONS)
}
exports.fetchLevels = function(ctx) {
// for now only provide the read/write perms externally
ctx.body = [PermissionLevels.WRITE, PermissionLevels.READ]
}
exports.fetch = async function(ctx) {
const db = new CouchDB(ctx.appId)
const roles = await getAllDBRoles(db)
let permissions = {}
// create an object with structure role ID -> resource ID -> level
for (let role of roles) {
if (role.permissions) {
const roleId = getExternalRoleID(role._id)
if (permissions[roleId] == null) {
permissions[roleId] = {}
}
for (let [resource, level] of Object.entries(role.permissions)) {
permissions[roleId][resource] = higherPermission(
permissions[roleId][resource],
level
)
}
}
}
ctx.body = permissions
}
exports.getResourcePerms = async function(ctx) {
const resourceId = ctx.params.resourceId
const db = new CouchDB(ctx.appId)
const body = await db.allDocs(
getRoleParams(null, {
include_docs: true,
})
)
const roles = body.rows.map(row => row.doc)
const resourcePerms = {}
for (let role of roles) {
// update the various roleIds in the resource permissions
if (role.permissions && role.permissions[resourceId]) {
const roleId = getExternalRoleID(role._id)
resourcePerms[roleId] = higherPermission(
resourcePerms[roleId],
role.permissions[resourceId]
)
}
}
ctx.body = resourcePerms
}
exports.addPermission = async function(ctx) {
ctx.body = await updatePermissionOnRole(
ctx.appId,
ctx.params,
PermissionUpdateType.ADD
)
}
exports.removePermission = async function(ctx) {
ctx.body = await updatePermissionOnRole(
ctx.appId,
ctx.params,
PermissionUpdateType.REMOVE
)
}

View File

@ -1,8 +1,11 @@
const CouchDB = require("../../db")
const {
BUILTIN_ROLES,
BUILTIN_ROLE_IDS,
Role,
getRole,
isBuiltin,
getExternalRoleID,
} = require("../../utilities/security/roles")
const {
generateRoleID,
@ -16,6 +19,14 @@ const UpdateRolesOptions = {
REMOVED: "removed",
}
// exclude internal roles like builder
const EXTERNAL_BUILTIN_ROLE_IDS = [
BUILTIN_ROLE_IDS.ADMIN,
BUILTIN_ROLE_IDS.POWER,
BUILTIN_ROLE_IDS.BASIC,
BUILTIN_ROLE_IDS.PUBLIC,
]
async function updateRolesOnUserTable(db, roleId, updateOption) {
const table = await db.get(ViewNames.USERS)
const schema = table.schema
@ -46,16 +57,22 @@ exports.fetch = async function(ctx) {
include_docs: true,
})
)
const customRoles = body.rows.map(row => row.doc)
const roles = body.rows.map(row => row.doc)
// exclude internal roles like builder
const staticRoles = [
BUILTIN_ROLES.ADMIN,
BUILTIN_ROLES.POWER,
BUILTIN_ROLES.BASIC,
BUILTIN_ROLES.PUBLIC,
]
ctx.body = [...staticRoles, ...customRoles]
// need to combine builtin with any DB record of them (for sake of permissions)
for (let builtinRoleId of EXTERNAL_BUILTIN_ROLE_IDS) {
const builtinRole = BUILTIN_ROLES[builtinRoleId]
const dbBuiltin = roles.filter(
dbRole => getExternalRoleID(dbRole._id) === builtinRoleId
)[0]
if (dbBuiltin == null) {
roles.push(builtinRole)
} else {
dbBuiltin._id = getExternalRoleID(dbBuiltin._id)
roles.push(Object.assign(builtinRole, dbBuiltin))
}
}
ctx.body = roles
}
exports.find = async function(ctx) {
@ -67,6 +84,8 @@ exports.save = async function(ctx) {
let { _id, name, inherits, permissionId } = ctx.request.body
if (!_id) {
_id = generateRoleID()
} else if (isBuiltin(_id)) {
ctx.throw(400, "Cannot update builtin roles.")
}
const role = new Role(_id, name)
.addPermission(permissionId)
@ -84,6 +103,9 @@ exports.save = async function(ctx) {
exports.destroy = async function(ctx) {
const db = new CouchDB(ctx.user.appId)
const roleId = ctx.params.roleId
if (isBuiltin(roleId)) {
ctx.throw(400, "Cannot delete builtin role.")
}
// first check no users actively attached to role
const users = (
await db.allDocs(
@ -94,7 +116,7 @@ exports.destroy = async function(ctx) {
).rows.map(row => row.doc)
const usersWithRole = users.filter(user => user.roleId === roleId)
if (usersWithRole.length !== 0) {
ctx.throw("Cannot delete role when it is in use.")
ctx.throw(400, "Cannot delete role when it is in use.")
}
await db.remove(roleId, ctx.params.rev)

View File

@ -54,7 +54,7 @@ async function findRow(db, appId, tableId, rowId) {
exports.patch = async function(ctx) {
const appId = ctx.user.appId
const db = new CouchDB(appId)
let row = await db.get(ctx.params.id)
let row = await db.get(ctx.params.rowId)
const table = await db.get(row.tableId)
const patchfields = ctx.request.body
@ -123,7 +123,7 @@ exports.save = async function(ctx) {
// if the row obj had an _id then it will have been retrieved
const existingRow = ctx.preExisting
if (existingRow) {
ctx.params.id = row._id
ctx.params.rowId = row._id
await exports.patch(ctx)
return
}
@ -229,6 +229,38 @@ exports.fetchView = async function(ctx) {
}
}
exports.search = async function(ctx) {
const appId = ctx.user.appId
const db = new CouchDB(appId)
const {
query,
pagination: { pageSize = 10, page },
} = ctx.request.body
query.tableId = ctx.params.tableId
const response = await db.find({
selector: query,
limit: pageSize,
skip: pageSize * page,
})
const rows = response.docs
// delete passwords from users
if (query.tableId === ViewNames.USERS) {
for (let row of rows) {
delete row.password
}
}
const table = await db.get(ctx.params.tableId)
ctx.body = await enrichRows(appId, table, rows)
}
exports.fetchTableRows = async function(ctx) {
const appId = ctx.user.appId
const db = new CouchDB(appId)

View File

@ -7,6 +7,7 @@ const {
generateTableID,
generateRowID,
} = require("../../db/utils")
const { isEqual } = require("lodash/fp")
async function checkForColumnUpdates(db, oldTable, updatedTable) {
let updatedRows
@ -128,6 +129,46 @@ exports.save = async function(ctx) {
const result = await db.post(tableToSave)
tableToSave._rev = result.rev
// create relevant search indexes
if (tableToSave.indexes && tableToSave.indexes.length > 0) {
const currentIndexes = await db.getIndexes()
const indexName = `search:${result.id}`
const existingIndex = currentIndexes.indexes.find(
existing => existing.name === indexName
)
if (existingIndex) {
const currentFields = existingIndex.def.fields.map(
field => Object.keys(field)[0]
)
// if index fields have changed, delete the original index
if (!isEqual(currentFields, tableToSave.indexes)) {
await db.deleteIndex(existingIndex)
// create/recreate the index with fields
await db.createIndex({
index: {
fields: tableToSave.indexes,
name: indexName,
ddoc: "search_ddoc",
type: "json",
},
})
}
} else {
// create/recreate the index with fields
await db.createIndex({
index: {
fields: tableToSave.indexes,
name: indexName,
ddoc: "search_ddoc",
type: "json",
},
})
}
}
ctx.eventEmitter &&
ctx.eventEmitter.emitTable(`table:save`, appId, tableToSave)
@ -171,6 +212,15 @@ exports.destroy = async function(ctx) {
// don't remove the table itself until very end
await db.remove(tableToDelete)
// remove table search index
const currentIndexes = await db.getIndexes()
const existingIndex = currentIndexes.indexes.find(
existing => existing.name === `search:${ctx.params.tableId}`
)
if (existingIndex) {
await db.deleteIndex(existingIndex)
}
ctx.eventEmitter &&
ctx.eventEmitter.emitTable(`table:delete`, appId, tableToDelete)
ctx.status = 200

View File

@ -47,6 +47,7 @@ router.use(async (ctx, next) => {
message: err.message,
status: ctx.status,
}
console.trace(err)
}
})

View File

@ -8,6 +8,7 @@ const {
PermissionTypes,
} = require("../../utilities/security/permissions")
const Joi = require("joi")
const { bodyResource, paramResource } = require("../../middleware/resourceId")
const router = Router()
@ -64,9 +65,15 @@ router
controller.getDefinitionList
)
.get("/api/automations", authorized(BUILDER), controller.fetch)
.get("/api/automations/:id", authorized(BUILDER), controller.find)
.get(
"/api/automations/:id",
paramResource("id"),
authorized(BUILDER),
controller.find
)
.put(
"/api/automations",
bodyResource("_id"),
authorized(BUILDER),
generateValidator(true),
controller.update
@ -79,9 +86,15 @@ router
)
.post(
"/api/automations/:id/trigger",
paramResource("id"),
authorized(PermissionTypes.AUTOMATION, PermissionLevels.EXECUTE),
controller.trigger
)
.delete("/api/automations/:id/:rev", authorized(BUILDER), controller.destroy)
.delete(
"/api/automations/:id/:rev",
paramResource("id"),
authorized(BUILDER),
controller.destroy
)
module.exports = router

View File

@ -1,10 +1,47 @@
const Router = require("@koa/router")
const controller = require("../controllers/permission")
const authorized = require("../../middleware/authorized")
const { BUILDER } = require("../../utilities/security/permissions")
const {
BUILDER,
PermissionLevels,
} = require("../../utilities/security/permissions")
const Joi = require("joi")
const joiValidator = require("../../middleware/joi-validator")
const router = Router()
router.get("/api/permissions", authorized(BUILDER), controller.fetch)
function generateValidator() {
const permLevelArray = Object.values(PermissionLevels)
// prettier-ignore
return joiValidator.params(Joi.object({
level: Joi.string().valid(...permLevelArray).required(),
resourceId: Joi.string(),
roleId: Joi.string(),
}).unknown(true))
}
router
.get("/api/permission/builtin", authorized(BUILDER), controller.fetchBuiltin)
.get("/api/permission/levels", authorized(BUILDER), controller.fetchLevels)
.get("/api/permission", authorized(BUILDER), controller.fetch)
.get(
"/api/permission/:resourceId",
authorized(BUILDER),
controller.getResourcePerms
)
// adding a specific role/level for the resource overrides the underlying access control
.post(
"/api/permission/:roleId/:resourceId/:level",
authorized(BUILDER),
generateValidator(),
controller.addPermission
)
// deleting the level defaults it back the underlying access control for the resource
.delete(
"/api/permission/:roleId/:resourceId/:level",
authorized(BUILDER),
generateValidator(),
controller.removePermission
)
module.exports = router

View File

@ -8,6 +8,11 @@ const {
PermissionTypes,
} = require("../../utilities/security/permissions")
const joiValidator = require("../../middleware/joi-validator")
const {
bodyResource,
bodySubResource,
paramResource,
} = require("../../middleware/resourceId")
const router = Router()
@ -43,12 +48,14 @@ router
.get("/api/queries", authorized(BUILDER), queryController.fetch)
.post(
"/api/queries",
bodySubResource("datasourceId", "_id"),
authorized(BUILDER),
generateQueryValidation(),
queryController.save
)
.post(
"/api/queries/preview",
bodyResource("datasourceId"),
authorized(BUILDER),
generateQueryPreviewValidation(),
queryController.preview
@ -60,11 +67,13 @@ router
)
.post(
"/api/queries/:queryId",
paramResource("queryId"),
authorized(PermissionTypes.QUERY, PermissionLevels.WRITE),
queryController.execute
)
.delete(
"/api/queries/:queryId/:revId",
paramResource("queryId"),
authorized(BUILDER),
queryController.destroy
)

View File

@ -1,7 +1,10 @@
const Router = require("@koa/router")
const controller = require("../controllers/role")
const authorized = require("../../middleware/authorized")
const { BUILDER } = require("../../utilities/security/permissions")
const {
BUILDER,
PermissionLevels,
} = require("../../utilities/security/permissions")
const Joi = require("joi")
const joiValidator = require("../../middleware/joi-validator")
const {
@ -11,12 +14,17 @@ const {
const router = Router()
function generateValidator() {
const permLevelArray = Object.values(PermissionLevels)
// prettier-ignore
return joiValidator.body(Joi.object({
_id: Joi.string().optional(),
_rev: Joi.string().optional(),
name: Joi.string().required(),
// this is the base permission ID (for now a built in)
permissionId: Joi.string().valid(...Object.values(BUILTIN_PERMISSION_IDS)).required(),
permissions: Joi.object()
.pattern(/.*/, [Joi.string().valid(...permLevelArray)])
.optional(),
inherits: Joi.string().optional(),
}).unknown(true))
}

View File

@ -2,6 +2,10 @@ const Router = require("@koa/router")
const rowController = require("../controllers/row")
const authorized = require("../../middleware/authorized")
const usage = require("../../middleware/usageQuota")
const {
paramResource,
paramSubResource,
} = require("../../middleware/resourceId")
const {
PermissionLevels,
PermissionTypes,
@ -12,37 +16,50 @@ const router = Router()
router
.get(
"/api/:tableId/:rowId/enrich",
paramSubResource("tableId", "rowId"),
authorized(PermissionTypes.TABLE, PermissionLevels.READ),
rowController.fetchEnrichedRow
)
.get(
"/api/:tableId/rows",
paramResource("tableId"),
authorized(PermissionTypes.TABLE, PermissionLevels.READ),
rowController.fetchTableRows
)
.get(
"/api/:tableId/rows/:rowId",
paramSubResource("tableId", "rowId"),
authorized(PermissionTypes.TABLE, PermissionLevels.READ),
rowController.find
)
.post(
"/api/:tableId/rows",
paramResource("tableId"),
authorized(PermissionTypes.TABLE, PermissionLevels.WRITE),
usage,
rowController.save
)
.post(
"/api/:tableId/rows/search",
paramResource("tableId"),
authorized(PermissionTypes.TABLE, PermissionLevels.READ),
rowController.search
)
.patch(
"/api/:tableId/rows/:id",
"/api/:tableId/rows/:rowId",
paramSubResource("tableId", "rowId"),
authorized(PermissionTypes.TABLE, PermissionLevels.WRITE),
rowController.patch
)
.post(
"/api/:tableId/rows/validate",
paramResource("tableId"),
authorized(PermissionTypes.TABLE, PermissionLevels.WRITE),
rowController.validate
)
.delete(
"/api/:tableId/rows/:rowId/:revId",
paramSubResource("tableId", "rowId"),
authorized(PermissionTypes.TABLE, PermissionLevels.WRITE),
usage,
rowController.destroy

View File

@ -1,6 +1,7 @@
const Router = require("@koa/router")
const tableController = require("../controllers/table")
const authorized = require("../../middleware/authorized")
const { paramResource, bodyResource } = require("../../middleware/resourceId")
const {
BUILDER,
PermissionLevels,
@ -13,10 +14,17 @@ router
.get("/api/tables", authorized(BUILDER), tableController.fetch)
.get(
"/api/tables/:id",
paramResource("id"),
authorized(PermissionTypes.TABLE, PermissionLevels.READ),
tableController.find
)
.post("/api/tables", authorized(BUILDER), tableController.save)
.post(
"/api/tables",
// allows control over updating a table
bodyResource("_id"),
authorized(BUILDER),
tableController.save
)
.post(
"/api/tables/csv/validate",
authorized(BUILDER),
@ -24,6 +32,7 @@ router
)
.delete(
"/api/tables/:tableId/:revId",
paramResource("tableId"),
authorized(BUILDER),
tableController.destroy
)

View File

@ -4,6 +4,9 @@ const { BUILTIN_ROLE_IDS } = require("../../../utilities/security/roles")
const packageJson = require("../../../../package")
const jwt = require("jsonwebtoken")
const env = require("../../../environment")
const {
BUILTIN_PERMISSION_IDS,
} = require("../../../utilities/security/permissions")
const TEST_CLIENT_ID = "test-client-id"
@ -37,6 +40,17 @@ exports.defaultHeaders = appId => {
return headers
}
exports.publicHeaders = appId => {
const headers = {
Accept: "application/json",
}
if (appId) {
headers["x-budibase-app-id"] = appId
}
return headers
}
exports.BASE_TABLE = {
name: "TestTable",
type: "table",
@ -70,6 +84,56 @@ exports.createTable = async (request, appId, table, removeId = true) => {
return res.body
}
exports.makeBasicRow = tableId => {
return {
name: "Test Contact",
description: "original description",
status: "new",
tableId: tableId,
}
}
exports.createRow = async (request, appId, tableId, row = null) => {
row = row || exports.makeBasicRow(tableId)
const res = await request
.post(`/api/${tableId}/rows`)
.send(row)
.set(exports.defaultHeaders(appId))
.expect("Content-Type", /json/)
.expect(200)
return res.body
}
exports.createRole = async (request, appId) => {
const roleBody = {
name: "NewRole",
inherits: BUILTIN_ROLE_IDS.BASIC,
permissionId: BUILTIN_PERMISSION_IDS.READ_ONLY,
}
const res = await request
.post(`/api/roles`)
.send(roleBody)
.set(exports.defaultHeaders(appId))
.expect("Content-Type", /json/)
.expect(200)
return res.body
}
exports.addPermission = async (
request,
appId,
role,
resource,
level = "read"
) => {
const res = await request
.post(`/api/permission/${role}/${resource}/${level}`)
.set(exports.defaultHeaders(appId))
.expect("Content-Type", /json/)
.expect(200)
return res.body
}
exports.createLinkedTable = async (request, appId) => {
// get the ID to link to
const table = await exports.createTable(request, appId)

View File

@ -0,0 +1,125 @@
const {
createApplication,
createTable,
createRow,
supertest,
defaultHeaders,
addPermission,
publicHeaders,
makeBasicRow,
} = require("./couchTestUtils")
const { BUILTIN_ROLE_IDS } = require("../../../utilities/security/roles")
const HIGHER_ROLE_ID = BUILTIN_ROLE_IDS.BASIC
const STD_ROLE_ID = BUILTIN_ROLE_IDS.PUBLIC
describe("/permission", () => {
let server
let request
let appId
let table
let perms
let row
beforeAll(async () => {
;({ request, server } = await supertest())
})
afterAll(() => {
server.close()
})
beforeEach(async () => {
let app = await createApplication(request)
appId = app.instance._id
table = await createTable(request, appId)
perms = await addPermission(request, appId, STD_ROLE_ID, table._id)
row = await createRow(request, appId, table._id)
})
async function getTablePermissions() {
return request
.get(`/api/permission/${table._id}`)
.set(defaultHeaders(appId))
.expect("Content-Type", /json/)
.expect(200)
}
describe("levels", () => {
it("should be able to get levels", async () => {
const res = await request
.get(`/api/permission/levels`)
.set(defaultHeaders(appId))
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toBeDefined()
expect(res.body.length).toEqual(2)
expect(res.body).toContain("read")
expect(res.body).toContain("write")
})
})
describe("add", () => {
it("should be able to add permission to a role for the table", async () => {
expect(perms.length).toEqual(1)
expect(perms[0]._id).toEqual(`${STD_ROLE_ID}`)
})
it("should get the resource permissions", async () => {
const res = await request
.get(`/api/permission/${table._id}`)
.set(defaultHeaders(appId))
.expect("Content-Type", /json/)
.expect(200)
expect(res.body[STD_ROLE_ID]).toEqual("read")
})
it("should get resource permissions with multiple roles", async () => {
perms = await addPermission(request, appId, HIGHER_ROLE_ID, table._id, "write")
const res = await getTablePermissions()
expect(res.body[HIGHER_ROLE_ID]).toEqual("write")
expect(res.body[STD_ROLE_ID]).toEqual("read")
const allRes = await request
.get(`/api/permission`)
.set(defaultHeaders(appId))
.expect("Content-Type", /json/)
.expect(200)
expect(allRes.body[HIGHER_ROLE_ID][table._id]).toEqual("write")
expect(allRes.body[STD_ROLE_ID][table._id]).toEqual("read")
})
})
describe("remove", () => {
it("should be able to remove the permission", async () => {
const res = await request
.delete(`/api/permission/${STD_ROLE_ID}/${table._id}/read`)
.set(defaultHeaders(appId))
.expect("Content-Type", /json/)
.expect(200)
expect(res.body[0]._id).toEqual(STD_ROLE_ID)
const permsRes = await getTablePermissions()
expect(permsRes.body[STD_ROLE_ID]).toBeUndefined()
})
})
describe("check public user allowed", () => {
it("should be able to read the row", async () => {
const res = await request
.get(`/api/${table._id}/rows`)
.set(publicHeaders(appId))
.expect("Content-Type", /json/)
.expect(200)
expect(res.body[0]._id).toEqual(row._id)
})
it("shouldn't allow writing from a public user", async () => {
const res = await request
.post(`/api/${table._id}/rows`)
.send(makeBasicRow(table._id))
.set(publicHeaders(appId))
.expect("Content-Type", /json/)
.expect(403)
expect(res.status).toEqual(403)
})
})
})

View File

@ -1,7 +1,5 @@
const {
createApplication,
createTable,
createView,
supertest,
defaultHeaders,
} = require("./couchTestUtils")
@ -20,8 +18,6 @@ describe("/roles", () => {
let server
let request
let appId
let table
let view
beforeAll(async () => {
;({ request, server } = await supertest())
@ -34,8 +30,6 @@ describe("/roles", () => {
beforeEach(async () => {
let app = await createApplication(request)
appId = app.instance._id
table = await createTable(request, appId)
view = await createView(request, appId, table._id)
})
describe("create", () => {

View File

@ -5,6 +5,7 @@ const {
defaultHeaders,
createLinkedTable,
createAttachmentTable,
makeBasicRow,
} = require("./couchTestUtils");
const { enrichRows } = require("../../../utilities")
const env = require("../../../environment")
@ -30,12 +31,7 @@ describe("/rows", () => {
app = await createApplication(request)
appId = app.instance._id
table = await createTable(request, appId)
row = {
name: "Test Contact",
description: "original description",
status: "new",
tableId: table._id
}
row = makeBasicRow(table._id)
})
const createRow = async r =>

View File

@ -14,7 +14,15 @@ const selfhost = require("./selfhost")
const app = new Koa()
// set up top level koa middleware
app.use(koaBody({ multipart: true }))
app.use(
koaBody({
multipart: true,
formLimit: "10mb",
jsonLimit: "10mb",
textLimit: "10mb",
enableTypes: ["json", "form", "text"],
})
)
app.use(
logger({

View File

@ -2,12 +2,14 @@ const PouchDB = require("pouchdb")
const replicationStream = require("pouchdb-replication-stream")
const allDbs = require("pouchdb-all-dbs")
const { budibaseAppsDir } = require("../utilities/budibaseDir")
const find = require("pouchdb-find")
const env = require("../environment")
const COUCH_DB_URL = env.COUCH_DB_URL || `leveldb://${budibaseAppsDir()}/.data/`
const isInMemory = env.NODE_ENV === "jest"
PouchDB.plugin(replicationStream.plugin)
PouchDB.plugin(find)
PouchDB.adapter("writableStream", replicationStream.adapters.writableStream)
let POUCH_DB_DEFAULTS = {

View File

@ -170,8 +170,8 @@ exports.getAppParams = (appId = null, otherProps = {}) => {
* Generates a new role ID.
* @returns {string} The new role ID which the role doc can be stored under.
*/
exports.generateRoleID = () => {
return `${DocumentTypes.ROLE}${SEPARATOR}${newid()}`
exports.generateRoleID = id => {
return `${DocumentTypes.ROLE}${SEPARATOR}${id || newid()}`
}
/**

View File

@ -1,10 +1,11 @@
const {
BUILTIN_ROLE_IDS,
getUserPermissionIds,
getUserPermissions,
} = require("../utilities/security/roles")
const {
PermissionTypes,
doesHavePermission,
doesHaveResourcePermission,
doesHaveBasePermission,
} = require("../utilities/security/permissions")
const env = require("../environment")
const { isAPIKeyValid } = require("../utilities/security/apikey")
@ -14,6 +15,10 @@ const ADMIN_ROLES = [BUILTIN_ROLE_IDS.ADMIN, BUILTIN_ROLE_IDS.BUILDER]
const LOCAL_PASS = new RegExp(["webhooks/trigger", "webhooks/schema"].join("|"))
function hasResource(ctx) {
return ctx.resourceId != null
}
module.exports = (permType, permLevel = null) => async (ctx, next) => {
// webhooks can pass locally
if (!env.CLOUD && LOCAL_PASS.test(ctx.request.url)) {
@ -38,25 +43,39 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => {
// don't expose builder endpoints in the cloud
if (env.CLOUD && permType === PermissionTypes.BUILDER) return
if (!ctx.auth.authenticated) {
ctx.throw(403, "Session not authenticated")
}
if (!ctx.user) {
ctx.throw(403, "User not found")
ctx.throw(403, "No user info found")
}
const role = ctx.user.role
const permissions = await getUserPermissionIds(ctx.appId, role._id)
if (ADMIN_ROLES.indexOf(role._id) !== -1) {
return next()
}
const { basePermissions, permissions } = await getUserPermissions(
ctx.appId,
role._id
)
const isAdmin = ADMIN_ROLES.indexOf(role._id) !== -1
const isAuthed = ctx.auth.authenticated
if (permType === PermissionTypes.BUILDER) {
// this may need to change in the future, right now only admins
// can have access to builder features, this is hard coded into
// our rules
if (isAdmin && isAuthed) {
return next()
} else if (permType === PermissionTypes.BUILDER) {
ctx.throw(403, "Not Authorized")
}
if (!doesHavePermission(permType, permLevel, permissions)) {
if (
hasResource(ctx) &&
doesHaveResourcePermission(permissions, permLevel, ctx)
) {
return next()
}
if (!isAuthed) {
ctx.throw(403, "Session not authenticated")
}
if (!doesHaveBasePermission(permType, permLevel, basePermissions)) {
ctx.throw(403, "User does not have permission")
}

View File

@ -22,3 +22,7 @@ function validate(schema, property) {
module.exports.body = schema => {
return validate(schema, "body")
}
module.exports.params = schema => {
return validate(schema, "params")
}

View File

@ -0,0 +1,59 @@
class ResourceIdGetter {
constructor(ctxProperty) {
this.parameter = ctxProperty
this.main = null
this.sub = null
return this
}
mainResource(field) {
this.main = field
return this
}
subResource(field) {
this.sub = field
return this
}
build() {
const parameter = this.parameter,
main = this.main,
sub = this.sub
return (ctx, next) => {
const request = ctx.request[parameter] || ctx[parameter]
if (request == null) {
return next()
}
if (main != null && request[main]) {
ctx.resourceId = request[main]
}
if (sub != null && request[sub]) {
ctx.subResourceId = request[sub]
}
return next()
}
}
}
module.exports.paramResource = main => {
return new ResourceIdGetter("params").mainResource(main).build()
}
module.exports.paramSubResource = (main, sub) => {
return new ResourceIdGetter("params")
.mainResource(main)
.subResource(sub)
.build()
}
module.exports.bodyResource = main => {
return new ResourceIdGetter("body").mainResource(main).build()
}
module.exports.bodySubResource = (main, sub) => {
return new ResourceIdGetter("body")
.mainResource(main)
.subResource(sub)
.build()
}

View File

@ -94,17 +94,22 @@ exports.getDeployedApps = async () => {
}
const workerUrl = !env.CLOUD ? await exports.getWorkerUrl() : env.WORKER_URL
const hostingKey = !env.CLOUD ? hostingInfo.selfHostKey : env.HOSTING_KEY
const response = await fetch(`${workerUrl}/api/apps`, {
method: "GET",
headers: {
"x-budibase-auth": hostingKey,
},
})
const json = await response.json()
for (let value of Object.values(json)) {
if (value.url) {
value.url = value.url.toLowerCase()
try {
const response = await fetch(`${workerUrl}/api/apps`, {
method: "GET",
headers: {
"x-budibase-auth": hostingKey,
},
})
const json = await response.json()
for (let value of Object.values(json)) {
if (value.url) {
value.url = value.url.toLowerCase()
}
}
return json
} catch (err) {
// error, cannot determine deployed apps, don't stop app creation - sort this later
return {}
}
return json
}

View File

@ -7,6 +7,7 @@ const PermissionLevels = {
ADMIN: "admin",
}
// these are the global types, that govern the underlying default behaviour
const PermissionTypes = {
TABLE: "table",
USER: "user",
@ -29,12 +30,11 @@ function Permission(type, level) {
*/
function getAllowedLevels(userPermLevel) {
switch (userPermLevel) {
case PermissionLevels.READ:
return [PermissionLevels.READ]
case PermissionLevels.WRITE:
return [PermissionLevels.READ, PermissionLevels.WRITE]
case PermissionLevels.EXECUTE:
return [PermissionLevels.EXECUTE]
case PermissionLevels.READ:
return [PermissionLevels.EXECUTE, PermissionLevels.READ]
case PermissionLevels.WRITE:
case PermissionLevels.ADMIN:
return [
PermissionLevels.READ,
@ -97,7 +97,35 @@ exports.BUILTIN_PERMISSIONS = {
},
}
exports.doesHavePermission = (permType, permLevel, permissionIds) => {
exports.doesHaveResourcePermission = (
permissions,
permLevel,
{ resourceId, subResourceId }
) => {
// set foundSub to not subResourceId, incase there is no subResource
let foundMain = false,
foundSub = !subResourceId
for (let [resource, level] of Object.entries(permissions)) {
const levels = getAllowedLevels(level)
if (resource === resourceId && levels.indexOf(permLevel) !== -1) {
foundMain = true
}
if (
subResourceId &&
resource === subResourceId &&
levels.indexOf(permLevel) !== -1
) {
foundSub = true
}
// this will escape if foundMain only when no sub resource
if (foundMain && foundSub) {
break
}
}
return foundMain && foundSub
}
exports.doesHaveBasePermission = (permType, permLevel, permissionIds) => {
const builtins = Object.values(exports.BUILTIN_PERMISSIONS)
let permissions = flatten(
builtins
@ -115,6 +143,25 @@ exports.doesHavePermission = (permType, permLevel, permissionIds) => {
return false
}
exports.higherPermission = (perm1, perm2) => {
function toNum(perm) {
switch (perm) {
// not everything has execute privileges
case PermissionLevels.EXECUTE:
return 0
case PermissionLevels.READ:
return 1
case PermissionLevels.WRITE:
return 2
case PermissionLevels.ADMIN:
return 3
default:
return -1
}
}
return toNum(perm1) > toNum(perm2) ? perm1 : perm2
}
// utility as a lot of things need simply the builder permission
exports.BUILDER = PermissionTypes.BUILDER
exports.PermissionTypes = PermissionTypes

View File

@ -1,6 +1,7 @@
const CouchDB = require("../../db")
const { cloneDeep } = require("lodash/fp")
const { BUILTIN_PERMISSION_IDS } = require("./permissions")
const { BUILTIN_PERMISSION_IDS, higherPermission } = require("./permissions")
const { generateRoleID, DocumentTypes, SEPARATOR } = require("../../db/utils")
const BUILTIN_IDS = {
ADMIN: "ADMIN",
@ -44,15 +45,15 @@ exports.BUILTIN_ROLES = {
}
exports.BUILTIN_ROLE_ID_ARRAY = Object.values(exports.BUILTIN_ROLES).map(
level => level._id
role => role._id
)
exports.BUILTIN_ROLE_NAME_ARRAY = Object.values(exports.BUILTIN_ROLES).map(
level => level.name
role => role.name
)
function isBuiltin(role) {
return exports.BUILTIN_ROLE_ID_ARRAY.indexOf(role) !== -1
return exports.BUILTIN_ROLE_ID_ARRAY.some(builtin => role.includes(builtin))
}
/**
@ -66,14 +67,25 @@ exports.getRole = async (appId, roleId) => {
if (!roleId) {
return null
}
let role
let role = {}
// built in roles mostly come from the in-code implementation,
// but can be extended by a doc stored about them (e.g. permissions)
if (isBuiltin(roleId)) {
role = cloneDeep(
Object.values(exports.BUILTIN_ROLES).find(role => role._id === roleId)
)
} else {
}
try {
const db = new CouchDB(appId)
role = await db.get(roleId)
const dbRole = await db.get(exports.getDBRoleID(roleId))
role = Object.assign(role, dbRole)
// finalise the ID
role._id = exports.getExternalRoleID(role._id)
} catch (err) {
// only throw an error if there is no role at all
if (Object.keys(role).length === 0) {
throw err
}
}
return role
}
@ -118,14 +130,26 @@ exports.getUserRoleHierarchy = async (appId, userRoleId) => {
* Get all of the user permissions which could be found across the role hierarchy
* @param appId The ID of the application from which roles should be obtained.
* @param userRoleId The user's role ID, this can be found in their access token.
* @returns {Promise<string[]>} A list of permission IDs these should all be unique.
* @returns {Promise<{basePermissions: string[], permissions: Object}>} the base
* permission IDs as well as any custom resource permissions.
*/
exports.getUserPermissionIds = async (appId, userRoleId) => {
return [
...new Set(
(await getAllUserRoles(appId, userRoleId)).map(role => role.permissionId)
),
exports.getUserPermissions = async (appId, userRoleId) => {
const rolesHierarchy = await getAllUserRoles(appId, userRoleId)
const basePermissions = [
...new Set(rolesHierarchy.map(role => role.permissionId)),
]
const permissions = {}
for (let role of rolesHierarchy) {
if (role.permissions) {
for (let [resource, level] of Object.entries(role.permissions)) {
permissions[resource] = higherPermission(permissions[resource], level)
}
}
}
return {
basePermissions,
permissions,
}
}
class AccessController {
@ -177,6 +201,27 @@ class AccessController {
}
}
/**
* Adds the "role_" for builtin role IDs which are to be written to the DB (for permissions).
*/
exports.getDBRoleID = roleId => {
if (roleId.startsWith(DocumentTypes.ROLE)) {
return roleId
}
return generateRoleID(roleId)
}
/**
* Remove the "role_" from builtin role IDs that have been written to the DB (for permissions).
*/
exports.getExternalRoleID = roleId => {
// for built in roles we want to remove the DB role ID element (role_)
if (roleId.startsWith(DocumentTypes.ROLE) && isBuiltin(roleId)) {
return roleId.split(`${DocumentTypes.ROLE}${SEPARATOR}`)[1]
}
return roleId
}
exports.AccessController = AccessController
exports.BUILTIN_ROLE_IDS = BUILTIN_IDS
exports.isBuiltin = isBuiltin

View File

@ -3219,6 +3219,13 @@ fd-slicer@~1.1.0:
dependencies:
pend "~1.2.0"
fetch-cookie@0.10.1:
version "0.10.1"
resolved "https://registry.yarnpkg.com/fetch-cookie/-/fetch-cookie-0.10.1.tgz#5ea88f3d36950543c87997c27ae2aeafb4b5c4d4"
integrity sha512-beB+VEd4cNeVG1PY+ee74+PkuCQnik78pgLi5Ah/7qdUfov8IctU0vLUbBT8/10Ma5GMBeI4wtxhGrEfKNYs2g==
dependencies:
tough-cookie "^2.3.3 || ^3.0.1 || ^4.0.0"
fetch-cookie@0.7.3:
version "0.7.3"
resolved "https://registry.yarnpkg.com/fetch-cookie/-/fetch-cookie-0.7.3.tgz#b8d023f421dd2b2f4a0eca9cd7318a967ed4eed8"
@ -6498,6 +6505,20 @@ pouch-stream@^0.4.0:
inherits "^2.0.1"
readable-stream "^1.0.27-1"
pouchdb-abstract-mapreduce@7.2.2:
version "7.2.2"
resolved "https://registry.yarnpkg.com/pouchdb-abstract-mapreduce/-/pouchdb-abstract-mapreduce-7.2.2.tgz#dd1b10a83f8d24361dce9aaaab054614b39f766f"
integrity sha512-7HWN/2yV2JkwMnGnlp84lGvFtnm0Q55NiBUdbBcaT810+clCGKvhssBCrXnmwShD1SXTwT83aszsgiSfW+SnBA==
dependencies:
pouchdb-binary-utils "7.2.2"
pouchdb-collate "7.2.2"
pouchdb-collections "7.2.2"
pouchdb-errors "7.2.2"
pouchdb-fetch "7.2.2"
pouchdb-mapreduce-utils "7.2.2"
pouchdb-md5 "7.2.2"
pouchdb-utils "7.2.2"
pouchdb-adapter-leveldb-core@7.2.2:
version "7.2.2"
resolved "https://registry.yarnpkg.com/pouchdb-adapter-leveldb-core/-/pouchdb-adapter-leveldb-core-7.2.2.tgz#e0aa6a476e2607d7ae89f4a803c9fba6e6d05a8a"
@ -6557,6 +6578,11 @@ pouchdb-binary-utils@7.2.2:
dependencies:
buffer-from "1.1.1"
pouchdb-collate@7.2.2:
version "7.2.2"
resolved "https://registry.yarnpkg.com/pouchdb-collate/-/pouchdb-collate-7.2.2.tgz#fc261f5ef837c437e3445fb0abc3f125d982c37c"
integrity sha512-/SMY9GGasslknivWlCVwXMRMnQ8myKHs4WryQ5535nq1Wj/ehpqWloMwxEQGvZE1Sda3LOm7/5HwLTcB8Our+w==
pouchdb-collections@7.2.2:
version "7.2.2"
resolved "https://registry.yarnpkg.com/pouchdb-collections/-/pouchdb-collections-7.2.2.tgz#aeed77f33322429e3f59d59ea233b48ff0e68572"
@ -6569,6 +6595,28 @@ pouchdb-errors@7.2.2:
dependencies:
inherits "2.0.4"
pouchdb-fetch@7.2.2:
version "7.2.2"
resolved "https://registry.yarnpkg.com/pouchdb-fetch/-/pouchdb-fetch-7.2.2.tgz#492791236d60c899d7e9973f9aca0d7b9cc02230"
integrity sha512-lUHmaG6U3zjdMkh8Vob9GvEiRGwJfXKE02aZfjiVQgew+9SLkuOxNw3y2q4d1B6mBd273y1k2Lm0IAziRNxQnA==
dependencies:
abort-controller "3.0.0"
fetch-cookie "0.10.1"
node-fetch "2.6.0"
pouchdb-find@^7.2.2:
version "7.2.2"
resolved "https://registry.yarnpkg.com/pouchdb-find/-/pouchdb-find-7.2.2.tgz#1227afdd761812d508fe0794b3e904518a721089"
integrity sha512-BmFeFVQ0kHmDehvJxNZl9OmIztCjPlZlVSdpijuFbk/Fi1EFPU1BAv3kLC+6DhZuOqU/BCoaUBY9sn66pPY2ag==
dependencies:
pouchdb-abstract-mapreduce "7.2.2"
pouchdb-collate "7.2.2"
pouchdb-errors "7.2.2"
pouchdb-fetch "7.2.2"
pouchdb-md5 "7.2.2"
pouchdb-selector-core "7.2.2"
pouchdb-utils "7.2.2"
pouchdb-json@7.2.2:
version "7.2.2"
resolved "https://registry.yarnpkg.com/pouchdb-json/-/pouchdb-json-7.2.2.tgz#b939be24b91a7322e9a24b8880a6e21514ec5e1f"
@ -6576,6 +6624,16 @@ pouchdb-json@7.2.2:
dependencies:
vuvuzela "1.0.3"
pouchdb-mapreduce-utils@7.2.2:
version "7.2.2"
resolved "https://registry.yarnpkg.com/pouchdb-mapreduce-utils/-/pouchdb-mapreduce-utils-7.2.2.tgz#13a46a3cc2a3f3b8e24861da26966904f2963146"
integrity sha512-rAllb73hIkU8rU2LJNbzlcj91KuulpwQu804/F6xF3fhZKC/4JQMClahk+N/+VATkpmLxp1zWmvmgdlwVU4HtQ==
dependencies:
argsarray "0.0.1"
inherits "2.0.4"
pouchdb-collections "7.2.2"
pouchdb-utils "7.2.2"
pouchdb-md5@7.2.2:
version "7.2.2"
resolved "https://registry.yarnpkg.com/pouchdb-md5/-/pouchdb-md5-7.2.2.tgz#415401acc5a844112d765bd1fb4e5d9f38fb0838"
@ -6616,6 +6674,14 @@ pouchdb-replication-stream@1.2.9:
pouchdb-promise "^6.0.4"
through2 "^2.0.0"
pouchdb-selector-core@7.2.2:
version "7.2.2"
resolved "https://registry.yarnpkg.com/pouchdb-selector-core/-/pouchdb-selector-core-7.2.2.tgz#264d7436a8c8ac3801f39960e79875ef7f3879a0"
integrity sha512-XYKCNv9oiNmSXV5+CgR9pkEkTFqxQGWplnVhO3W9P154H08lU0ZoNH02+uf+NjZ2kjse7Q1fxV4r401LEcGMMg==
dependencies:
pouchdb-collate "7.2.2"
pouchdb-utils "7.2.2"
pouchdb-utils@7.2.2:
version "7.2.2"
resolved "https://registry.yarnpkg.com/pouchdb-utils/-/pouchdb-utils-7.2.2.tgz#c17c4788f1d052b0daf4ef8797bbc4aaa3945aa4"
@ -6724,7 +6790,7 @@ pseudomap@^1.0.2:
resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
psl@^1.1.28:
psl@^1.1.28, psl@^1.1.33:
version "1.8.0"
resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24"
integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==
@ -8090,6 +8156,15 @@ tough-cookie@^2.3.3, tough-cookie@^2.3.4, tough-cookie@^2.4.3, tough-cookie@~2.5
psl "^1.1.28"
punycode "^2.1.1"
"tough-cookie@^2.3.3 || ^3.0.1 || ^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4"
integrity sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==
dependencies:
psl "^1.1.33"
punycode "^2.1.1"
universalify "^0.1.2"
tr46@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
@ -8217,7 +8292,7 @@ unique-string@^2.0.0:
dependencies:
crypto-random-string "^2.0.0"
universalify@^0.1.0:
universalify@^0.1.0, universalify@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==

View File

@ -1,6 +1,8 @@
Mozilla Public License Version 2.0
==================================
Copyright 2019-2021, Budibase Ltd
1. Definitions
--------------

View File

@ -112,6 +112,45 @@
"type": "datasource",
"label": "Data",
"key": "datasource"
},
{
"type": "text",
"label": "Empty Text",
"key": "noRowsMessage",
"defaultValue": "No rows found."
}
]
},
"search": {
"name": "Search",
"description": "A searchable list of items.",
"icon": "ri-search-line",
"styleable": true,
"hasChildren": true,
"dataProvider": true,
"settings": [
{
"type": "table",
"label": "Table",
"key": "table"
},
{
"type": "multifield",
"label": "Columns",
"key": "columns",
"dependsOn": "table"
},
{
"type": "number",
"label": "Rows/Page",
"defaultValue": 25,
"key": "pageSize"
},
{
"type": "text",
"label": "Empty Text",
"key": "noRowsMessage",
"defaultValue": "No rows found."
}
]
},

View File

@ -40,7 +40,7 @@
"gitHead": "1a80b09fd093f2599a68f7db72ad639dd50922dd",
"dependencies": {
"@adobe/spectrum-css-workflow-icons": "^1.1.0",
"@budibase/bbui": "^1.58.4",
"@budibase/bbui": "^1.58.5",
"@budibase/svelte-ag-grid": "^0.0.16",
"@spectrum-css/actionbutton": "^1.0.0-beta.1",
"@spectrum-css/button": "^3.0.0-beta.6",

View File

@ -1,7 +1,7 @@
<script>
import { getContext } from "svelte"
const { styleable } = getContext("sdk")
const { styleable, linkable } = getContext("sdk")
const component = getContext("component")
export const className = ""
@ -38,8 +38,11 @@
<h2 class="heading">{heading}</h2>
<h4 class="text">{description}</h4>
<a
use:linkable
style="--linkColor: {linkColor}; --linkHoverColor: {linkHoverColor}"
href={linkUrl}>{linkText}</a>
href={linkUrl}>
{linkText}
</a>
</div>
</div>

View File

@ -2,7 +2,8 @@
import { getContext } from "svelte"
import { isEmpty } from "lodash/fp"
export let datasource = []
export let datasource
export let noRowsMessage
const { API, styleable, Provider, builderStore, ActionTypes } = getContext(
"sdk"
@ -29,10 +30,10 @@
</script>
<Provider {actions}>
{#if rows.length > 0}
<div use:styleable={$component.styles}>
<div use:styleable={$component.styles}>
{#if rows.length > 0}
{#if $component.children === 0 && $builderStore.inBuilder}
<p>Add some components too</p>
<p><i class="ri-image-line" />Add some components to display.</p>
{:else}
{#each rows as row}
<Provider data={row}>
@ -40,20 +41,26 @@
</Provider>
{/each}
{/if}
</div>
{:else if loaded && $builderStore.inBuilder}
<div use:styleable={$component.styles}>
<p>Feed me some data</p>
</div>
{/if}
{:else if loaded && noRowsMessage}
<p><i class="ri-list-check-2" />{noRowsMessage}</p>
{/if}
</div>
</Provider>
<style>
p {
margin: 0 var(--spacing-m);
background-color: var(--grey-2);
color: var(--grey-6);
font-size: var(--font-size-s);
padding: var(--spacing-l);
border-radius: var(--border-radius-s);
display: grid;
place-items: center;
background: #f5f5f5;
border: #ccc 1px solid;
padding: var(--spacing-m);
}
p i {
margin-bottom: var(--spacing-m);
font-size: 1.5rem;
color: var(--grey-5);
}
</style>

View File

@ -30,8 +30,15 @@
await authStore.actions.logIn({ email, password })
loading = false
}
function handleKeydown(evt) {
if (evt.key === "Enter") {
login()
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
<div class="root" use:styleable={$component.styles}>
<div class="content">
{#if logo}

View File

@ -0,0 +1,182 @@
<script>
import { getContext } from "svelte"
import {
Button,
DatePicker,
Label,
Select,
Toggle,
Input,
} from "@budibase/bbui"
const { API, styleable, Provider, builderStore, ActionTypes } = getContext(
"sdk"
)
const component = getContext("component")
export let table
export let columns = []
export let pageSize
export let noRowsMessage
let rows = []
let loaded = false
let search = {}
let tableDefinition
let schema
// pagination
let page = 0
$: fetchData(table, page)
// omit empty strings
$: parsedSearch = Object.keys(search).reduce(
(acc, next) =>
search[next] === "" ? acc : { ...acc, [next]: search[next] },
{}
)
$: actions = [
{
type: ActionTypes.RefreshDatasource,
callback: () => fetchData(table, page),
metadata: { datasource: { type: "table", tableId: table } },
},
]
async function fetchData(table, page) {
if (table) {
const tableDef = await API.fetchTableDefinition(table)
schema = tableDef.schema
rows = await API.searchTableData({
tableId: table,
search: parsedSearch,
pagination: {
pageSize,
page,
},
})
}
loaded = true
}
function nextPage() {
page += 1
}
function previousPage() {
page -= 1
}
</script>
<Provider {actions}>
<div use:styleable={$component.styles}>
<div class="query-builder">
{#if schema}
{#each columns as field}
<div class="form-field">
<Label extraSmall grey>{schema[field].name}</Label>
{#if schema[field].type === 'options'}
<Select secondary bind:value={search[field]}>
<option value="">Choose an option</option>
{#each schema[field].constraints.inclusion as opt}
<option>{opt}</option>
{/each}
</Select>
{:else if schema[field].type === 'datetime'}
<DatePicker bind:value={search[field]} />
{:else if schema[field].type === 'boolean'}
<Toggle text={schema[field].name} bind:checked={search[field]} />
{:else if schema[field].type === 'number'}
<Input type="number" bind:value={search[field]} />
{:else if schema[field].type === 'string'}
<Input bind:value={search[field]} />
{/if}
</div>
{/each}
{/if}
<div class="actions">
<Button
secondary
on:click={() => {
search = {}
page = 0
}}>
Reset
</Button>
<Button
primary
on:click={() => {
page = 0
fetchData(table, page)
}}>
Search
</Button>
</div>
</div>
{#if loaded}
{#if rows.length > 0}
{#if $component.children === 0 && $builderStore.inBuilder}
<p><i class="ri-image-line" />Add some components to display.</p>
{:else}
{#each rows as row}
<Provider data={row}>
<slot />
</Provider>
{/each}
{/if}
{:else if noRowsMessage}
<p><i class="ri-search-2-line" />{noRowsMessage}</p>
{/if}
{/if}
<div class="pagination">
{#if page > 0}
<Button primary on:click={previousPage}>Back</Button>
{/if}
{#if rows.length === pageSize}
<Button primary on:click={nextPage}>Next</Button>
{/if}
</div>
</div>
</Provider>
<style>
p {
margin: 0 var(--spacing-m);
background-color: var(--grey-2);
color: var(--grey-6);
font-size: var(--font-size-s);
padding: var(--spacing-l);
border-radius: var(--border-radius-s);
display: grid;
place-items: center;
}
p i {
margin-bottom: var(--spacing-m);
font-size: 1.5rem;
color: var(--grey-5);
}
.query-builder {
padding: var(--spacing-m);
border-radius: var(--border-radius-s);
}
.actions {
display: grid;
grid-gap: var(--spacing-s);
justify-content: flex-end;
grid-auto-flow: column;
}
.form-field {
margin-bottom: var(--spacing-m);
}
.pagination {
display: grid;
grid-gap: var(--spacing-s);
justify-content: flex-end;
margin-top: var(--spacing-m);
grid-auto-flow: column;
}
</style>

View File

@ -49,3 +49,9 @@
</div>
{/if}
</Field>
<style>
.spectrum-Checkbox {
width: 100%;
}
</style>

View File

@ -3,6 +3,7 @@
import Field from "./Field.svelte"
import "flatpickr/dist/flatpickr.css"
import "@spectrum-css/inputgroup/dist/index-vars.css"
import { generateID } from "../helpers"
export let field
export let label
@ -14,8 +15,9 @@
let open = false
let flatpickr
$: flatpickrId = `${$fieldState?.id}-${generateID()}-wrapper`
$: flatpickrOptions = {
element: `#${$fieldState?.id}-wrapper`,
element: `#${flatpickrId}`,
enableTime: enableTime || false,
altInput: true,
altFormat: enableTime ? "F j Y, H:i" : "F j, Y",
@ -46,9 +48,7 @@
// duplicate input field.
// We need to blur both because the focus styling does not get properly
// applied.
const els = document.querySelectorAll(
`#${$fieldState.fieldId}-wrapper input`
)
const els = document.querySelectorAll(`#${flatpickrId} input`)
els.forEach(el => el.blur())
}
</script>
@ -62,9 +62,9 @@
on:close={onClose}
options={flatpickrOptions}
on:change={handleChange}
element={`#${$fieldState.fieldId}-wrapper`}>
element={`#${flatpickrId}`}>
<div
id={`${$fieldState.fieldId}-wrapper`}
id={flatpickrId}
aria-disabled="false"
aria-invalid={!$fieldState.valid}
class:is-invalid={!$fieldState.valid}
@ -124,14 +124,11 @@
cursor: pointer;
}
.flatpickr {
width: var(
--spectrum-alias-single-line-width,
var(--spectrum-global-dimension-size-2400)
);
width: 100%;
overflow: hidden;
}
.flatpickr .spectrum-Textfield {
width: auto;
width: 100%;
}
.overlay {
position: fixed;

View File

@ -61,8 +61,13 @@
</FieldGroupFallback>
<style>
label {
white-space: nowrap;
}
.spectrum-Form-itemField {
width: 360px;
position: relative;
width: 100%;
}
.error {
@ -73,4 +78,9 @@
font-size: var(--spectrum-global-dimension-font-size-75);
margin-top: var(--spectrum-global-dimension-size-75);
}
.spectrum-FieldLabel--right,
.spectrum-FieldLabel--left {
padding-right: var(--spectrum-global-dimension-size-200);
}
</style>

View File

@ -8,10 +8,20 @@
setContext("fieldGroup", { labelPosition })
</script>
<div use:styleable={$component.styles}>
<div class="wrapper" use:styleable={$component.styles}>
<div
class="spectrum-Form"
class:spectrum-Form--labelsAbove={labelPosition === 'above'}>
<slot />
</div>
</div>
<style>
.wrapper {
width: 100%;
position: relative;
}
.spectrum-Form {
width: 100%;
}
</style>

View File

@ -168,5 +168,6 @@
<style>
div {
padding: 20px;
position: relative;
}
</style>

View File

@ -65,4 +65,7 @@
div :global(.ql-snow .ql-formats:after) {
display: none;
}
div :global(.ql-editor p) {
word-break: break-all;
}
</style>

View File

@ -100,7 +100,10 @@
}
.spectrum-Popover {
max-height: 240px;
width: var(--spectrum-global-dimension-size-2400);
width: 100%;
z-index: 999;
}
.spectrum-Picker {
width: 100%;
}
</style>

View File

@ -58,3 +58,9 @@
</div>
{/if}
</Field>
<style>
.spectrum-Textfield {
width: 100%;
}
</style>

View File

@ -30,5 +30,6 @@ export { default as embed } from "./Embed.svelte"
export { default as cardhorizontal } from "./CardHorizontal.svelte"
export { default as cardstat } from "./CardStat.svelte"
export { default as icon } from "./Icon.svelte"
export { default as search } from "./Search.svelte"
export * from "./charts"
export * from "./forms"

View File

@ -44,10 +44,10 @@
lodash "^4.17.19"
to-fast-properties "^2.0.0"
"@budibase/bbui@^1.58.4":
version "1.58.4"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.58.4.tgz#a74d66b3dd715b0a9861a0f86bc0b863fd8f1d44"
integrity sha512-1oEVt7zMREM594CAUIXqOtiuP4Sx4FbfgPBHTZ+t4RhFfbFqvU7yyakqPZM2LhTAmO5Rfa+c+dfFLh+y1++KaA==
"@budibase/bbui@^1.58.5":
version "1.58.5"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.58.5.tgz#c9ce712941760825c7774a1de77594e989db4561"
integrity sha512-0j1I7BetJ2GzB1BXKyvvlkuFphLmADJh2U/Ihubwxx5qUDY8REoVzLgAB4c24zt0CGVTF9VMmOoMLd0zD0QwdQ==
dependencies:
markdown-it "^12.0.2"
quill "^1.3.7"