merge develop

This commit is contained in:
Keviin Åberg Kultalahti 2021-02-12 14:24:38 +01:00
commit 7cb263ceff
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: Each Budibase package has its own license:
builder: AGPLv3 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 GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007 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 Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed. of this license document, but changing it is not allowed.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,12 @@
<script> <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 { cloneDeep } from "lodash/fp"
import { backendUiStore } from "builderStore" import { backendUiStore } from "builderStore"
import { TableNames, UNEDITABLE_USER_FIELDS } from "constants" import { TableNames, UNEDITABLE_USER_FIELDS } from "constants"
@ -24,7 +31,9 @@
let primaryDisplay = let primaryDisplay =
$backendUiStore.selectedTable.primaryDisplay == null || $backendUiStore.selectedTable.primaryDisplay == null ||
$backendUiStore.selectedTable.primaryDisplay === field.name $backendUiStore.selectedTable.primaryDisplay === field.name
let oneToMany = false; let oneToMany = false;
let indexes = [...($backendUiStore.selectedTable.indexes || [])]
let confirmDeleteDialog let confirmDeleteDialog
let deletion let deletion
@ -42,7 +51,11 @@
originalName, originalName,
field, field,
primaryDisplay, primaryDisplay,
<<<<<<< HEAD
oneToMany, oneToMany,
=======
indexes,
>>>>>>> develop
}) })
return state 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() { function confirmDelete() {
confirmDeleteDialog.show() confirmDeleteDialog.show()
deletion = true deletion = true
@ -122,6 +147,20 @@
on:change={onChangePrimaryDisplay} on:change={onChangePrimaryDisplay}
thin thin
text="Use as table display column" /> 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}
{#if field.type === 'string'} {#if field.type === 'string'}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,3 +16,19 @@ export const fetchTableData = async tableId => {
const rows = await API.get({ url: `/api/${tableId}/rows` }) const rows = await API.get({ url: `/api/${tableId}/rows` })
return await enrichRows(rows, tableId) 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 // Clone and create new data context for this component tree
const context = getContext("context") const context = getContext("context")
const component = getContext("component") const component = getContext("component")
const newContext = createContextStore() const newContext = createContextStore(context)
setContext("context", newContext) setContext("context", newContext)
let initiated = false
$: providerKey = key || $component.id $: providerKey = key || $component.id
// Add data context // Add data context
$: { $: newContext.actions.provideData(providerKey, data)
newContext.actions.provideData(providerKey, $context, data)
initiated = true
}
// Instance ID is unique to each instance of a provider // Instance ID is unique to each instance of a provider
let instanceId let instanceId
@ -56,6 +52,4 @@
}) })
</script> </script>
{#if initiated} <slot />
<slot />
{/if}

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
GNU GENERAL PUBLIC LICENSE GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007 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 Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed. of this license document, but changing it is not allowed.

View File

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

View File

@ -14,6 +14,7 @@ exports.fetchInfo = async ctx => {
} }
exports.save = async ctx => { exports.save = async ctx => {
console.trace("DID A SAVE!")
const db = new CouchDB(BUILDER_CONFIG_DB) const db = new CouchDB(BUILDER_CONFIG_DB)
const { type } = ctx.request.body const { type } = ctx.request.body
if (type === HostingTypes.CLOUD && ctx.request.body._rev) { 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) { const PermissionUpdateType = {
// TODO: need to build out custom permissions 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) 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 CouchDB = require("../../db")
const { const {
BUILTIN_ROLES, BUILTIN_ROLES,
BUILTIN_ROLE_IDS,
Role, Role,
getRole, getRole,
isBuiltin,
getExternalRoleID,
} = require("../../utilities/security/roles") } = require("../../utilities/security/roles")
const { const {
generateRoleID, generateRoleID,
@ -16,6 +19,14 @@ const UpdateRolesOptions = {
REMOVED: "removed", 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) { async function updateRolesOnUserTable(db, roleId, updateOption) {
const table = await db.get(ViewNames.USERS) const table = await db.get(ViewNames.USERS)
const schema = table.schema const schema = table.schema
@ -46,16 +57,22 @@ exports.fetch = async function(ctx) {
include_docs: true, include_docs: true,
}) })
) )
const customRoles = body.rows.map(row => row.doc) const roles = body.rows.map(row => row.doc)
// exclude internal roles like builder // need to combine builtin with any DB record of them (for sake of permissions)
const staticRoles = [ for (let builtinRoleId of EXTERNAL_BUILTIN_ROLE_IDS) {
BUILTIN_ROLES.ADMIN, const builtinRole = BUILTIN_ROLES[builtinRoleId]
BUILTIN_ROLES.POWER, const dbBuiltin = roles.filter(
BUILTIN_ROLES.BASIC, dbRole => getExternalRoleID(dbRole._id) === builtinRoleId
BUILTIN_ROLES.PUBLIC, )[0]
] if (dbBuiltin == null) {
ctx.body = [...staticRoles, ...customRoles] roles.push(builtinRole)
} else {
dbBuiltin._id = getExternalRoleID(dbBuiltin._id)
roles.push(Object.assign(builtinRole, dbBuiltin))
}
}
ctx.body = roles
} }
exports.find = async function(ctx) { exports.find = async function(ctx) {
@ -67,6 +84,8 @@ exports.save = async function(ctx) {
let { _id, name, inherits, permissionId } = ctx.request.body let { _id, name, inherits, permissionId } = ctx.request.body
if (!_id) { if (!_id) {
_id = generateRoleID() _id = generateRoleID()
} else if (isBuiltin(_id)) {
ctx.throw(400, "Cannot update builtin roles.")
} }
const role = new Role(_id, name) const role = new Role(_id, name)
.addPermission(permissionId) .addPermission(permissionId)
@ -84,6 +103,9 @@ exports.save = async function(ctx) {
exports.destroy = async function(ctx) { exports.destroy = async function(ctx) {
const db = new CouchDB(ctx.user.appId) const db = new CouchDB(ctx.user.appId)
const roleId = ctx.params.roleId const roleId = ctx.params.roleId
if (isBuiltin(roleId)) {
ctx.throw(400, "Cannot delete builtin role.")
}
// first check no users actively attached to role // first check no users actively attached to role
const users = ( const users = (
await db.allDocs( await db.allDocs(
@ -94,7 +116,7 @@ exports.destroy = async function(ctx) {
).rows.map(row => row.doc) ).rows.map(row => row.doc)
const usersWithRole = users.filter(user => user.roleId === roleId) const usersWithRole = users.filter(user => user.roleId === roleId)
if (usersWithRole.length !== 0) { 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) 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) { exports.patch = async function(ctx) {
const appId = ctx.user.appId const appId = ctx.user.appId
const db = new CouchDB(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 table = await db.get(row.tableId)
const patchfields = ctx.request.body 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 // if the row obj had an _id then it will have been retrieved
const existingRow = ctx.preExisting const existingRow = ctx.preExisting
if (existingRow) { if (existingRow) {
ctx.params.id = row._id ctx.params.rowId = row._id
await exports.patch(ctx) await exports.patch(ctx)
return 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) { exports.fetchTableRows = async function(ctx) {
const appId = ctx.user.appId const appId = ctx.user.appId
const db = new CouchDB(appId) const db = new CouchDB(appId)

View File

@ -7,6 +7,7 @@ const {
generateTableID, generateTableID,
generateRowID, generateRowID,
} = require("../../db/utils") } = require("../../db/utils")
const { isEqual } = require("lodash/fp")
async function checkForColumnUpdates(db, oldTable, updatedTable) { async function checkForColumnUpdates(db, oldTable, updatedTable) {
let updatedRows let updatedRows
@ -128,6 +129,46 @@ exports.save = async function(ctx) {
const result = await db.post(tableToSave) const result = await db.post(tableToSave)
tableToSave._rev = result.rev 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 &&
ctx.eventEmitter.emitTable(`table:save`, appId, tableToSave) 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 // don't remove the table itself until very end
await db.remove(tableToDelete) 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 &&
ctx.eventEmitter.emitTable(`table:delete`, appId, tableToDelete) ctx.eventEmitter.emitTable(`table:delete`, appId, tableToDelete)
ctx.status = 200 ctx.status = 200

View File

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

View File

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

View File

@ -1,10 +1,47 @@
const Router = require("@koa/router") const Router = require("@koa/router")
const controller = require("../controllers/permission") const controller = require("../controllers/permission")
const authorized = require("../../middleware/authorized") 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() 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 module.exports = router

View File

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

View File

@ -1,7 +1,10 @@
const Router = require("@koa/router") const Router = require("@koa/router")
const controller = require("../controllers/role") const controller = require("../controllers/role")
const authorized = require("../../middleware/authorized") const authorized = require("../../middleware/authorized")
const { BUILDER } = require("../../utilities/security/permissions") const {
BUILDER,
PermissionLevels,
} = require("../../utilities/security/permissions")
const Joi = require("joi") const Joi = require("joi")
const joiValidator = require("../../middleware/joi-validator") const joiValidator = require("../../middleware/joi-validator")
const { const {
@ -11,12 +14,17 @@ const {
const router = Router() const router = Router()
function generateValidator() { function generateValidator() {
const permLevelArray = Object.values(PermissionLevels)
// prettier-ignore // prettier-ignore
return joiValidator.body(Joi.object({ return joiValidator.body(Joi.object({
_id: Joi.string().optional(), _id: Joi.string().optional(),
_rev: Joi.string().optional(), _rev: Joi.string().optional(),
name: Joi.string().required(), 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(), permissionId: Joi.string().valid(...Object.values(BUILTIN_PERMISSION_IDS)).required(),
permissions: Joi.object()
.pattern(/.*/, [Joi.string().valid(...permLevelArray)])
.optional(),
inherits: Joi.string().optional(), inherits: Joi.string().optional(),
}).unknown(true)) }).unknown(true))
} }

View File

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

View File

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

View File

@ -4,6 +4,9 @@ const { BUILTIN_ROLE_IDS } = require("../../../utilities/security/roles")
const packageJson = require("../../../../package") const packageJson = require("../../../../package")
const jwt = require("jsonwebtoken") const jwt = require("jsonwebtoken")
const env = require("../../../environment") const env = require("../../../environment")
const {
BUILTIN_PERMISSION_IDS,
} = require("../../../utilities/security/permissions")
const TEST_CLIENT_ID = "test-client-id" const TEST_CLIENT_ID = "test-client-id"
@ -37,6 +40,17 @@ exports.defaultHeaders = appId => {
return headers return headers
} }
exports.publicHeaders = appId => {
const headers = {
Accept: "application/json",
}
if (appId) {
headers["x-budibase-app-id"] = appId
}
return headers
}
exports.BASE_TABLE = { exports.BASE_TABLE = {
name: "TestTable", name: "TestTable",
type: "table", type: "table",
@ -70,6 +84,56 @@ exports.createTable = async (request, appId, table, removeId = true) => {
return res.body 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) => { exports.createLinkedTable = async (request, appId) => {
// get the ID to link to // get the ID to link to
const table = await exports.createTable(request, appId) 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 { const {
createApplication, createApplication,
createTable,
createView,
supertest, supertest,
defaultHeaders, defaultHeaders,
} = require("./couchTestUtils") } = require("./couchTestUtils")
@ -20,8 +18,6 @@ describe("/roles", () => {
let server let server
let request let request
let appId let appId
let table
let view
beforeAll(async () => { beforeAll(async () => {
;({ request, server } = await supertest()) ;({ request, server } = await supertest())
@ -34,8 +30,6 @@ describe("/roles", () => {
beforeEach(async () => { beforeEach(async () => {
let app = await createApplication(request) let app = await createApplication(request)
appId = app.instance._id appId = app.instance._id
table = await createTable(request, appId)
view = await createView(request, appId, table._id)
}) })
describe("create", () => { describe("create", () => {

View File

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

View File

@ -14,7 +14,15 @@ const selfhost = require("./selfhost")
const app = new Koa() const app = new Koa()
// set up top level koa middleware // 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( app.use(
logger({ logger({

View File

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

View File

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

View File

@ -1,10 +1,11 @@
const { const {
BUILTIN_ROLE_IDS, BUILTIN_ROLE_IDS,
getUserPermissionIds, getUserPermissions,
} = require("../utilities/security/roles") } = require("../utilities/security/roles")
const { const {
PermissionTypes, PermissionTypes,
doesHavePermission, doesHaveResourcePermission,
doesHaveBasePermission,
} = require("../utilities/security/permissions") } = require("../utilities/security/permissions")
const env = require("../environment") const env = require("../environment")
const { isAPIKeyValid } = require("../utilities/security/apikey") 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("|")) 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) => { module.exports = (permType, permLevel = null) => async (ctx, next) => {
// webhooks can pass locally // webhooks can pass locally
if (!env.CLOUD && LOCAL_PASS.test(ctx.request.url)) { 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 // don't expose builder endpoints in the cloud
if (env.CLOUD && permType === PermissionTypes.BUILDER) return if (env.CLOUD && permType === PermissionTypes.BUILDER) return
if (!ctx.auth.authenticated) {
ctx.throw(403, "Session not authenticated")
}
if (!ctx.user) { if (!ctx.user) {
ctx.throw(403, "User not found") ctx.throw(403, "No user info found")
} }
const role = ctx.user.role const role = ctx.user.role
const permissions = await getUserPermissionIds(ctx.appId, role._id) const { basePermissions, permissions } = await getUserPermissions(
if (ADMIN_ROLES.indexOf(role._id) !== -1) { ctx.appId,
return next() 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") 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") ctx.throw(403, "User does not have permission")
} }

View File

@ -22,3 +22,7 @@ function validate(schema, property) {
module.exports.body = schema => { module.exports.body = schema => {
return validate(schema, "body") 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 workerUrl = !env.CLOUD ? await exports.getWorkerUrl() : env.WORKER_URL
const hostingKey = !env.CLOUD ? hostingInfo.selfHostKey : env.HOSTING_KEY const hostingKey = !env.CLOUD ? hostingInfo.selfHostKey : env.HOSTING_KEY
const response = await fetch(`${workerUrl}/api/apps`, { try {
method: "GET", const response = await fetch(`${workerUrl}/api/apps`, {
headers: { method: "GET",
"x-budibase-auth": hostingKey, headers: {
}, "x-budibase-auth": hostingKey,
}) },
const json = await response.json() })
for (let value of Object.values(json)) { const json = await response.json()
if (value.url) { for (let value of Object.values(json)) {
value.url = value.url.toLowerCase() 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", ADMIN: "admin",
} }
// these are the global types, that govern the underlying default behaviour
const PermissionTypes = { const PermissionTypes = {
TABLE: "table", TABLE: "table",
USER: "user", USER: "user",
@ -29,12 +30,11 @@ function Permission(type, level) {
*/ */
function getAllowedLevels(userPermLevel) { function getAllowedLevels(userPermLevel) {
switch (userPermLevel) { switch (userPermLevel) {
case PermissionLevels.READ:
return [PermissionLevels.READ]
case PermissionLevels.WRITE:
return [PermissionLevels.READ, PermissionLevels.WRITE]
case PermissionLevels.EXECUTE: case PermissionLevels.EXECUTE:
return [PermissionLevels.EXECUTE] return [PermissionLevels.EXECUTE]
case PermissionLevels.READ:
return [PermissionLevels.EXECUTE, PermissionLevels.READ]
case PermissionLevels.WRITE:
case PermissionLevels.ADMIN: case PermissionLevels.ADMIN:
return [ return [
PermissionLevels.READ, 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) const builtins = Object.values(exports.BUILTIN_PERMISSIONS)
let permissions = flatten( let permissions = flatten(
builtins builtins
@ -115,6 +143,25 @@ exports.doesHavePermission = (permType, permLevel, permissionIds) => {
return false 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 // utility as a lot of things need simply the builder permission
exports.BUILDER = PermissionTypes.BUILDER exports.BUILDER = PermissionTypes.BUILDER
exports.PermissionTypes = PermissionTypes exports.PermissionTypes = PermissionTypes

View File

@ -1,6 +1,7 @@
const CouchDB = require("../../db") const CouchDB = require("../../db")
const { cloneDeep } = require("lodash/fp") 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 = { const BUILTIN_IDS = {
ADMIN: "ADMIN", ADMIN: "ADMIN",
@ -44,15 +45,15 @@ exports.BUILTIN_ROLES = {
} }
exports.BUILTIN_ROLE_ID_ARRAY = Object.values(exports.BUILTIN_ROLES).map( 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( exports.BUILTIN_ROLE_NAME_ARRAY = Object.values(exports.BUILTIN_ROLES).map(
level => level.name role => role.name
) )
function isBuiltin(role) { 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) { if (!roleId) {
return null 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)) { if (isBuiltin(roleId)) {
role = cloneDeep( role = cloneDeep(
Object.values(exports.BUILTIN_ROLES).find(role => role._id === roleId) Object.values(exports.BUILTIN_ROLES).find(role => role._id === roleId)
) )
} else { }
try {
const db = new CouchDB(appId) 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 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 * 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 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. * @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) => { exports.getUserPermissions = async (appId, userRoleId) => {
return [ const rolesHierarchy = await getAllUserRoles(appId, userRoleId)
...new Set( const basePermissions = [
(await getAllUserRoles(appId, userRoleId)).map(role => role.permissionId) ...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 { 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.AccessController = AccessController
exports.BUILTIN_ROLE_IDS = BUILTIN_IDS exports.BUILTIN_ROLE_IDS = BUILTIN_IDS
exports.isBuiltin = isBuiltin exports.isBuiltin = isBuiltin

View File

@ -3219,6 +3219,13 @@ fd-slicer@~1.1.0:
dependencies: dependencies:
pend "~1.2.0" 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: fetch-cookie@0.7.3:
version "0.7.3" version "0.7.3"
resolved "https://registry.yarnpkg.com/fetch-cookie/-/fetch-cookie-0.7.3.tgz#b8d023f421dd2b2f4a0eca9cd7318a967ed4eed8" 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" inherits "^2.0.1"
readable-stream "^1.0.27-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: pouchdb-adapter-leveldb-core@7.2.2:
version "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" 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: dependencies:
buffer-from "1.1.1" 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: pouchdb-collections@7.2.2:
version "7.2.2" version "7.2.2"
resolved "https://registry.yarnpkg.com/pouchdb-collections/-/pouchdb-collections-7.2.2.tgz#aeed77f33322429e3f59d59ea233b48ff0e68572" resolved "https://registry.yarnpkg.com/pouchdb-collections/-/pouchdb-collections-7.2.2.tgz#aeed77f33322429e3f59d59ea233b48ff0e68572"
@ -6569,6 +6595,28 @@ pouchdb-errors@7.2.2:
dependencies: dependencies:
inherits "2.0.4" 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: pouchdb-json@7.2.2:
version "7.2.2" version "7.2.2"
resolved "https://registry.yarnpkg.com/pouchdb-json/-/pouchdb-json-7.2.2.tgz#b939be24b91a7322e9a24b8880a6e21514ec5e1f" resolved "https://registry.yarnpkg.com/pouchdb-json/-/pouchdb-json-7.2.2.tgz#b939be24b91a7322e9a24b8880a6e21514ec5e1f"
@ -6576,6 +6624,16 @@ pouchdb-json@7.2.2:
dependencies: dependencies:
vuvuzela "1.0.3" 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: pouchdb-md5@7.2.2:
version "7.2.2" version "7.2.2"
resolved "https://registry.yarnpkg.com/pouchdb-md5/-/pouchdb-md5-7.2.2.tgz#415401acc5a844112d765bd1fb4e5d9f38fb0838" 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" pouchdb-promise "^6.0.4"
through2 "^2.0.0" 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: pouchdb-utils@7.2.2:
version "7.2.2" version "7.2.2"
resolved "https://registry.yarnpkg.com/pouchdb-utils/-/pouchdb-utils-7.2.2.tgz#c17c4788f1d052b0daf4ef8797bbc4aaa3945aa4" 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" resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
psl@^1.1.28: psl@^1.1.28, psl@^1.1.33:
version "1.8.0" version "1.8.0"
resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24"
integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== 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" psl "^1.1.28"
punycode "^2.1.1" 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: tr46@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
@ -8217,7 +8292,7 @@ unique-string@^2.0.0:
dependencies: dependencies:
crypto-random-string "^2.0.0" crypto-random-string "^2.0.0"
universalify@^0.1.0: universalify@^0.1.0, universalify@^0.1.2:
version "0.1.2" version "0.1.2"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==

View File

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

View File

@ -112,6 +112,45 @@
"type": "datasource", "type": "datasource",
"label": "Data", "label": "Data",
"key": "datasource" "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", "gitHead": "1a80b09fd093f2599a68f7db72ad639dd50922dd",
"dependencies": { "dependencies": {
"@adobe/spectrum-css-workflow-icons": "^1.1.0", "@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", "@budibase/svelte-ag-grid": "^0.0.16",
"@spectrum-css/actionbutton": "^1.0.0-beta.1", "@spectrum-css/actionbutton": "^1.0.0-beta.1",
"@spectrum-css/button": "^3.0.0-beta.6", "@spectrum-css/button": "^3.0.0-beta.6",

View File

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

View File

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

View File

@ -30,8 +30,15 @@
await authStore.actions.logIn({ email, password }) await authStore.actions.logIn({ email, password })
loading = false loading = false
} }
function handleKeydown(evt) {
if (evt.key === "Enter") {
login()
}
}
</script> </script>
<svelte:window on:keydown={handleKeydown} />
<div class="root" use:styleable={$component.styles}> <div class="root" use:styleable={$component.styles}>
<div class="content"> <div class="content">
{#if logo} {#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> </div>
{/if} {/if}
</Field> </Field>
<style>
.spectrum-Checkbox {
width: 100%;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -58,3 +58,9 @@
</div> </div>
{/if} {/if}
</Field> </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 cardhorizontal } from "./CardHorizontal.svelte"
export { default as cardstat } from "./CardStat.svelte" export { default as cardstat } from "./CardStat.svelte"
export { default as icon } from "./Icon.svelte" export { default as icon } from "./Icon.svelte"
export { default as search } from "./Search.svelte"
export * from "./charts" export * from "./charts"
export * from "./forms" export * from "./forms"

View File

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