Merge branch 'develop' of github.com:Budibase/budibase into url-context

This commit is contained in:
Andrew Kingston 2021-02-16 15:35:05 +00:00
commit ec94aaa90b
73 changed files with 1649 additions and 9051 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.8",
"@budibase/colorpicker": "1.0.1",
"@budibase/string-templates": "^0.7.8",

View File

@ -235,7 +235,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

@ -30,6 +30,7 @@ export const getBackendUiStore = () => {
const queries = await queriesResponse.json()
const integrationsResponse = await api.get("/api/integrations")
const integrations = await integrationsResponse.json()
const permissionLevels = await store.actions.permissions.fetchLevels()
store.update(state => {
state.selectedDatabase = db
@ -37,6 +38,7 @@ export const getBackendUiStore = () => {
state.datasources = datasources
state.queries = queries
state.integrations = integrations
state.permissionLevels = permissionLevels
return state
})
},
@ -232,7 +234,7 @@ export const getBackendUiStore = () => {
return state
})
},
saveField: ({ originalName, field, primaryDisplay = false }) => {
saveField: ({ originalName, field, primaryDisplay = false, indexes }) => {
store.update(state => {
// delete the original if renaming
// need to handle if the column had no name, empty string
@ -249,6 +251,10 @@ export const getBackendUiStore = () => {
state.draftTable.primaryDisplay = field.name
}
if (indexes) {
state.draftTable.indexes = indexes
}
state.draftTable.schema[field.name] = cloneDeep(field)
store.actions.tables.save(state.draftTable)
return state
@ -324,6 +330,25 @@ export const getBackendUiStore = () => {
return response
},
},
permissions: {
fetchLevels: async () => {
const response = await api.get("/api/permission/levels")
const json = await response.json()
return json
},
forResource: async resourceId => {
const response = await api.get(`/api/permission/${resourceId}`)
const json = await response.json()
return json
},
save: async ({ role, resource, level }) => {
const response = await api.post(
`/api/permission/${role}/${resource}/${level}`
)
const json = await response.json()
return json
},
},
}
return store

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

@ -5,6 +5,7 @@
import CreateViewButton from "./buttons/CreateViewButton.svelte"
import ExportButton from "./buttons/ExportButton.svelte"
import EditRolesButton from "./buttons/EditRolesButton.svelte"
import ManageAccessButton from "./buttons/ManageAccessButton.svelte"
import * as api from "./api"
import Table from "./Table.svelte"
import { TableNames } from "constants"
@ -47,6 +48,7 @@
title={isUsersTable ? 'Create New User' : 'Create New Row'}
modalContentComponent={isUsersTable ? CreateEditUser : CreateEditRow} />
<CreateViewButton />
<ManageAccessButton resourceId={$backendUiStore.selectedTable?._id} />
<ExportButton view={tableView} />
{/if}
{#if isUsersTable}

View File

@ -6,6 +6,7 @@
import GroupByButton from "./buttons/GroupByButton.svelte"
import FilterButton from "./buttons/FilterButton.svelte"
import ExportButton from "./buttons/ExportButton.svelte"
import ManageAccessButton from "./buttons/ManageAccessButton.svelte"
export let view = {}
@ -53,5 +54,6 @@
{#if view.calculation}
<GroupByButton {view} />
{/if}
<ManageAccessButton resourceId={decodeURI(name)} />
<ExportButton {view} />
</Table>

View File

@ -0,0 +1,43 @@
<script>
import { TextButton, Icon, Popover } from "@budibase/bbui"
import { backendUiStore } from "builderStore"
import { Roles } from "constants/backend"
import api from "builderStore/api"
import ManageAccessPopover from "../popovers/ManageAccessPopover.svelte"
export let resourceId
let anchor
let dropdown
let levels
let permissions
async function openDropdown() {
permissions = await backendUiStore.actions.permissions.forResource(
resourceId
)
levels = await backendUiStore.actions.permissions.fetchLevels()
dropdown.show()
}
</script>
<div bind:this={anchor}>
<TextButton text small on:click={openDropdown}>
<i class="ri-lock-line" />
Manage Access
</TextButton>
</div>
<Popover bind:this={dropdown} {anchor} align="left">
<ManageAccessPopover
{resourceId}
{levels}
{permissions}
onClosed={dropdown.hide} />
</Popover>
<style>
i {
margin-right: var(--spacing-xs);
font-size: var(--font-size-s);
}
</style>

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,6 +31,7 @@
let primaryDisplay =
$backendUiStore.selectedTable.primaryDisplay == null ||
$backendUiStore.selectedTable.primaryDisplay === field.name
let indexes = [...($backendUiStore.selectedTable.indexes || [])]
let confirmDeleteDialog
let deletion
@ -41,6 +49,7 @@
originalName,
field,
primaryDisplay,
indexes,
})
return state
})
@ -79,6 +88,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
@ -120,6 +141,20 @@
on:change={onChangePrimaryDisplay}
thin
text="Use as table display column" />
<Label grey 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

@ -0,0 +1,92 @@
<script>
import { onMount } from "svelte"
import { backendUiStore } from "builderStore"
import { Roles } from "constants/backend"
import api from "builderStore/api"
import { notifier } from "builderStore/store/notifications"
import { Button, Label, Input, Select, Spacer } from "@budibase/bbui"
export let resourceId
export let permissions
export let onClosed
async function changePermission(level, role) {
await backendUiStore.actions.permissions.save({
level,
role,
resource: resourceId,
})
// Show updated permissions in UI: REMOVE
permissions = await backendUiStore.actions.permissions.forResource(
resourceId
)
notifier.success("Updated permissions.")
// TODO: update permissions
// permissions[]
}
</script>
<div class="popover">
<h5>Who Can Access This Data?</h5>
<div class="note">
<Label extraSmall grey>Specify the minimum access level role for this data.</Label>
</div>
<Spacer large />
<div class="row">
<Label extraSmall grey>Level</Label>
<Label extraSmall grey>Role</Label>
{#each Object.keys(permissions) as level}
<Input secondary thin value={level} disabled={true} />
<Select
secondary
thin
value={permissions[level]}
on:change={e => changePermission(level, e.target.value)}>
{#each $backendUiStore.roles as role}
<option value={role._id}>{role.name}</option>
{/each}
</Select>
{/each}
</div>
<Spacer large />
<div class="footer">
<Button secondary on:click={onClosed}>Cancel</Button>
</div>
</div>
<style>
.popover {
display: grid;
width: 400px;
}
h5 {
margin: 0;
font-weight: 500;
}
hr {
margin: var(--spacing-s) 0 var(--spacing-m) 0;
}
.footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-m);
margin-top: var(--spacing-l);
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: var(--spacing-m);
}
.note {
margin-top: 10px;
margin-bottom: 0;
}
</style>

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

@ -92,3 +92,11 @@ export const HostingTypes = {
CLOUD: "cloud",
SELF: "self",
}
export const Roles = {
ADMIN: "ADMIN",
POWER: "POWER",
BASIC: "BASIC",
PUBLIC: "PUBLIC",
BUILDER: "BUILDER",
}

File diff suppressed because it is too large Load Diff

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,187 @@
const { BUILTIN_PERMISSIONS } = require("../../utilities/security/permissions")
const {
BUILTIN_PERMISSIONS,
PermissionLevels,
isPermissionLevelHigherThanRead,
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")
const {
CURRENTLY_SUPPORTED_LEVELS,
getBasePermissions,
} = require("../../utilities/security/utilities")
exports.fetch = async function(ctx) {
// TODO: need to build out custom permissions
const PermissionUpdateType = {
REMOVE: "remove",
ADD: "add",
}
const SUPPORTED_LEVELS = CURRENTLY_SUPPORTED_LEVELS
// quick function to perform a bit of weird logic, make sure fetch calls
// always say a write role also has read permission
function fetchLevelPerms(permissions, level, roleId) {
if (!permissions) {
permissions = {}
}
permissions[level] = roleId
if (
isPermissionLevelHigherThanRead(level) &&
!permissions[PermissionLevels.READ]
) {
permissions[PermissionLevels.READ] = roleId
}
return permissions
}
// 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] = higherPermission(
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 = SUPPORTED_LEVELS
}
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) {
continue
}
const roleId = getExternalRoleID(role._id)
for (let [resource, level] of Object.entries(role.permissions)) {
permissions[resource] = fetchLevelPerms(
permissions[resource],
level,
roleId
)
}
}
// apply the base permissions
const finalPermissions = {}
for (let [resource, permission] of Object.entries(permissions)) {
const basePerms = getBasePermissions(resource)
finalPermissions[resource] = Object.assign(basePerms, permission)
}
ctx.body = finalPermissions
}
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)
let permissions = {}
for (let level of SUPPORTED_LEVELS) {
// update the various roleIds in the resource permissions
for (let role of roles) {
if (role.permissions && role.permissions[resourceId] === level) {
permissions = fetchLevelPerms(
permissions,
level,
getExternalRoleID(role._id)
)
}
}
}
ctx.body = Object.assign(getBasePermissions(resourceId), permissions)
}
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,24 @@ exports.fetch = async function(ctx) {
include_docs: true,
})
)
const customRoles = body.rows.map(row => row.doc)
let 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 {
// remove role and all back after combining with the builtin
roles = roles.filter(role => role._id !== dbBuiltin._id)
dbBuiltin._id = getExternalRoleID(dbBuiltin._id)
roles.push(Object.assign(builtinRole, dbBuiltin))
}
}
ctx.body = roles
}
exports.find = async function(ctx) {
@ -67,6 +86,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 +105,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 +118,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,126 @@
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["read"]).toEqual(STD_ROLE_ID)
expect(res.body["write"]).toEqual(HIGHER_ROLE_ID)
})
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["read"]).toEqual(STD_ROLE_ID)
expect(res.body["write"]).toEqual(HIGHER_ROLE_ID)
const allRes = await request
.get(`/api/permission`)
.set(defaultHeaders(appId))
.expect("Content-Type", /json/)
.expect(200)
expect(allRes.body[table._id]["write"]).toEqual(HIGHER_ROLE_ID)
expect(allRes.body[table._id]["read"]).toEqual(STD_ROLE_ID)
})
})
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

@ -2,6 +2,7 @@ const Router = require("@koa/router")
const viewController = require("../controllers/view")
const rowController = require("../controllers/row")
const authorized = require("../../middleware/authorized")
const { paramResource } = require("../../middleware/resourceId")
const {
BUILDER,
PermissionTypes,
@ -15,12 +16,14 @@ router
.get("/api/views/export", authorized(BUILDER), viewController.exportView)
.get(
"/api/views/:viewName",
paramResource("viewName"),
authorized(PermissionTypes.VIEW, PermissionLevels.READ),
rowController.fetchView
)
.get("/api/views", authorized(BUILDER), viewController.fetch)
.delete(
"/api/views/:viewName",
paramResource("viewName"),
authorized(BUILDER),
usage,
viewController.destroy

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

@ -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",
@ -22,6 +23,22 @@ function Permission(type, level) {
this.type = type
}
function levelToNumber(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
}
}
/**
* Given the specified permission level for the user return the levels they are allowed to carry out.
* @param {string} userPermLevel The permission level of the user.
@ -29,12 +46,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,
@ -47,6 +63,7 @@ function getAllowedLevels(userPermLevel) {
}
exports.BUILTIN_PERMISSION_IDS = {
PUBLIC: "public",
READ_ONLY: "read_only",
WRITE: "write",
ADMIN: "admin",
@ -54,6 +71,13 @@ exports.BUILTIN_PERMISSION_IDS = {
}
exports.BUILTIN_PERMISSIONS = {
PUBLIC: {
_id: exports.BUILTIN_PERMISSION_IDS.PUBLIC,
name: "Public",
permissions: [
new Permission(PermissionTypes.WEBHOOK, PermissionLevels.EXECUTE),
],
},
READ_ONLY: {
_id: exports.BUILTIN_PERMISSION_IDS.READ_ONLY,
name: "Read only",
@ -97,7 +121,40 @@ exports.BUILTIN_PERMISSIONS = {
},
}
exports.doesHavePermission = (permType, permLevel, permissionIds) => {
exports.getBuiltinPermissionByID = id => {
const perms = Object.values(exports.BUILTIN_PERMISSIONS)
return perms.find(perm => perm._id === id)
}
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 +172,14 @@ exports.doesHavePermission = (permType, permLevel, permissionIds) => {
return false
}
exports.higherPermission = (perm1, perm2) => {
return levelToNumber(perm1) > levelToNumber(perm2) ? perm1 : perm2
}
exports.isPermissionLevelHigherThanRead = level => {
return levelToNumber(level) > 1
}
// 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",
@ -36,7 +37,7 @@ exports.BUILTIN_ROLES = {
.addPermission(BUILTIN_PERMISSION_IDS.WRITE)
.addInheritance(BUILTIN_IDS.PUBLIC),
PUBLIC: new Role(BUILTIN_IDS.PUBLIC, "Public").addPermission(
BUILTIN_PERMISSION_IDS.READ_ONLY
BUILTIN_PERMISSION_IDS.PUBLIC
),
BUILDER: new Role(BUILTIN_IDS.BUILDER, "Builder").addPermission(
BUILTIN_PERMISSION_IDS.ADMIN
@ -44,15 +45,50 @@ 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))
}
/**
* Works through the inheritance ranks to see how far up the builtin stack this ID is.
*/
function builtinRoleToNumber(id) {
const MAX = Object.values(BUILTIN_IDS).length + 1
if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) {
return MAX
}
let role = exports.BUILTIN_ROLES[id],
count = 0
do {
if (!role) {
break
}
role = exports.BUILTIN_ROLES[role.inherits]
count++
} while (role !== null)
return count
}
/**
* Returns whichever builtin roleID is lower.
*/
exports.lowerBuiltinRoleID = (roleId1, roleId2) => {
if (!roleId1) {
return roleId2
}
if (!roleId2) {
return roleId1
}
return builtinRoleToNumber(roleId1) > builtinRoleToNumber(roleId2)
? roleId2
: roleId1
}
/**
@ -66,14 +102,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 +165,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 +236,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

@ -0,0 +1,70 @@
const {
PermissionLevels,
PermissionTypes,
getBuiltinPermissionByID,
isPermissionLevelHigherThanRead,
} = require("../../utilities/security/permissions")
const {
lowerBuiltinRoleID,
BUILTIN_ROLES,
} = require("../../utilities/security/roles")
const { DocumentTypes } = require("../../db/utils")
const CURRENTLY_SUPPORTED_LEVELS = [
PermissionLevels.WRITE,
PermissionLevels.READ,
]
exports.getPermissionType = resourceId => {
const docType = Object.values(DocumentTypes).filter(docType =>
resourceId.startsWith(docType)
)[0]
switch (docType) {
case DocumentTypes.TABLE:
case DocumentTypes.ROW:
return PermissionTypes.TABLE
case DocumentTypes.AUTOMATION:
return PermissionTypes.AUTOMATION
case DocumentTypes.WEBHOOK:
return PermissionTypes.WEBHOOK
case DocumentTypes.QUERY:
case DocumentTypes.DATASOURCE:
return PermissionTypes.QUERY
default:
// views don't have an ID, will end up here
return PermissionTypes.VIEW
}
}
/**
* works out the basic permissions based on builtin roles for a resource, using its ID
* @param resourceId
* @returns {{}}
*/
exports.getBasePermissions = resourceId => {
const type = exports.getPermissionType(resourceId)
const permissions = {}
for (let [roleId, role] of Object.entries(BUILTIN_ROLES)) {
if (!role.permissionId) {
continue
}
const perms = getBuiltinPermissionByID(role.permissionId)
const typedPermission = perms.permissions.find(perm => perm.type === type)
if (
typedPermission &&
CURRENTLY_SUPPORTED_LEVELS.indexOf(typedPermission.level) !== -1
) {
const level = typedPermission.level
permissions[level] = lowerBuiltinRoleID(permissions[level], roleId)
if (isPermissionLevelHigherThanRead(level)) {
permissions[PermissionLevels.READ] = lowerBuiltinRoleID(
permissions[PermissionLevels.READ],
roleId
)
}
}
}
return permissions
}
exports.CURRENTLY_SUPPORTED_LEVELS = CURRENTLY_SUPPORTED_LEVELS

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"