diff --git a/packages/backend-core/src/security/roles.ts b/packages/backend-core/src/security/roles.ts index c14178cacb..7d9a11db48 100644 --- a/packages/backend-core/src/security/roles.ts +++ b/packages/backend-core/src/security/roles.ts @@ -213,6 +213,22 @@ export function getBuiltinRole(roleId: string): Role | undefined { return cloneDeep(role) } +export function validInherits( + allRoles: RoleDoc[], + inherits?: string | string[] +): boolean { + if (!inherits) { + return false + } + const find = (id: string) => allRoles.find(r => roleIDsAreEqual(r._id!, id)) + if (Array.isArray(inherits)) { + const filtered = inherits.filter(roleId => find(roleId)) + return inherits.length !== 0 && filtered.length === inherits.length + } else { + return !!find(inherits) + } +} + /** * Works through the inheritance ranks to see how far up the builtin stack this ID is. */ @@ -290,7 +306,7 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string { : roleId1 } -export function compareRoleIds(roleId1: string, roleId2: string) { +export function roleIDsAreEqual(roleId1: string, roleId2: string) { // make sure both role IDs are prefixed correctly return prefixRoleID(roleId1) === prefixRoleID(roleId2) } @@ -323,7 +339,7 @@ export function findRole( roleId = prefixRoleID(roleId) } const dbRole = roles.find( - role => role._id && compareRoleIds(role._id, roleId) + role => role._id && roleIDsAreEqual(role._id, roleId) ) if (!dbRole && !isBuiltin(roleId) && opts?.defaultPublic) { return cloneDeep(BUILTIN_ROLES.PUBLIC) @@ -557,7 +573,7 @@ export class AccessController { } return ( - roleIds?.find(roleId => compareRoleIds(roleId, tryingRoleId)) !== + roleIds?.find(roleId => roleIDsAreEqual(roleId, tryingRoleId)) !== undefined ) } diff --git a/packages/bbui/src/InlineAlert/InlineAlert.svelte b/packages/bbui/src/InlineAlert/InlineAlert.svelte index 3b98936f62..edfa760eb8 100644 --- a/packages/bbui/src/InlineAlert/InlineAlert.svelte +++ b/packages/bbui/src/InlineAlert/InlineAlert.svelte @@ -8,6 +8,7 @@ export let onConfirm = undefined export let buttonText = "" export let cta = false + $: icon = selectIcon(type) // if newlines used, convert them to different elements $: split = message.split("\n") diff --git a/packages/bbui/src/List/ListItem.svelte b/packages/bbui/src/List/ListItem.svelte index 5b6152781a..699df2d456 100644 --- a/packages/bbui/src/List/ListItem.svelte +++ b/packages/bbui/src/List/ListItem.svelte @@ -1,5 +1,6 @@
- {#if icon} + {#if icon === "StatusLight"} + + {:else if icon} {/if}
@@ -43,7 +48,7 @@ diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridAutomationsButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridAutomationsButton.svelte index 9670bb2f35..b2448eeaf3 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridAutomationsButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridAutomationsButton.svelte @@ -34,7 +34,7 @@ const generateAutomation = () => { popover?.hide() - dispatch("request-generate") + dispatch("generate") } diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridGenerateButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridGenerateButton.svelte index 5cc3aca19e..5d1ec835d5 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridGenerateButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridGenerateButton.svelte @@ -91,7 +91,7 @@ - +
magic wand Generate diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridScreensButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridScreensButton.svelte index 701a286112..db446b3c9e 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridScreensButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridScreensButton.svelte @@ -22,7 +22,7 @@ const generateScreen = () => { popover?.hide() - dispatch("request-generate") + dispatch("generate") } diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditUser.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditUser.svelte index a9990d0c2b..5ac2beab65 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditUser.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditUser.svelte @@ -125,7 +125,7 @@ label="Role" bind:value={row.roleId} options={$roles} - getOptionLabel={role => role.name} + getOptionLabel={role => role.uiMetadata.displayName} getOptionValue={role => role._id} disabled={!creating} /> diff --git a/packages/builder/src/components/backend/DataTable/modals/EditRoles.svelte b/packages/builder/src/components/backend/DataTable/modals/EditRoles.svelte deleted file mode 100644 index 6cec28de1d..0000000000 --- a/packages/builder/src/components/backend/DataTable/modals/EditRoles.svelte +++ /dev/null @@ -1,174 +0,0 @@ - - - - {#if errors.length} - - {/if} - - x._id} - getOptionLabel={x => x.name} - disabled={shouldDisableRoleInput} - /> - {/if} -
- {#if !isCreating && !builtInRoles.includes(selectedRole.name)} - - {/if} -
-
diff --git a/packages/builder/src/components/backend/DataTable/modals/ManageAccessModal.svelte b/packages/builder/src/components/backend/DataTable/modals/ManageAccessModal.svelte deleted file mode 100644 index bc5c437c57..0000000000 --- a/packages/builder/src/components/backend/DataTable/modals/ManageAccessModal.svelte +++ /dev/null @@ -1,127 +0,0 @@ - - -Specify the minimum access level role for this data. -
- - - {#each Object.keys(computedPermissions) as level} - - (tempDisplayName = e.detail)} + /> + (tempDescription = e.detail)} + /> +
+ + (tempColor = e.detail)} /> +
+ + + + diff --git a/packages/builder/src/components/backend/RoleEditor/constants.js b/packages/builder/src/components/backend/RoleEditor/constants.js new file mode 100644 index 0000000000..6f188e2141 --- /dev/null +++ b/packages/builder/src/components/backend/RoleEditor/constants.js @@ -0,0 +1,9 @@ +export const ZoomDuration = 300 +export const MaxAutoZoom = 1.2 +export const GridResolution = 20 +export const NodeHeight = GridResolution * 3 +export const NodeWidth = GridResolution * 12 +export const NodeHSpacing = GridResolution * 6 +export const NodeVSpacing = GridResolution * 2 +export const MinHeight = GridResolution * 10 +export const EmptyStateID = "empty" diff --git a/packages/builder/src/components/backend/RoleEditor/utils.js b/packages/builder/src/components/backend/RoleEditor/utils.js new file mode 100644 index 0000000000..a958fc6401 --- /dev/null +++ b/packages/builder/src/components/backend/RoleEditor/utils.js @@ -0,0 +1,245 @@ +import dagre from "@dagrejs/dagre" +import { + NodeWidth, + NodeHeight, + GridResolution, + NodeHSpacing, + NodeVSpacing, + MinHeight, + EmptyStateID, +} from "./constants" +import { getNodesBounds, Position } from "@xyflow/svelte" +import { Roles } from "constants/backend" +import { roles } from "stores/builder" +import { get } from "svelte/store" + +// Calculates the bounds of all custom nodes +export const getBounds = nodes => { + const interactiveNodes = nodes.filter(node => node.data.interactive) + + // Empty state bounds which line up with bounds after adding first node + if (!interactiveNodes.length) { + return { + x: 0, + y: -3.5 * GridResolution, + width: 12 * GridResolution, + height: 10 * GridResolution, + } + } + let bounds = getNodesBounds(interactiveNodes) + + // Enforce a min size + if (bounds.height < MinHeight) { + const diff = MinHeight - bounds.height + bounds.height = MinHeight + bounds.y -= diff / 2 + } + return bounds +} + +// Gets the position of the basic role +export const getBasicPosition = bounds => ({ + x: bounds.x - NodeHSpacing - NodeWidth, + y: bounds.y + bounds.height / 2 - NodeHeight / 2, +}) + +// Gets the position of the admin role +export const getAdminPosition = bounds => ({ + x: bounds.x + bounds.width + NodeHSpacing, + y: bounds.y + bounds.height / 2 - NodeHeight / 2, +}) + +// Filters out invalid nodes and edges +const preProcessLayout = ({ nodes, edges }) => { + const ignoredIds = [Roles.PUBLIC, Roles.BASIC, Roles.ADMIN, EmptyStateID] + const targetlessIds = [Roles.POWER] + return { + nodes: nodes.filter(node => { + // Filter out ignored IDs + if (ignoredIds.includes(node.id)) { + return false + } + return true + }), + edges: edges.filter(edge => { + // Filter out edges from ignored IDs + if ( + ignoredIds.includes(edge.source) || + ignoredIds.includes(edge.target) + ) { + return false + } + // Filter out edges which have the same source and target + if (edge.source === edge.target) { + return false + } + // Filter out edges which target targetless roles + if (targetlessIds.includes(edge.target)) { + return false + } + return true + }), + } +} + +// Updates positions of nodes and edges into a nice graph structure +export const dagreLayout = ({ nodes, edges }) => { + const dagreGraph = new dagre.graphlib.Graph() + dagreGraph.setDefaultEdgeLabel(() => ({})) + dagreGraph.setGraph({ + rankdir: "LR", + ranksep: NodeHSpacing, + nodesep: NodeVSpacing, + }) + nodes.forEach(node => { + dagreGraph.setNode(node.id, { width: NodeWidth, height: NodeHeight }) + }) + edges.forEach(edge => { + dagreGraph.setEdge(edge.source, edge.target) + }) + dagre.layout(dagreGraph) + nodes.forEach(node => { + const pos = dagreGraph.node(node.id) + node.targetPosition = Position.Left + node.sourcePosition = Position.Right + node.position = { + x: Math.round((pos.x - NodeWidth / 2) / GridResolution) * GridResolution, + y: Math.round((pos.y - NodeHeight / 2) / GridResolution) * GridResolution, + } + }) + return { nodes, edges } +} + +const postProcessLayout = ({ nodes, edges }) => { + // Add basic and admin nodes at each edge + const bounds = getBounds(nodes) + const $roles = get(roles) + nodes.push({ + ...roleToNode($roles.find(role => role._id === Roles.BASIC)), + position: getBasicPosition(bounds), + }) + nodes.push({ + ...roleToNode($roles.find(role => role._id === Roles.ADMIN)), + position: getAdminPosition(bounds), + }) + + // Add custom edges for basic and admin brackets + edges.push({ + id: "basic-bracket", + source: Roles.BASIC, + target: Roles.ADMIN, + type: "bracket", + }) + edges.push({ + id: "admin-bracket", + source: Roles.ADMIN, + target: Roles.BASIC, + type: "bracket", + }) + + // Add empty state node if required + if (!nodes.some(node => node.data.interactive)) { + nodes.push({ + id: EmptyStateID, + type: "empty", + position: { + x: bounds.x + bounds.width / 2 - NodeWidth / 2, + y: bounds.y + bounds.height / 2 - NodeHeight / 2, + }, + data: {}, + measured: { + width: NodeWidth, + height: NodeHeight, + }, + deletable: false, + draggable: false, + connectable: false, + selectable: false, + }) + } + + return { nodes, edges } +} + +// Automatically lays out the graph, sanitising and enriching the structure +export const autoLayout = ({ nodes, edges }) => { + return postProcessLayout(dagreLayout(preProcessLayout({ nodes, edges }))) +} + +// Converts a role doc into a node structure +export const roleToNode = role => { + const custom = ![ + Roles.PUBLIC, + Roles.BASIC, + Roles.POWER, + Roles.ADMIN, + Roles.BUILDER, + ].includes(role._id) + const interactive = custom || role._id === Roles.POWER + return { + id: role._id, + sourcePosition: Position.Right, + targetPosition: Position.Left, + type: "role", + position: { x: 0, y: 0 }, + data: { + ...role.uiMetadata, + custom, + interactive, + }, + measured: { + width: NodeWidth, + height: NodeHeight, + }, + deletable: custom, + draggable: interactive, + connectable: interactive, + selectable: interactive, + } +} + +// Converts a node structure back into a role doc +export const nodeToRole = ({ node, edges }) => ({ + ...get(roles).find(role => role._id === node.id), + inherits: edges + .filter(x => x.target === node.id) + .map(x => x.source) + .concat(Roles.BASIC), + uiMetadata: { + displayName: node.data.displayName, + color: node.data.color, + description: node.data.description, + }, +}) + +// Builds a default layout from an array of roles +export const rolesToLayout = roles => { + let nodes = [] + let edges = [] + + // Add all nodes and edges + for (let role of roles) { + // Add node for this role + nodes.push(roleToNode(role)) + + // Add edges for this role + let inherits = [] + if (role.inherits) { + inherits = Array.isArray(role.inherits) ? role.inherits : [role.inherits] + } + for (let sourceRole of inherits) { + if (!roles.some(x => x._id === sourceRole)) { + continue + } + edges.push({ + id: `${sourceRole}-${role._id}`, + source: sourceRole, + target: role._id, + }) + } + } + return { + nodes, + edges, + } +} diff --git a/packages/builder/src/components/common/RoleIcon.svelte b/packages/builder/src/components/common/RoleIcon.svelte index 1bd6ba49bc..3b48935e0c 100644 --- a/packages/builder/src/components/common/RoleIcon.svelte +++ b/packages/builder/src/components/common/RoleIcon.svelte @@ -1,12 +1,14 @@ diff --git a/packages/builder/src/components/common/RoleSelect.svelte b/packages/builder/src/components/common/RoleSelect.svelte index 6006b8ab8d..38b84e964d 100644 --- a/packages/builder/src/components/common/RoleSelect.svelte +++ b/packages/builder/src/components/common/RoleSelect.svelte @@ -3,7 +3,7 @@ import { roles } from "stores/builder" import { licensing } from "stores/portal" - import { Constants, RoleUtils } from "@budibase/frontend-core" + import { Constants } from "@budibase/frontend-core" import { createEventDispatcher } from "svelte" import { capitalise } from "helpers" @@ -49,7 +49,8 @@ let options = roles .filter(role => allowedRoles.includes(role._id)) .map(role => ({ - name: enrichLabel(role.name), + color: role.uiMetadata.color, + name: enrichLabel(role.uiMetadata.displayName), _id: role._id, })) if (allowedRoles.includes(Constants.Roles.CREATOR)) { @@ -64,7 +65,8 @@ // Allow all core roles let options = roles.map(role => ({ - name: enrichLabel(role.name), + color: role.uiMetadata.color, + name: enrichLabel(role.uiMetadata.displayName), _id: role._id, })) @@ -100,7 +102,7 @@ if (role._id === Constants.Roles.CREATOR || role._id === RemoveID) { return null } - return RoleUtils.getRoleColour(role._id) + return role.color || "var(--spectrum-global-color-static-magenta-400)" } const getIcon = role => { diff --git a/packages/builder/src/components/design/settings/controls/FilterEditor/FilterEditor.svelte b/packages/builder/src/components/design/settings/controls/FilterEditor/FilterEditor.svelte index d6f1732e64..34c317e865 100644 --- a/packages/builder/src/components/design/settings/controls/FilterEditor/FilterEditor.svelte +++ b/packages/builder/src/components/design/settings/controls/FilterEditor/FilterEditor.svelte @@ -59,6 +59,7 @@ bind:this={drawer} title="Filtering" on:drawerHide + on:drawerShow on:drawerShow={() => { // Reset to the currently available value. localFilters = Helpers.cloneDeep(value) diff --git a/packages/builder/src/components/design/settings/controls/RoleSelect.svelte b/packages/builder/src/components/design/settings/controls/RoleSelect.svelte index 5b5daac183..b99e58f205 100644 --- a/packages/builder/src/components/design/settings/controls/RoleSelect.svelte +++ b/packages/builder/src/components/design/settings/controls/RoleSelect.svelte @@ -1,20 +1,21 @@ role.label} - getOptionValue={role => role.value} - /> - diff --git a/packages/builder/src/stores/builder/roles.js b/packages/builder/src/stores/builder/roles.js index ac395aa232..fd3581f1d4 100644 --- a/packages/builder/src/stores/builder/roles.js +++ b/packages/builder/src/stores/builder/roles.js @@ -1,16 +1,34 @@ -import { writable } from "svelte/store" +import { derived, writable, get } from "svelte/store" import { API } from "api" import { RoleUtils } from "@budibase/frontend-core" export function createRolesStore() { - const { subscribe, update, set } = writable([]) + const store = writable([]) + const enriched = derived(store, $store => { + return $store.map(role => ({ + ...role, + + // Ensure we have new metadata for all roles + uiMetadata: { + displayName: role.uiMetadata?.displayName || role.name, + color: + role.uiMetadata?.color || "var(--spectrum-global-color-magenta-400)", + description: role.uiMetadata?.description || "Custom role", + }, + })) + }) function setRoles(roles) { - set( + store.set( roles.sort((a, b) => { const priorityA = RoleUtils.getRolePriority(a._id) const priorityB = RoleUtils.getRolePriority(b._id) - return priorityA > priorityB ? -1 : 1 + if (priorityA !== priorityB) { + return priorityA > priorityB ? -1 : 1 + } + const nameA = a.uiMetadata?.displayName || a.name + const nameB = b.uiMetadata?.displayName || b.name + return nameA < nameB ? -1 : 1 }) ) } @@ -29,17 +47,43 @@ export function createRolesStore() { roleId: role?._id, roleRev: role?._rev, }) - update(state => state.filter(existing => existing._id !== role._id)) + await actions.fetch() }, save: async role => { const savedRole = await API.saveRole(role) await actions.fetch() return savedRole }, + replace: (roleId, role) => { + // Handles external updates of roles + if (!roleId) { + return + } + + // Handle deletion + if (!role) { + store.update(state => state.filter(x => x._id !== roleId)) + return + } + + // Add new role + const index = get(store).findIndex(x => x._id === role._id) + if (index === -1) { + store.update(state => [...state, role]) + } + + // Update existing role + else if (role) { + store.update(state => { + state[index] = role + return [...state] + }) + } + }, } return { - subscribe, + subscribe: enriched.subscribe, ...actions, } } diff --git a/packages/builder/src/stores/builder/websocket.js b/packages/builder/src/stores/builder/websocket.js index 7df5ab9adb..8a0d83abc1 100644 --- a/packages/builder/src/stores/builder/websocket.js +++ b/packages/builder/src/stores/builder/websocket.js @@ -9,6 +9,7 @@ import { snippets, datasources, tables, + roles, } from "stores/builder" import { get } from "svelte/store" import { auth, appsStore } from "stores/portal" @@ -56,12 +57,18 @@ export const createBuilderWebsocket = appId => { datasources.replaceDatasource(id, datasource) }) + // Role events + socket.onOther(BuilderSocketEvent.RoleChange, ({ id, role }) => { + roles.replace(id, role) + }) + // Design section events socket.onOther(BuilderSocketEvent.ScreenChange, ({ id, screen }) => { screenStore.replace(id, screen) }) + + // App events socket.onOther(BuilderSocketEvent.AppMetadataChange, ({ metadata }) => { - //Sync app metadata across the stores appStore.syncMetadata(metadata) themeStore.syncMetadata(metadata) navigationStore.syncMetadata(metadata) @@ -79,7 +86,7 @@ export const createBuilderWebsocket = appId => { } ) - // Automations + // Automation events socket.onOther(BuilderSocketEvent.AutomationChange, ({ id, automation }) => { automationStore.actions.replace(id, automation) }) diff --git a/packages/client/src/components/app/DataProvider.svelte b/packages/client/src/components/app/DataProvider.svelte index 7b5187ffb6..e6629aa3f3 100644 --- a/packages/client/src/components/app/DataProvider.svelte +++ b/packages/client/src/components/app/DataProvider.svelte @@ -126,6 +126,9 @@ } const extendQuery = (defaultQuery, extensions) => { + if (!Object.keys(extensions).length) { + return defaultQuery + } const extended = { [LogicalOperator.AND]: { conditions: [ diff --git a/packages/client/src/components/devtools/DevToolsHeader.svelte b/packages/client/src/components/devtools/DevToolsHeader.svelte index 55b705e717..eca085a88a 100644 --- a/packages/client/src/components/devtools/DevToolsHeader.svelte +++ b/packages/client/src/components/devtools/DevToolsHeader.svelte @@ -1,28 +1,29 @@ diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte index 5d5f06872d..cd6b61af80 100644 --- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte @@ -66,7 +66,7 @@ focus: () => api?.focus?.(), blur: () => api?.blur?.(), isActive: () => api?.isActive?.() ?? false, - onKeyDown: (...params) => api?.onKeyDown(...params), + onKeyDown: (...params) => api?.onKeyDown?.(...params), isReadonly: () => readonly, getType: () => column.schema.type, getValue: () => row[column.name], diff --git a/packages/frontend-core/src/components/grid/cells/NumberCell.svelte b/packages/frontend-core/src/components/grid/cells/NumberCell.svelte index 2acec7d11e..c8ae96ef21 100644 --- a/packages/frontend-core/src/components/grid/cells/NumberCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/NumberCell.svelte @@ -1,3 +1,7 @@ + + - + diff --git a/packages/frontend-core/src/components/grid/cells/RoleCell.svelte b/packages/frontend-core/src/components/grid/cells/RoleCell.svelte new file mode 100644 index 0000000000..82d1e26aa7 --- /dev/null +++ b/packages/frontend-core/src/components/grid/cells/RoleCell.svelte @@ -0,0 +1,45 @@ + + +
+
+ +
+
+ {role?.uiMetadata?.displayName || role?.name || "Unknown role"} +
+
+ + diff --git a/packages/frontend-core/src/components/grid/cells/TextCell.svelte b/packages/frontend-core/src/components/grid/cells/TextCell.svelte index 0cf0ab2004..9275bca3c6 100644 --- a/packages/frontend-core/src/components/grid/cells/TextCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/TextCell.svelte @@ -7,11 +7,13 @@ export let type = "text" export let readonly = false export let api + export let format = null let input let active = false $: editable = focused && !readonly + $: displayValue = format?.(value) ?? value ?? "" const handleChange = e => { onChange(e.target.value) @@ -52,7 +54,7 @@ {:else}
- {value ?? ""} + {displayValue}
{/if} diff --git a/packages/frontend-core/src/components/grid/lib/renderers.js b/packages/frontend-core/src/components/grid/lib/renderers.js index f9e26d920a..70700f9417 100644 --- a/packages/frontend-core/src/components/grid/lib/renderers.js +++ b/packages/frontend-core/src/components/grid/lib/renderers.js @@ -16,6 +16,7 @@ import AttachmentSingleCell from "../cells/AttachmentSingleCell.svelte" import BBReferenceCell from "../cells/BBReferenceCell.svelte" import SignatureCell from "../cells/SignatureCell.svelte" import BBReferenceSingleCell from "../cells/BBReferenceSingleCell.svelte" +import RoleCell from "../cells/RoleCell.svelte" const TypeComponentMap = { [FieldType.STRING]: TextCell, @@ -35,6 +36,9 @@ const TypeComponentMap = { [FieldType.JSON]: JSONCell, [FieldType.BB_REFERENCE]: BBReferenceCell, [FieldType.BB_REFERENCE_SINGLE]: BBReferenceSingleCell, + + // Custom types for UI only + role: RoleCell, } export const getCellRenderer = column => { return ( diff --git a/packages/frontend-core/src/utils/roles.js b/packages/frontend-core/src/utils/roles.js index 1ae9d3ac14..913d452e7c 100644 --- a/packages/frontend-core/src/utils/roles.js +++ b/packages/frontend-core/src/utils/roles.js @@ -7,20 +7,7 @@ const RolePriorities = { [Roles.BASIC]: 2, [Roles.PUBLIC]: 1, } -const RoleColours = { - [Roles.ADMIN]: "var(--spectrum-global-color-static-red-400)", - [Roles.CREATOR]: "var(--spectrum-global-color-static-magenta-600)", - [Roles.POWER]: "var(--spectrum-global-color-static-orange-400)", - [Roles.BASIC]: "var(--spectrum-global-color-static-green-400)", - [Roles.PUBLIC]: "var(--spectrum-global-color-static-blue-400)", -} export const getRolePriority = role => { return RolePriorities[role] ?? 0 } - -export const getRoleColour = roleId => { - return ( - RoleColours[roleId] ?? "var(--spectrum-global-color-static-magenta-400)" - ) -} diff --git a/packages/server/scripts/load/utils.js b/packages/server/scripts/load/utils.js index 1dabdcec9a..57e03c471c 100644 --- a/packages/server/scripts/load/utils.js +++ b/packages/server/scripts/load/utils.js @@ -29,7 +29,7 @@ exports.createApp = async apiKey => { const body = { name, url: `/${name}`, - useTemplate: "true", + useTemplate: true, templateKey: "app/school-admin-panel", templateName: "School Admin Panel", } diff --git a/packages/server/src/api/controllers/application.ts b/packages/server/src/api/controllers/application.ts index 59f67540fe..d8d9d75336 100644 --- a/packages/server/src/api/controllers/application.ts +++ b/packages/server/src/api/controllers/application.ts @@ -23,6 +23,7 @@ import { cache, context, db as dbCore, + docIds, env as envCore, ErrorCode, events, @@ -35,7 +36,6 @@ import { import { USERS_TABLE_SCHEMA, DEFAULT_BB_DATASOURCE_ID } from "../../constants" import { buildDefaultDocs } from "../../db/defaultData/datasource_bb_default" import { removeAppFromUserRoles } from "../../utilities/workerRequests" -import { stringToReadStream } from "../../utilities" import { doesUserHaveLock } from "../../utilities/redis" import { cleanupAutomations } from "../../automations/utils" import { getUniqueRows } from "../../utilities/usageQuota/rows" @@ -54,6 +54,11 @@ import { DuplicateAppResponse, UpdateAppRequest, UpdateAppResponse, + Database, + FieldType, + BBReferenceFieldSubType, + Row, + BBRequest, } from "@budibase/types" import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts" import sdk from "../../sdk" @@ -123,8 +128,7 @@ function checkAppName( } interface AppTemplate { - templateString?: string - useTemplate?: string + useTemplate?: boolean file?: { type?: string path: string @@ -148,14 +152,7 @@ async function createInstance(appId: string, template: AppTemplate) { await createRoutingView() await createAllSearchIndex() - // replicate the template data to the instance DB - // this is currently very hard to test, downloading and importing template files - if (template && template.templateString) { - const { ok } = await db.load(stringToReadStream(template.templateString)) - if (!ok) { - throw "Error loading database dump from memory." - } - } else if (template && template.useTemplate === "true") { + if (template && template.useTemplate) { await sdk.backups.importApp(appId, db, template) } else { // create the users table @@ -243,14 +240,15 @@ export async function fetchAppPackage( async function performAppCreate(ctx: UserCtx) { const apps = (await dbCore.getAllApps({ dev: true })) as App[] - const { - name, - url, - encryptionPassword, - useTemplate, - templateKey, - templateString, - } = ctx.request.body + const { body } = ctx.request + const { name, url, encryptionPassword, templateKey } = body + + let useTemplate + if (typeof body.useTemplate === "string") { + useTemplate = body.useTemplate === "true" + } else if (typeof body.useTemplate === "boolean") { + useTemplate = body.useTemplate + } checkAppName(ctx, apps, name) const appUrl = sdk.applications.getAppUrl({ name, url }) @@ -259,16 +257,15 @@ async function performAppCreate(ctx: UserCtx) { const instanceConfig: AppTemplate = { useTemplate, key: templateKey, - templateString, } - if (ctx.request.files && ctx.request.files.templateFile) { + if (ctx.request.files && ctx.request.files.fileToImport) { instanceConfig.file = { - ...(ctx.request.files.templateFile as any), + ...(ctx.request.files.fileToImport as any), password: encryptionPassword, } - } else if (typeof ctx.request.body.file?.path === "string") { + } else if (typeof body.file?.path === "string") { instanceConfig.file = { - path: ctx.request.body.file?.path, + path: body.file?.path, } } @@ -279,6 +276,10 @@ async function performAppCreate(ctx: UserCtx) { const instance = await createInstance(appId, instanceConfig) const db = context.getAppDB() + if (instanceConfig.useTemplate && !instanceConfig.file) { + await updateUserColumns(appId, db, ctx.user._id!) + } + const newApplication: App = { _id: DocumentType.APP_METADATA, _rev: undefined, @@ -375,21 +376,81 @@ async function performAppCreate(ctx: UserCtx) { }) } -async function creationEvents(request: any, app: App) { +async function updateUserColumns( + appId: string, + db: Database, + toUserId: string +) { + await context.doInAppContext(appId, async () => { + const allTables = await sdk.tables.getAllTables() + const tablesWithUserColumns = [] + for (const table of allTables) { + const userColumns = Object.values(table.schema).filter( + f => + (f.type === FieldType.BB_REFERENCE || + f.type === FieldType.BB_REFERENCE_SINGLE) && + f.subtype === BBReferenceFieldSubType.USER + ) + if (!userColumns.length) { + continue + } + + tablesWithUserColumns.push({ + tableId: table._id!, + columns: userColumns.map(c => c.name), + }) + } + + const docsToUpdate = [] + + for (const { tableId, columns } of tablesWithUserColumns) { + const docs = await db.allDocs( + docIds.getRowParams(tableId, null, { include_docs: true }) + ) + const rows = docs.rows.map(d => d.doc!) + + for (const row of rows) { + let shouldUpdate = false + const updatedColumns = columns.reduce((newColumns, column) => { + if (row[column]) { + shouldUpdate = true + if (Array.isArray(row[column])) { + newColumns[column] = row[column]?.map(() => toUserId) + } else if (row[column]) { + newColumns[column] = toUserId + } + } + return newColumns + }, {}) + + if (shouldUpdate) { + docsToUpdate.push({ + ...row, + ...updatedColumns, + }) + } + } + } + + await db.bulkDocs(docsToUpdate) + }) +} + +async function creationEvents(request: BBRequest, app: App) { let creationFns: ((app: App) => Promise)[] = [] - const body = request.body - if (body.useTemplate === "true") { + const { useTemplate, templateKey, file } = request.body + if (useTemplate === "true") { // from template - if (body.templateKey && body.templateKey !== "undefined") { - creationFns.push(a => events.app.templateImported(a, body.templateKey)) + if (templateKey && templateKey !== "undefined") { + creationFns.push(a => events.app.templateImported(a, templateKey)) } // from file - else if (request.files?.templateFile) { + else if (request.files?.fileToImport) { creationFns.push(a => events.app.fileImported(a)) } // from server file path - else if (request.body.file) { + else if (file) { // explicitly pass in the newly created app id creationFns.push(a => events.app.duplicated(a, app.appId)) } @@ -399,16 +460,14 @@ async function creationEvents(request: any, app: App) { } } - if (!request.duplicate) { - creationFns.push(a => events.app.created(a)) - } + creationFns.push(a => events.app.created(a)) for (let fn of creationFns) { await fn(app) } } -async function appPostCreate(ctx: UserCtx, app: App) { +async function appPostCreate(ctx: UserCtx, app: App) { const tenantId = tenancy.getTenantId() await migrations.backPopulateMigrations({ type: MigrationType.APP, @@ -419,7 +478,7 @@ async function appPostCreate(ctx: UserCtx, app: App) { await creationEvents(ctx.request, app) // app import, template creation and duplication - if (ctx.request.body.useTemplate === "true") { + if (ctx.request.body.useTemplate) { const { rows } = await getUniqueRows([app.appId]) const rowCount = rows ? rows.length : 0 if (rowCount) { diff --git a/packages/server/src/api/controllers/role.ts b/packages/server/src/api/controllers/role.ts index f26b4bae69..1047711983 100644 --- a/packages/server/src/api/controllers/role.ts +++ b/packages/server/src/api/controllers/role.ts @@ -18,9 +18,11 @@ import { UserCtx, UserMetadata, DocumentType, + PermissionLevel, } from "@budibase/types" import { RoleColor, sdk as sharedSdk, helpers } from "@budibase/shared-core" import sdk from "../../sdk" +import { builderSocket } from "../../websockets" const UpdateRolesOptions = { CREATED: "created", @@ -34,11 +36,11 @@ async function removeRoleFromOthers(roleId: string) { let changed = false if (Array.isArray(role.inherits)) { const newInherits = role.inherits.filter( - id => !roles.compareRoleIds(id, roleId) + id => !roles.roleIDsAreEqual(id, roleId) ) changed = role.inherits.length !== newInherits.length role.inherits = newInherits - } else if (role.inherits && roles.compareRoleIds(role.inherits, roleId)) { + } else if (role.inherits && roles.roleIDsAreEqual(role.inherits, roleId)) { role.inherits = roles.BUILTIN_ROLE_IDS.PUBLIC changed = true } @@ -124,6 +126,17 @@ export async function save(ctx: UserCtx) { ctx.throw(400, "Cannot change custom role name") } + // custom roles should always inherit basic - if they don't inherit anything else + if (!inherits && roles.validInherits(allRoles, dbRole?.inherits)) { + inherits = dbRole?.inherits + } else if (!roles.validInherits(allRoles, inherits)) { + inherits = [roles.BUILTIN_ROLE_IDS.BASIC] + } + // assume write permission level for newly created roles + if (isCreate && !permissionId) { + permissionId = PermissionLevel.WRITE + } + const role = new roles.Role(_id, name, permissionId, { displayName: uiMetadata?.displayName || name, description: uiMetadata?.description || "Custom role", @@ -177,6 +190,7 @@ export async function save(ctx: UserCtx) { }, }) } + builderSocket?.emitRoleUpdate(ctx, role) } export async function destroy(ctx: UserCtx) { @@ -216,6 +230,7 @@ export async function destroy(ctx: UserCtx) { ctx.message = `Role ${ctx.params.roleId} deleted successfully` ctx.status = 200 + builderSocket?.emitRoleDeletion(ctx, role) } export async function accessible(ctx: UserCtx) { @@ -223,35 +238,23 @@ export async function accessible(ctx: UserCtx) { if (!roleId) { roleId = roles.BUILTIN_ROLE_IDS.PUBLIC } + // If a custom role is provided in the header, filter out higher level roles + const roleHeader = ctx.header[Header.PREVIEW_ROLE] + if (Array.isArray(roleHeader)) { + ctx.throw(400, `Too many roles specified in ${Header.PREVIEW_ROLE} header`) + } + const isBuilder = ctx.user && sharedSdk.users.isAdminOrBuilder(ctx.user) let roleIds: string[] = [] - if (ctx.user && sharedSdk.users.isAdminOrBuilder(ctx.user)) { + if (!roleHeader && isBuilder) { const appId = context.getAppId() if (appId) { roleIds = await roles.getAllRoleIds(appId) } + } else if (isBuilder && roleHeader) { + roleIds = await roles.getUserRoleIdHierarchy(roleHeader) } else { roleIds = await roles.getUserRoleIdHierarchy(roleId!) } - // If a custom role is provided in the header, filter out higher level roles - const roleHeader = ctx.header?.[Header.PREVIEW_ROLE] as string - if (roleHeader && !Object.keys(roles.BUILTIN_ROLE_IDS).includes(roleHeader)) { - const role = await roles.getRole(roleHeader) - const inherits = role?.inherits - const orderedRoles = roleIds.reverse() - let filteredRoles = [roleHeader] - for (let role of orderedRoles) { - filteredRoles = [role, ...filteredRoles] - if ( - (Array.isArray(inherits) && inherits.includes(role)) || - role === inherits - ) { - break - } - } - filteredRoles.pop() - roleIds = [roleHeader, ...filteredRoles] - } - ctx.body = roleIds.map(roleId => roles.getExternalRoleID(roleId)) } diff --git a/packages/server/src/api/routes/tests/application.spec.ts b/packages/server/src/api/routes/tests/application.spec.ts index fe8250bde5..47b6776610 100644 --- a/packages/server/src/api/routes/tests/application.spec.ts +++ b/packages/server/src/api/routes/tests/application.spec.ts @@ -21,6 +21,7 @@ import tk from "timekeeper" import * as uuid from "uuid" import { structures } from "@budibase/backend-core/tests" import nock from "nock" +import path from "path" describe("/applications", () => { let config = setup.getConfig() @@ -137,11 +138,17 @@ describe("/applications", () => { }) it("creates app from template", async () => { + nock("https://prod-budi-templates.s3-eu-west-1.amazonaws.com") + .get(`/templates/app/agency-client-portal.tar.gz`) + .replyWithFile( + 200, + path.resolve(__dirname, "data", "agency-client-portal.tar.gz") + ) + const app = await config.api.application.create({ name: utils.newid(), useTemplate: "true", - templateKey: "test", - templateString: "{}", + templateKey: "app/agency-client-portal", }) expect(app._id).toBeDefined() expect(events.app.created).toHaveBeenCalledTimes(1) @@ -152,7 +159,7 @@ describe("/applications", () => { const app = await config.api.application.create({ name: utils.newid(), useTemplate: "true", - templateFile: "src/api/routes/tests/data/export.txt", + fileToImport: "src/api/routes/tests/data/export.txt", }) expect(app._id).toBeDefined() expect(events.app.created).toHaveBeenCalledTimes(1) @@ -172,7 +179,7 @@ describe("/applications", () => { const app = await config.api.application.create({ name: utils.newid(), useTemplate: "true", - templateFile: "src/api/routes/tests/data/old-app.txt", + fileToImport: "src/api/routes/tests/data/old-app.txt", }) expect(app._id).toBeDefined() expect(app.navigation).toBeDefined() diff --git a/packages/server/src/api/routes/tests/role.spec.ts b/packages/server/src/api/routes/tests/role.spec.ts index 5668295342..adb83ca793 100644 --- a/packages/server/src/api/routes/tests/role.spec.ts +++ b/packages/server/src/api/routes/tests/role.spec.ts @@ -38,6 +38,26 @@ describe("/roles", () => { _id: dbCore.prefixRoleID(res._id!), }) }) + + it("handle a role with invalid inherits", async () => { + const role = basicRole() + role.inherits = ["not_real", "some_other_not_real"] + + const res = await config.api.roles.save(role, { + status: 200, + }) + expect(res.inherits).toEqual([BUILTIN_ROLE_IDS.BASIC]) + }) + + it("handle a role with no inherits", async () => { + const role = basicRole() + role.inherits = [] + + const res = await config.api.roles.save(role, { + status: 200, + }) + expect(res.inherits).toEqual([BUILTIN_ROLE_IDS.BASIC]) + }) }) describe("update", () => { @@ -149,6 +169,17 @@ describe("/roles", () => { { status: 400, body: { message: LOOP_ERROR } } ) }) + + it("handle updating a role, without its inherits", async () => { + const res = await config.api.roles.save({ + ...basicRole(), + inherits: [BUILTIN_ROLE_IDS.ADMIN], + }) + // remove the roles so that it will default back to DB roles, then save again + delete res.inherits + const updatedRes = await config.api.roles.save(res) + expect(updatedRes.inherits).toEqual([BUILTIN_ROLE_IDS.ADMIN]) + }) }) describe("fetch", () => { @@ -298,6 +329,23 @@ describe("/roles", () => { } ) }) + + it("should fetch preview role correctly even without basic specified", async () => { + const role = await config.api.roles.save(basicRole()) + // have to forcefully delete the inherits from DB - technically can't + // happen anymore - but good test case + await dbCore.getDB(config.appId!).put({ + ...role, + _id: dbCore.prefixRoleID(role._id!), + inherits: [], + }) + await config.withHeaders({ "x-budibase-role": role.name }, async () => { + const res = await config.api.roles.accessible({ + status: 200, + }) + expect(res).toEqual([role.name]) + }) + }) }) describe("accessible - multi-inheritance", () => { diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index fa288081c7..66e931d0ab 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -2087,7 +2087,6 @@ describe.each([ ) tableId = table._id! - // @ts-ignore - until AI implemented const rowValues: Record = { [FieldType.STRING]: generator.guid(), [FieldType.LONGFORM]: generator.paragraph(), diff --git a/packages/server/src/api/routes/tests/screen.spec.ts b/packages/server/src/api/routes/tests/screen.spec.ts index 5dfe3d2a44..894710ca27 100644 --- a/packages/server/src/api/routes/tests/screen.spec.ts +++ b/packages/server/src/api/routes/tests/screen.spec.ts @@ -86,7 +86,6 @@ describe("/screens", () => { status: 200, } ) - // basic and role1 screen expect(res.screens.length).toEqual(screenIds.length) expect(res.screens.map(s => s._id).sort()).toEqual(screenIds.sort()) }) @@ -107,6 +106,25 @@ describe("/screens", () => { screen2._id!, ]) }) + + it("should be able to fetch basic and screen 1 with role1 in role header", async () => { + await config.withHeaders( + { + "x-budibase-role": role1._id!, + }, + async () => { + const res = await config.api.application.getDefinition( + config.prodAppId!, + { + status: 200, + } + ) + const screenIds = [screen._id!, screen1._id!] + expect(res.screens.length).toEqual(screenIds.length) + expect(res.screens.map(s => s._id).sort()).toEqual(screenIds.sort()) + } + ) + }) }) describe("save", () => { diff --git a/packages/server/src/api/routes/utils/validators.ts b/packages/server/src/api/routes/utils/validators.ts index d9dbd96b16..02a1a2d060 100644 --- a/packages/server/src/api/routes/utils/validators.ts +++ b/packages/server/src/api/routes/utils/validators.ts @@ -355,9 +355,7 @@ export function applicationValidator(opts = { isCreate: true }) { _id: OPTIONAL_STRING, _rev: OPTIONAL_STRING, url: OPTIONAL_STRING, - template: Joi.object({ - templateString: OPTIONAL_STRING, - }), + template: Joi.object({}), } const appNameValidator = Joi.string() @@ -390,9 +388,7 @@ export function applicationValidator(opts = { isCreate: true }) { _rev: OPTIONAL_STRING, name: appNameValidator, url: OPTIONAL_STRING, - template: Joi.object({ - templateString: OPTIONAL_STRING, - }).unknown(true), + template: Joi.object({}).unknown(true), snippets: snippetValidator, }).unknown(true) ) diff --git a/packages/server/src/automations/actions.ts b/packages/server/src/automations/actions.ts index e921e569c9..a8b69fa7d7 100644 --- a/packages/server/src/automations/actions.ts +++ b/packages/server/src/automations/actions.ts @@ -18,6 +18,7 @@ import * as loop from "./steps/loop" import * as collect from "./steps/collect" import * as branch from "./steps/branch" import * as triggerAutomationRun from "./steps/triggerAutomationRun" +import * as openai from "./steps/openai" import env from "../environment" import { PluginType, @@ -50,6 +51,7 @@ const ACTION_IMPLS: ActionImplType = { QUERY_ROWS: queryRow.run, COLLECT: collect.run, TRIGGER_AUTOMATION_RUN: triggerAutomationRun.run, + OPENAI: openai.run, // these used to be lowercase step IDs, maintain for backwards compat discord: discord.run, slack: slack.run, @@ -89,21 +91,25 @@ export const BUILTIN_ACTION_DEFINITIONS: Record< // ran at all if (env.SELF_HOSTED) { const bash = require("./steps/bash") - const openai = require("./steps/openai") // @ts-ignore ACTION_IMPLS["EXECUTE_BASH"] = bash.run // @ts-ignore BUILTIN_ACTION_DEFINITIONS["EXECUTE_BASH"] = bash.definition - // @ts-ignore - ACTION_IMPLS.OPENAI = openai.run - BUILTIN_ACTION_DEFINITIONS.OPENAI = openai.definition } export async function getActionDefinitions() { if (await features.flags.isEnabled(FeatureFlag.AUTOMATION_BRANCHING)) { BUILTIN_ACTION_DEFINITIONS["BRANCH"] = branch.definition } + if ( + env.SELF_HOSTED || + (await features.flags.isEnabled(FeatureFlag.BUDIBASE_AI)) || + (await features.flags.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) + ) { + BUILTIN_ACTION_DEFINITIONS["OPENAI"] = openai.definition + } + const actionDefinitions = BUILTIN_ACTION_DEFINITIONS if (env.SELF_HOSTED) { const plugins = await sdk.plugins.fetch(PluginType.AUTOMATION) diff --git a/packages/server/src/middleware/currentapp.ts b/packages/server/src/middleware/currentapp.ts index d616377298..a8ef8bb251 100644 --- a/packages/server/src/middleware/currentapp.ts +++ b/packages/server/src/middleware/currentapp.ts @@ -56,22 +56,9 @@ export default async (ctx: UserCtx, next: any) => { ctx.request && (ctx.request.headers[constants.Header.PREVIEW_ROLE] as string) if (isBuilder && isDevApp && roleHeader) { - // Ensure the role is valid by ensuring a definition exists - try { - if (roleHeader) { - const role = await roles.getRole(roleHeader) - if (role) { - roleId = roleHeader - - // Delete admin and builder flags so that the specified role is honoured - ctx.user = users.removePortalUserPermissions( - ctx.user - ) as ContextUser - } - } - } catch (error) { - // Swallow error and do nothing - } + roleId = roleHeader + // Delete admin and builder flags so that the specified role is honoured + ctx.user = users.removePortalUserPermissions(ctx.user) as ContextUser } } diff --git a/packages/server/src/tests/utilities/api/application.ts b/packages/server/src/tests/utilities/api/application.ts index 516af5c973..9dabc8cfe8 100644 --- a/packages/server/src/tests/utilities/api/application.ts +++ b/packages/server/src/tests/utilities/api/application.ts @@ -17,8 +17,8 @@ export class ApplicationAPI extends TestAPI { app: CreateAppRequest, expectations?: Expectations ): Promise => { - const files = app.templateFile ? { templateFile: app.templateFile } : {} - delete app.templateFile + const files = app.fileToImport ? { fileToImport: app.fileToImport } : {} + delete app.fileToImport return await this._post("/api/applications", { fields: app, files, diff --git a/packages/server/src/tests/utilities/structures.ts b/packages/server/src/tests/utilities/structures.ts index 1198dc514d..b63d6d1a78 100644 --- a/packages/server/src/tests/utilities/structures.ts +++ b/packages/server/src/tests/utilities/structures.ts @@ -8,31 +8,31 @@ import { } from "../../automations" import { AIOperationEnum, + AutoFieldSubType, Automation, AutomationActionStepId, + AutomationEventType, AutomationResults, AutomationStatus, AutomationStep, AutomationStepType, AutomationTrigger, AutomationTriggerStepId, + BBReferenceFieldSubType, + CreateViewRequest, Datasource, + FieldSchema, FieldType, + INTERNAL_TABLE_SOURCE_ID, + JsonFieldSubType, + LoopStepType, + Query, + Role, SourceName, Table, - INTERNAL_TABLE_SOURCE_ID, TableSourceType, - Query, Webhook, WebhookActionType, - AutomationEventType, - LoopStepType, - FieldSchema, - BBReferenceFieldSubType, - JsonFieldSubType, - AutoFieldSubType, - Role, - CreateViewRequest, } from "@budibase/types" import { LoopInput } from "../../definitions/automations" import { merge } from "lodash" @@ -439,7 +439,7 @@ export function updateRowAutomationWithFilters( appId: string, tableId: string ): Automation { - const automation: Automation = { + return { name: "updateRowWithFilters", type: "automation", appId, @@ -472,7 +472,6 @@ export function updateRowAutomationWithFilters( }, }, } - return automation } export function basicAutomationResults( @@ -606,7 +605,6 @@ export function fullSchemaWithoutLinks({ }): { [type in Exclude]: FieldSchema & { type: type } } { - // @ts-ignore - until AI implemented return { [FieldType.STRING]: { name: "string", diff --git a/packages/server/src/utilities/index.ts b/packages/server/src/utilities/index.ts index ce6f2345ca..129137a72e 100644 --- a/packages/server/src/utilities/index.ts +++ b/packages/server/src/utilities/index.ts @@ -2,9 +2,6 @@ import env from "../environment" import { context } from "@budibase/backend-core" import { generateMetadataID } from "../db/utils" import { Document } from "@budibase/types" -import stream from "stream" - -const Readable = stream.Readable export function wait(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) @@ -98,15 +95,6 @@ export function escapeDangerousCharacters(string: string) { .replace(/[\t]/g, "\\t") } -export function stringToReadStream(string: string) { - return new Readable({ - read() { - this.push(string) - this.push(null) - }, - }) -} - export function formatBytes(bytes: string) { const units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] const byteIncrements = 1024 diff --git a/packages/server/src/websockets/builder.ts b/packages/server/src/websockets/builder.ts index 702a941095..cf92d68ef3 100644 --- a/packages/server/src/websockets/builder.ts +++ b/packages/server/src/websockets/builder.ts @@ -11,6 +11,7 @@ import { Screen, App, Automation, + Role, } from "@budibase/types" import { gridSocket } from "./index" import { clearLock, updateLock } from "../utilities/redis" @@ -100,6 +101,20 @@ export default class BuilderSocket extends BaseSocket { }) } + emitRoleUpdate(ctx: any, role: Role) { + this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.RoleChange, { + id: role._id, + role, + }) + } + + emitRoleDeletion(ctx: any, role: Role) { + this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.RoleChange, { + id: role._id, + role: null, + }) + } + emitTableUpdate(ctx: any, table: Table, options?: EmitOptions) { if (table.sourceId == null || table.sourceId === "") { throw new Error("Table sourceId is not set") diff --git a/packages/shared-core/src/constants/index.ts b/packages/shared-core/src/constants/index.ts index 6c49625937..11bb79c8d3 100644 --- a/packages/shared-core/src/constants/index.ts +++ b/packages/shared-core/src/constants/index.ts @@ -97,6 +97,7 @@ export enum BuilderSocketEvent { SelectResource = "SelectResource", AppPublishChange = "AppPublishChange", AutomationChange = "AutomationChange", + RoleChange = "RoleChange", } export const SocketSessionTTL = 60 diff --git a/packages/types/src/api/web/application.ts b/packages/types/src/api/web/application.ts index bb4d8c7f72..57422ceabc 100644 --- a/packages/types/src/api/web/application.ts +++ b/packages/types/src/api/web/application.ts @@ -7,10 +7,8 @@ export interface CreateAppRequest { useTemplate?: string templateName?: string templateKey?: string - templateFile?: string - includeSampleData?: boolean + fileToImport?: string encryptionPassword?: string - templateString?: string file?: { path: string } } diff --git a/packages/types/src/documents/app/automation/schema.ts b/packages/types/src/documents/app/automation/schema.ts index 599256694d..b8a19b7b45 100644 --- a/packages/types/src/documents/app/automation/schema.ts +++ b/packages/types/src/documents/app/automation/schema.ts @@ -126,16 +126,16 @@ export type ActionImplementations = { n8nStepInputs, ExternalAppStepOutputs > + [AutomationActionStepId.OPENAI]: ActionImplementation< + OpenAIStepInputs, + OpenAIStepOutputs + > } & (T extends "self" ? { [AutomationActionStepId.EXECUTE_BASH]: ActionImplementation< BashStepInputs, BashStepOutputs > - [AutomationActionStepId.OPENAI]: ActionImplementation< - OpenAIStepInputs, - OpenAIStepOutputs - > } : {}) diff --git a/packages/worker/src/api/controllers/global/configs.ts b/packages/worker/src/api/controllers/global/configs.ts index e6e80ff3a5..750b02088c 100644 --- a/packages/worker/src/api/controllers/global/configs.ts +++ b/packages/worker/src/api/controllers/global/configs.ts @@ -44,9 +44,7 @@ const getEventFns = async (config: Config, existing?: Config) => { fns.push(events.email.SMTPCreated) } else if (isAIConfig(config)) { fns.push(() => events.ai.AIConfigCreated) - fns.push(() => - pro.quotas.updateCustomAIConfigCount(Object.keys(config.config).length) - ) + fns.push(() => pro.quotas.addCustomAIConfig()) } else if (isGoogleConfig(config)) { fns.push(() => events.auth.SSOCreated(ConfigType.GOOGLE)) if (config.config.activated) { @@ -85,9 +83,6 @@ const getEventFns = async (config: Config, existing?: Config) => { fns.push(events.email.SMTPUpdated) } else if (isAIConfig(config)) { fns.push(() => events.ai.AIConfigUpdated) - fns.push(() => - pro.quotas.updateCustomAIConfigCount(Object.keys(config.config).length) - ) } else if (isGoogleConfig(config)) { fns.push(() => events.auth.SSOUpdated(ConfigType.GOOGLE)) if (!existing.config.activated && config.config.activated) { @@ -253,7 +248,7 @@ export async function save(ctx: UserCtx) { if (existingConfig) { await verifyAIConfig(config, existingConfig) } - await pro.quotas.updateCustomAIConfigCount(Object.keys(config).length) + await pro.quotas.addCustomAIConfig() break } } catch (err: any) { @@ -342,29 +337,43 @@ export async function find(ctx: UserCtx) { let scopedConfig = await configs.getConfig(type) if (scopedConfig) { - if (type === ConfigType.OIDC_LOGOS) { - enrichOIDCLogos(scopedConfig) - } - - if (type === ConfigType.AI) { - await pro.sdk.ai.enrichAIConfig(scopedConfig) - // Strip out the API Keys from the response so they don't show in the UI - for (const key in scopedConfig.config) { - if (scopedConfig.config[key].apiKey) { - scopedConfig.config[key].apiKey = PASSWORD_REPLACEMENT - } - } - } - ctx.body = scopedConfig + await handleConfigType(type, scopedConfig) + } else if (type === ConfigType.AI) { + scopedConfig = { config: {} } as AIConfig + await handleAIConfig(scopedConfig) } else { - // don't throw an error, there simply is nothing to return + // If no config found and not AI type, just return an empty body ctx.body = {} + return } + + ctx.body = scopedConfig } catch (err: any) { ctx.throw(err?.status || 400, err) } } +async function handleConfigType(type: ConfigType, config: Config) { + if (type === ConfigType.OIDC_LOGOS) { + enrichOIDCLogos(config) + } else if (type === ConfigType.AI) { + await handleAIConfig(config) + } +} + +async function handleAIConfig(config: AIConfig) { + await pro.sdk.ai.enrichAIConfig(config) + stripApiKeys(config) +} + +function stripApiKeys(config: AIConfig) { + for (const key in config?.config) { + if (config.config[key].apiKey) { + config.config[key].apiKey = PASSWORD_REPLACEMENT + } + } +} + export async function publicOidc(ctx: Ctx) { try { // Find the config with the most granular scope based on context @@ -508,6 +517,9 @@ export async function destroy(ctx: UserCtx) { try { await db.remove(id, rev) await cache.destroy(cache.CacheKey.CHECKLIST) + if (id === configs.generateConfigID(ConfigType.AI)) { + await pro.quotas.removeCustomAIConfig() + } ctx.body = { message: "Config deleted successfully" } } catch (err: any) { ctx.throw(err.status, err) diff --git a/packages/worker/src/api/controllers/global/tests/configs.spec.ts b/packages/worker/src/api/controllers/global/tests/configs.spec.ts index 9091f29247..857f499dcc 100644 --- a/packages/worker/src/api/controllers/global/tests/configs.spec.ts +++ b/packages/worker/src/api/controllers/global/tests/configs.spec.ts @@ -13,10 +13,6 @@ describe("Global configs controller", () => { await config.afterAll() }) - afterEach(() => { - jest.resetAllMocks() - }) - it("Should strip secrets when pulling AI config", async () => { const data = structures.configs.ai() await config.api.configs.saveConfig(data) diff --git a/yarn.lock b/yarn.lock index 1198e98ad6..5dfef16e0e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2343,6 +2343,18 @@ enabled "2.0.x" kuler "^2.0.0" +"@dagrejs/dagre@1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@dagrejs/dagre/-/dagre-1.1.4.tgz#66f9c0e2b558308f2c268f60e2c28f22ee17e339" + integrity sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg== + dependencies: + "@dagrejs/graphlib" "2.2.4" + +"@dagrejs/graphlib@2.2.4": + version "2.2.4" + resolved "https://registry.yarnpkg.com/@dagrejs/graphlib/-/graphlib-2.2.4.tgz#d77bfa9ff49e2307c0c6e6b8b26b5dd3c05816c4" + integrity sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw== + "@datadog/native-appsec@7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@datadog/native-appsec/-/native-appsec-7.0.0.tgz#a380174dd49aef2d9bb613a0ec8ead6dc7822095" @@ -5093,6 +5105,11 @@ resolved "https://registry.yarnpkg.com/@spectrum-css/vars/-/vars-4.3.1.tgz#d333fa41909f691c8750b5c15ad9ba029df2248e" integrity sha512-rX6Iasu9BsFMVgEN0vGRPm9dmSxva+IK/uqQAa9HM0lliwqUiFrJxrFXHHpiAgNuux/U4srEJwbSpGzfF+CegQ== +"@svelte-put/shortcut@^3.1.0": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@svelte-put/shortcut/-/shortcut-3.1.1.tgz#aba4d7407024d5cff38727e12925c8f81e877079" + integrity sha512-2L5EYTZXiaKvbEelVkg5znxqvfZGZai3m97+cAiUBhLZwXnGtviTDpHxOoZBsqz41szlfRMcamW/8o0+fbW3ZQ== + "@sveltejs/vite-plugin-svelte@1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-1.4.0.tgz#412a735de489ca731d0c780c2b410f45dd95b392" @@ -5451,6 +5468,45 @@ dependencies: "@types/node" "*" +"@types/d3-color@*": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" + integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== + +"@types/d3-drag@^3.0.7": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02" + integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-interpolate@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" + integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== + dependencies: + "@types/d3-color" "*" + +"@types/d3-selection@*", "@types/d3-selection@^3.0.10": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.10.tgz#98cdcf986d0986de6912b5892e7c015a95ca27fe" + integrity sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg== + +"@types/d3-transition@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.8.tgz#677707f5eed5b24c66a1918cde05963021351a8f" + integrity sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-zoom@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b" + integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw== + dependencies: + "@types/d3-interpolate" "*" + "@types/d3-selection" "*" + "@types/debug@*": version "4.1.7" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82" @@ -6578,6 +6634,28 @@ loupe "^3.1.1" tinyrainbow "^1.2.0" +"@xyflow/svelte@^0.1.18": + version "0.1.18" + resolved "https://registry.yarnpkg.com/@xyflow/svelte/-/svelte-0.1.18.tgz#ba2f9f72adc64ff6f71a5ad03cf759af8d7c9748" + integrity sha512-P2td3XcvMk36pnhyRUAXtmwfd7sv1KAHVF29YZUNndYlgxG98vwj1UoyyuXwCHIiyu82GgowaTppHCNPXsvNSg== + dependencies: + "@svelte-put/shortcut" "^3.1.0" + "@xyflow/system" "0.0.41" + classcat "^5.0.4" + +"@xyflow/system@0.0.41": + version "0.0.41" + resolved "https://registry.yarnpkg.com/@xyflow/system/-/system-0.0.41.tgz#6c314b2bbca594aec4d7cdb56efb003be6727d21" + integrity sha512-XAjs8AUA0YMfYD91cT6pLGALwbsPS64s2WBHyULqL1m0gTqXqaUSLK1P7qA/Q8HecN0RFbqlM2tPO8bmZXP0YQ== + dependencies: + "@types/d3-drag" "^3.0.7" + "@types/d3-selection" "^3.0.10" + "@types/d3-transition" "^3.0.8" + "@types/d3-zoom" "^3.0.8" + d3-drag "^3.0.0" + d3-selection "^3.0.0" + d3-zoom "^3.0.0" + "@yarnpkg/lockfile@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" @@ -8244,6 +8322,11 @@ cjs-module-lexer@^1.0.0, cjs-module-lexer@^1.2.2: resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz#6c370ab19f8a3394e318fe682686ec0ac684d107" integrity sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ== +classcat@^5.0.4: + version "5.0.5" + resolved "https://registry.yarnpkg.com/classcat/-/classcat-5.0.5.tgz#8c209f359a93ac302404a10161b501eba9c09c77" + integrity sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w== + clean-stack@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" @@ -9159,6 +9242,68 @@ curlconverter@3.21.0: string.prototype.startswith "^1.0.0" yamljs "^0.3.0" +"d3-color@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" + integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== + +"d3-dispatch@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e" + integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg== + +"d3-drag@2 - 3", d3-drag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba" + integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg== + dependencies: + d3-dispatch "1 - 3" + d3-selection "3" + +"d3-ease@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" + integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== + +"d3-interpolate@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" + integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== + dependencies: + d3-color "1 - 3" + +"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" + integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== + +"d3-timer@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" + integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== + +"d3-transition@2 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f" + integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w== + dependencies: + d3-color "1 - 3" + d3-dispatch "1 - 3" + d3-ease "1 - 3" + d3-interpolate "1 - 3" + d3-timer "1 - 3" + +d3-zoom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3" + integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw== + dependencies: + d3-dispatch "1 - 3" + d3-drag "2 - 3" + d3-interpolate "1 - 3" + d3-selection "2 - 3" + d3-transition "2 - 3" + dargs@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/dargs/-/dargs-7.0.0.tgz#04015c41de0bcb69ec84050f3d9be0caf8d6d5cc"