diff --git a/packages/backend-core/src/environment.ts b/packages/backend-core/src/environment.ts index 4e93e8d9ee..7d3a9f18f5 100644 --- a/packages/backend-core/src/environment.ts +++ b/packages/backend-core/src/environment.ts @@ -83,7 +83,7 @@ function getPackageJsonFields(): { if (isDev() && !isTest()) { try { const lerna = getParentFile("lerna.json") - localVersion = lerna.version + localVersion = `${lerna.version}+local` } catch { // } 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 ee6e325921..e979b2b684 100644 --- a/packages/bbui/src/List/ListItem.svelte +++ b/packages/bbui/src/List/ListItem.svelte @@ -1,5 +1,6 @@ <script> import Icon from "../Icon/Icon.svelte" + import StatusLight from "../StatusLight/StatusLight.svelte" export let icon = null export let iconColor = null @@ -15,12 +16,14 @@ href={url} class="list-item" class:hoverable={hoverable || url != null} - class:selected class:large={!!subtitle} on:click + class:selected > <div class="list-item__left"> - {#if icon} + {#if icon === "StatusLight"} + <StatusLight square size="L" color={iconColor} /> + {:else if icon} <div class="list-item__icon"> <Icon name={icon} color={iconColor} size={subtitle ? "XL" : "M"} /> </div> @@ -48,7 +51,7 @@ <style> .list-item { - padding: var(--spacing-m); + padding: var(--spacing-m) var(--spacing-l); background: var(--spectrum-global-color-gray-75); display: flex; flex-direction: row; @@ -72,10 +75,16 @@ border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; } - .list-item.hoverable:not(.selected):hover { + .hoverable:hover { + cursor: pointer; + } + .hoverable:not(.selected):hover { background: var(--spectrum-global-color-gray-200); border-color: var(--spectrum-global-color-gray-400); } + .selected { + background: var(--spectrum-global-color-blue-100); + } /* Selection is only meant for standalone list items (non stacked) so we just set a fixed border radius */ .list-item.selected { @@ -121,7 +130,7 @@ display: flex; flex-direction: row; align-items: center; - gap: var(--spacing-s); + gap: var(--spacing-m); } .list-item.large .list-item__left, .list-item.large .list-item__right { diff --git a/packages/bbui/src/index.js b/packages/bbui/src/index.js index 083b4ecfd5..3efafed156 100644 --- a/packages/bbui/src/index.js +++ b/packages/bbui/src/index.js @@ -21,6 +21,7 @@ export { default as EnvDropdown } from "./Form/EnvDropdown.svelte" export { default as Multiselect } from "./Form/Multiselect.svelte" export { default as Search } from "./Form/Search.svelte" export { default as RichTextField } from "./Form/RichTextField.svelte" +export { default as FieldLabel } from "./Form/FieldLabel.svelte" export { default as Slider } from "./Form/Slider.svelte" export { default as File } from "./Form/File.svelte" diff --git a/packages/builder/package.json b/packages/builder/package.json index aec0b509f0..46e454f5b9 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -59,12 +59,14 @@ "@codemirror/state": "^6.2.0", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.11.2", + "@dagrejs/dagre": "1.1.4", "@fontsource/source-sans-pro": "^5.0.3", "@fortawesome/fontawesome-svg-core": "^6.4.2", "@fortawesome/free-brands-svg-icons": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^6.4.2", "@spectrum-css/page": "^3.0.1", "@spectrum-css/vars": "^3.0.1", + "@xyflow/svelte": "^0.1.18", "@zerodevx/svelte-json-view": "^1.0.7", "codemirror": "^5.65.16", "cron-parser": "^4.9.0", diff --git a/packages/builder/src/components/automation/AutomationPanel/CreateAutomationModal.svelte b/packages/builder/src/components/automation/AutomationPanel/CreateAutomationModal.svelte index 365d3d358f..35fdba970a 100644 --- a/packages/builder/src/components/automation/AutomationPanel/CreateAutomationModal.svelte +++ b/packages/builder/src/components/automation/AutomationPanel/CreateAutomationModal.svelte @@ -1,4 +1,5 @@ <script> + import { goto } from "@roxi/routify" import { automationStore } from "stores/builder" import { notifications, @@ -32,11 +33,12 @@ triggerVal.stepId, triggerVal ) - await automationStore.actions.create(name, trigger) + const automation = await automationStore.actions.create(name, trigger) if (triggerVal.stepId === TriggerStepID.WEBHOOK) { webhookModal.show() } notifications.success(`Automation ${name} created`) + $goto(`../automation/${automation._id}`) } catch (error) { notifications.error("Error creating automation") } diff --git a/packages/builder/src/components/backend/DataTable/buttons/EditRolesButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/EditRolesButton.svelte deleted file mode 100644 index 3b31de170c..0000000000 --- a/packages/builder/src/components/backend/DataTable/buttons/EditRolesButton.svelte +++ /dev/null @@ -1,15 +0,0 @@ -<script> - import { Button, Modal } from "@budibase/bbui" - import EditRolesModal from "../modals/EditRoles.svelte" - - let modal -</script> - -<div> - <Button secondary icon="UsersLock" on:click on:click={modal.show}> - Edit roles - </Button> -</div> -<Modal bind:this={modal} on:show on:hide> - <EditRolesModal /> -</Modal> diff --git a/packages/builder/src/components/backend/DataTable/buttons/ManageAccessButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/ManageAccessButton.svelte index 22a55ecc03..2826d8d986 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/ManageAccessButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/ManageAccessButton.svelte @@ -1,31 +1,195 @@ <script> - import { ActionButton } from "@budibase/bbui" - import { permissions } from "stores/builder" - import ManageAccessModal from "../modals/ManageAccessModal.svelte" + import { + ActionButton, + Input, + Select, + Label, + List, + ListItem, + notifications, + } from "@budibase/bbui" + import { permissions as permissionsStore, roles } from "stores/builder" import DetailPopover from "components/common/DetailPopover.svelte" - import EditRolesButton from "./EditRolesButton.svelte" + import { PermissionSource } from "@budibase/types" + import { capitalise } from "helpers" + import InfoDisplay from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte" + import { Roles } from "constants/backend" export let resourceId - let resourcePermissions + const inheritedRoleId = "inherited" + const builtins = [Roles.ADMIN, Roles.POWER, Roles.BASIC, Roles.PUBLIC] + + let permissions let showPopover = true + let dependantsInfoMessage $: fetchPermissions(resourceId) + $: loadDependantInfo(resourceId) + $: roleMismatch = checkRoleMismatch(permissions) + $: selectedRole = roleMismatch ? null : permissions?.[0]?.value + $: readableRole = selectedRole + ? $roles.find(x => x._id === selectedRole)?.uiMetadata.displayName + : null + $: buttonLabel = readableRole ? `Access: ${readableRole}` : "Access" + $: highlight = roleMismatch || selectedRole === Roles.PUBLIC + + $: builtInRoles = builtins.map(roleId => $roles.find(x => x._id === roleId)) + $: customRoles = $roles + .filter(x => !builtins.includes(x._id)) + .slice() + .toSorted((a, b) => { + const aName = a.uiMetadata.displayName || a.name + const bName = b.uiMetadata.displayName || b.name + return aName < bName ? -1 : 1 + }) const fetchPermissions = async id => { - resourcePermissions = await permissions.forResourceDetailed(id) + const res = await permissionsStore.forResourceDetailed(id) + permissions = Object.entries(res?.permissions || {}).map(([perm, info]) => { + let enriched = { + permission: perm, + value: + info.permissionType === PermissionSource.INHERITED + ? inheritedRoleId + : info.role, + options: [...$roles], + } + if (info.inheritablePermission) { + enriched.options.unshift({ + _id: inheritedRoleId, + name: `Inherit (${ + $roles.find(x => x._id === info.inheritablePermission).name + })`, + }) + } + return enriched + }) + } + + const checkRoleMismatch = permissions => { + if (!permissions || permissions.length < 2) { + return false + } + return ( + permissions[0].value !== permissions[1].value || + permissions[0].value === inheritedRoleId + ) + } + + const loadDependantInfo = async resourceId => { + const dependantsInfo = await permissionsStore.getDependantsInfo(resourceId) + const resourceByType = dependantsInfo?.resourceByType + if (resourceByType) { + const total = Object.values(resourceByType).reduce((p, c) => p + c, 0) + let resourceDisplay = + Object.keys(resourceByType).length === 1 && resourceByType.view + ? "view" + : "resource" + + if (total === 1) { + dependantsInfoMessage = `1 ${resourceDisplay} is inheriting this access` + } else if (total > 1) { + dependantsInfoMessage = `${total} ${resourceDisplay}s are inheriting this access` + } else { + dependantsInfoMessage = null + } + } else { + dependantsInfoMessage = null + } + } + + const changePermission = async role => { + try { + await permissionsStore.save({ + level: "read", + role, + resource: resourceId, + }) + await permissionsStore.save({ + level: "write", + role, + resource: resourceId, + }) + await fetchPermissions(resourceId) + notifications.success("Updated permissions") + } catch (error) { + console.error(error) + notifications.error("Error updating permissions") + } } </script> -<DetailPopover title="Manage access" {showPopover}> +<DetailPopover title="Select access role" {showPopover}> <svelte:fragment slot="anchor" let:open> - <ActionButton icon="LockClosed" selected={open} quiet>Access</ActionButton> + <ActionButton + icon="LockClosed" + selected={open || highlight} + quiet + accentColor={highlight ? "#ff0000" : null} + > + {buttonLabel} + </ActionButton> </svelte:fragment> - {#if resourcePermissions} - <ManageAccessModal {resourceId} permissions={resourcePermissions} /> + + {#if roleMismatch} + <div class="row"> + <Label extraSmall grey>Level</Label> + <Label extraSmall grey>Role</Label> + {#each permissions as permission} + <Input value={capitalise(permission.permission)} disabled /> + <Select + placeholder={false} + value={permission.value} + on:change={e => changePermission(e.detail)} + disabled + options={permission.options} + getOptionLabel={x => x.name} + getOptionValue={x => x._id} + /> + {/each} + </div> + <InfoDisplay + error + icon="Alert" + body="Your previous configuration is shown above.<br/> Please choose a single role for read and write access." + /> + {/if} + + <List> + {#each builtInRoles as role} + <ListItem + title={role.uiMetadata.displayName} + subtitle={role.uiMetadata.description} + hoverable + selected={selectedRole === role._id} + icon="StatusLight" + iconColor={role.uiMetadata.color} + on:click={() => changePermission(role._id)} + /> + {/each} + {#each customRoles as role} + <ListItem + title={role.uiMetadata.displayName} + subtitle={role.uiMetadata.description} + hoverable + selected={selectedRole === role._id} + icon="StatusLight" + iconColor={role.uiMetadata.color} + on:click={() => changePermission(role._id)} + /> + {/each} + </List> + + {#if dependantsInfoMessage} + <InfoDisplay info body={dependantsInfoMessage} /> {/if} - <EditRolesButton - on:show={() => (showPopover = false)} - on:hide={() => (showPopover = true)} - /> </DetailPopover> + +<style> + .row { + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: var(--spacing-s); + } +</style> 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") } </script> 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 @@ <DetailPopover title="Generate" bind:this={popover}> <svelte:fragment slot="anchor" let:open> - <ActionButton selected={open}> + <ActionButton quiet selected={open}> <div class="center"> <img height={16} alt="magic wand" src={MagicWand} /> 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") } </script> 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 @@ -<script> - import { - keepOpen, - ModalContent, - Select, - Input, - Button, - notifications, - } from "@budibase/bbui" - import { onMount } from "svelte" - import { API } from "api" - import ErrorsBox from "components/common/ErrorsBox.svelte" - import { roles } from "stores/builder" - - const BASE_ROLE = { _id: "", inherits: "BASIC", permissionId: "write" } - - let basePermissions = [] - let selectedRole = BASE_ROLE - let errors = [] - let builtInRoles = ["Admin", "Power", "Basic", "Public"] - let validRegex = /^[a-zA-Z0-9_]*$/ - // Don't allow editing of public role - $: editableRoles = $roles.filter(role => role._id !== "PUBLIC") - $: selectedRoleId = selectedRole._id - $: otherRoles = editableRoles.filter(role => role._id !== selectedRoleId) - $: isCreating = selectedRoleId == null || selectedRoleId === "" - - $: roleNameError = getRoleNameError(selectedRole.name) - - $: valid = - selectedRole.name && - selectedRole.inherits && - selectedRole.permissionId && - !builtInRoles.includes(selectedRole.name) - - $: shouldDisableRoleInput = - builtInRoles.includes(selectedRole.name) && - selectedRole.name?.toLowerCase() === selectedRoleId?.toLowerCase() - - const fetchBasePermissions = async () => { - try { - basePermissions = await API.getBasePermissions() - } catch (error) { - notifications.error("Error fetching base permission options") - basePermissions = [] - } - } - - // Changes the selected role - const changeRole = event => { - const id = event?.detail - const role = $roles.find(role => role._id === id) - if (role) { - selectedRole = { - ...role, - inherits: role.inherits ?? "", - permissionId: role.permissionId ?? "", - } - } else { - selectedRole = BASE_ROLE - } - errors = [] - } - - // Saves or creates the selected role - const saveRole = async () => { - errors = [] - - // Clean up empty strings - const keys = ["_id", "inherits", "permissionId"] - keys.forEach(key => { - if (selectedRole[key] === "") { - delete selectedRole[key] - } - }) - - // Validation - if (!selectedRole.name || selectedRole.name.trim() === "") { - errors.push({ message: "Please enter a role name" }) - } - if (!selectedRole.permissionId) { - errors.push({ message: "Please choose permissions" }) - } - if (errors.length) { - return keepOpen - } - - // Save/create the role - try { - await roles.save(selectedRole) - notifications.success("Role saved successfully") - } catch (error) { - notifications.error(`Error saving role - ${error.message}`) - return keepOpen - } - } - - // Deletes the selected role - const deleteRole = async () => { - try { - await roles.delete(selectedRole) - changeRole() - notifications.success("Role deleted successfully") - } catch (error) { - notifications.error(`Error deleting role - ${error.message}`) - return false - } - } - - const getRoleNameError = name => { - const hasUniqueRoleName = !otherRoles - ?.map(role => role.name) - ?.includes(name) - const invalidRoleName = !validRegex.test(name) - if (!hasUniqueRoleName) { - return "Select a unique role name." - } else if (invalidRoleName) { - return "Please enter a role name consisting of only alphanumeric symbols and underscores" - } - } - - onMount(fetchBasePermissions) -</script> - -<ModalContent - title="Edit Roles" - confirmText={isCreating ? "Create" : "Save"} - onConfirm={saveRole} - disabled={!valid || roleNameError} -> - {#if errors.length} - <ErrorsBox {errors} /> - {/if} - <Select - thin - secondary - label="Role" - value={selectedRoleId} - on:change={changeRole} - options={editableRoles} - placeholder="Create new role" - getOptionValue={role => role._id} - getOptionLabel={role => role.name} - /> - {#if selectedRole} - <Input - label="Name" - bind:value={selectedRole.name} - disabled={!!selectedRoleId} - error={roleNameError} - /> - <Select - label="Inherits Role" - bind:value={selectedRole.inherits} - options={selectedRole._id === "BASIC" ? $roles : otherRoles} - getOptionValue={role => role._id} - getOptionLabel={role => role.name} - disabled={shouldDisableRoleInput} - /> - <Select - label="Base Permissions" - bind:value={selectedRole.permissionId} - options={basePermissions} - getOptionValue={x => x._id} - getOptionLabel={x => x.name} - disabled={shouldDisableRoleInput} - /> - {/if} - <div slot="footer"> - {#if !isCreating && !builtInRoles.includes(selectedRole.name)} - <Button warning on:click={deleteRole}>Delete</Button> - {/if} - </div> -</ModalContent> 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 @@ -<script> - import { PermissionSource } from "@budibase/types" - import { roles, permissions as permissionsStore } from "stores/builder" - import { - Label, - Input, - Select, - notifications, - Body, - Icon, - } from "@budibase/bbui" - import { capitalise } from "helpers" - import { get } from "svelte/store" - - export let resourceId - export let permissions - - const inheritedRoleId = "inherited" - - let dependantsInfoMessage - - $: loadDependantInfo(resourceId) - $: computedPermissions = Object.entries(permissions.permissions).reduce( - (p, [level, roleInfo]) => { - p[level] = { - selectedValue: - roleInfo.permissionType === PermissionSource.INHERITED - ? inheritedRoleId - : roleInfo.role, - options: [...$roles], - } - if (roleInfo.inheritablePermission) { - p[level].inheritOption = roleInfo.inheritablePermission - p[level].options.unshift({ - _id: inheritedRoleId, - name: `Inherit (${ - get(roles).find(x => x._id === roleInfo.inheritablePermission).name - })`, - }) - } - return p - }, - {} - ) - - async function changePermission(level, role) { - try { - if (role === inheritedRoleId) { - await permissionsStore.remove({ - level, - role, - resource: resourceId, - }) - } else { - await permissionsStore.save({ - level, - role, - resource: resourceId, - }) - } - - // Show updated permissions in UI: REMOVE - permissions = await permissionsStore.forResourceDetailed(resourceId) - notifications.success("Updated permissions") - } catch (error) { - notifications.error("Error updating permissions") - } - } - - async function loadDependantInfo(resourceId) { - const dependantsInfo = await permissionsStore.getDependantsInfo(resourceId) - const resourceByType = dependantsInfo?.resourceByType - if (resourceByType) { - const total = Object.values(resourceByType).reduce((p, c) => p + c, 0) - let resourceDisplay = - Object.keys(resourceByType).length === 1 && resourceByType.view - ? "view" - : "resource" - - if (total === 1) { - dependantsInfoMessage = `1 ${resourceDisplay} is inheriting this access.` - } else if (total > 1) { - dependantsInfoMessage = `${total} ${resourceDisplay}s are inheriting this access.` - } - } - } -</script> - -<Body size="S">Specify the minimum access level role for this data.</Body> -<div class="row"> - <Label extraSmall grey>Level</Label> - <Label extraSmall grey>Role</Label> - {#each Object.keys(computedPermissions) as level} - <Input value={capitalise(level)} disabled /> - <Select - placeholder={false} - value={computedPermissions[level].selectedValue} - on:change={e => changePermission(level, e.detail)} - options={computedPermissions[level].options} - getOptionLabel={x => x.name} - getOptionValue={x => x._id} - /> - {/each} -</div> - -{#if dependantsInfoMessage} - <div class="inheriting-resources"> - <Icon name="Alert" /> - <Body size="S"> - <i> - {dependantsInfoMessage} - </i> - </Body> - </div> -{/if} - -<style> - .row { - display: grid; - grid-template-columns: 1fr 1fr; - grid-gap: var(--spacing-s); - } - .inheriting-resources { - display: flex; - gap: var(--spacing-s); - } -</style> diff --git a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte index a6078e38fb..d7a98c564a 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte @@ -76,6 +76,13 @@ selectedBy={$userSelectedResourceMap[TableNames.USERS]} /> {/if} + <NavItem + icon="UserAdmin" + text="Manage roles" + selected={$isActive("./roles")} + on:click={() => $goto("./roles")} + selectedBy={$userSelectedResourceMap.roles} + /> {#each enrichedDataSources.filter(ds => ds.show) as datasource} <DatasourceNavItem {datasource} diff --git a/packages/builder/src/components/backend/RoleEditor/BracketEdge.svelte b/packages/builder/src/components/backend/RoleEditor/BracketEdge.svelte new file mode 100644 index 0000000000..182b56f122 --- /dev/null +++ b/packages/builder/src/components/backend/RoleEditor/BracketEdge.svelte @@ -0,0 +1,63 @@ +<script> + import { BaseEdge } from "@xyflow/svelte" + import { NodeWidth, GridResolution } from "./constants" + import { getContext } from "svelte" + + export let sourceX + export let sourceY + + const { bounds } = getContext("flow") + + $: bracketWidth = GridResolution * 3 + $: bracketHeight = $bounds.height / 2 + GridResolution * 2 + $: path = getCurlyBracePath( + sourceX + bracketWidth, + sourceY - bracketHeight, + sourceX + bracketWidth, + sourceY + bracketHeight + ) + + const getCurlyBracePath = (x1, y1, x2, y2) => { + const w = 2 // Thickness + const q = 1 // Intensity + const i = 28 // Inner radius strenth (lower is stronger) + const j = 32 // Outer radius strength (higher is stronger) + + // Calculate unit vector + var dx = x1 - x2 + var dy = y1 - y2 + var len = Math.sqrt(dx * dx + dy * dy) + dx = dx / len + dy = dy / len + + // Path control points + const qx1 = x1 + q * w * dy - j + const qy1 = y1 - q * w * dx + const qx2 = x1 - 0.25 * len * dx + (1 - q) * w * dy - i + const qy2 = y1 - 0.25 * len * dy - (1 - q) * w * dx + const tx1 = x1 - 0.5 * len * dx + w * dy - bracketWidth + const ty1 = y1 - 0.5 * len * dy - w * dx + const qx3 = x2 + q * w * dy - j + const qy3 = y2 - q * w * dx + const qx4 = x1 - 0.75 * len * dx + (1 - q) * w * dy - i + const qy4 = y1 - 0.75 * len * dy - (1 - q) * w * dx + + return `M ${x1} ${y1} Q ${qx1} ${qy1} ${qx2} ${qy2} T ${tx1} ${ty1} M ${x2} ${y2} Q ${qx3} ${qy3} ${qx4} ${qy4} T ${tx1} ${ty1}` + } +</script> + +<BaseEdge + {...$$props} + {path} + style="--width:{NodeWidth}px; --x:{sourceX}px; --y:{sourceY}px;" +/> + +<style> + :global(#basic-bracket) { + animation-timing-function: linear(1, 0); + } + :global(#admin-bracket) { + transform: scale(-1, 1) translateX(calc(var(--width) + 8px)); + transform-origin: var(--x) var(--y); + } +</style> diff --git a/packages/builder/src/components/backend/RoleEditor/Controls.svelte b/packages/builder/src/components/backend/RoleEditor/Controls.svelte new file mode 100644 index 0000000000..b695a71a43 --- /dev/null +++ b/packages/builder/src/components/backend/RoleEditor/Controls.svelte @@ -0,0 +1,74 @@ +<script> + import { Button, ActionButton } from "@budibase/bbui" + import { useSvelteFlow } from "@xyflow/svelte" + import { getContext } from "svelte" + import { ZoomDuration } from "./constants" + + const { createRole, layoutAndFit } = getContext("flow") + const flow = useSvelteFlow() +</script> + +<div class="control top-right"> + <div class="group"> + <ActionButton + icon="Add" + quiet + on:click={() => flow.zoomIn({ duration: ZoomDuration })} + /> + <ActionButton + icon="Remove" + quiet + on:click={() => flow.zoomOut({ duration: ZoomDuration })} + /> + </div> + <Button secondary on:click={layoutAndFit}>Auto layout</Button> +</div> +<div class="control bottom-right"> + <Button icon="Add" cta on:click={createRole}>Add role</Button> +</div> + +<style> + .control { + position: absolute; + z-index: 10; + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + } + .top-right { + top: 20px; + right: 20px; + } + .bottom-right { + bottom: 20px; + right: 20px; + } + .top-right :global(.spectrum-Button), + .top-right :global(.spectrum-ActionButton), + .top-right :global(.spectrum-Icon) { + color: var(--spectrum-global-color-gray-900) !important; + } + .top-right :global(.spectrum-Button), + .top-right :global(.spectrum-ActionButton) { + background: var(--spectrum-global-color-gray-200) !important; + } + .top-right :global(.spectrum-Button:hover), + .top-right :global(.spectrum-ActionButton:hover) { + background: var(--spectrum-global-color-gray-300) !important; + } + .group { + border-radius: 4px; + display: flex; + flex-direction: row; + } + .group :global(> *:not(:first-child)) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: 2px solid var(--spectrum-global-color-gray-300); + } + .group :global(> *:not(:last-child)) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } +</style> diff --git a/packages/builder/src/components/backend/RoleEditor/EmptyStateNode.svelte b/packages/builder/src/components/backend/RoleEditor/EmptyStateNode.svelte new file mode 100644 index 0000000000..2c949ed0f9 --- /dev/null +++ b/packages/builder/src/components/backend/RoleEditor/EmptyStateNode.svelte @@ -0,0 +1,24 @@ +<script> + import { NodeWidth, NodeHeight } from "./constants" +</script> + +<div class="node" style={`--width:${NodeWidth}px; --height:${NodeHeight}px;`}> + Add custom roles for more granular control over permissions +</div> + +<style> + .node { + border-radius: 4px; + width: var(--width); + height: var(--height); + display: grid; + place-items: center; + font-size: 16px; + text-align: center; + color: var(--spectrum-global-color-gray-800); + text-shadow: 4px 4px 10px var(--background-color), + 4px -4px 10px var(--background-color), + -4px 4px 10px var(--background-color), + -4px -4px 10px var(--background-color); + } +</style> diff --git a/packages/builder/src/components/backend/RoleEditor/RoleEdge.svelte b/packages/builder/src/components/backend/RoleEditor/RoleEdge.svelte new file mode 100644 index 0000000000..84badeb6b2 --- /dev/null +++ b/packages/builder/src/components/backend/RoleEditor/RoleEdge.svelte @@ -0,0 +1,123 @@ +<script> + import { getBezierPath, BaseEdge, EdgeLabelRenderer } from "@xyflow/svelte" + import { Icon, TooltipPosition } from "@budibase/bbui" + import { getContext, onMount } from "svelte" + import { roles } from "stores/builder" + + export let sourceX + export let sourceY + export let sourcePosition + export let targetX + export let targetY + export let targetPosition + export let id + export let source + export let target + + const { deleteEdge, selectedNodes } = getContext("flow") + + let iconHovered = false + let edgeHovered = false + + $: hovered = iconHovered || edgeHovered + $: active = + hovered || + $selectedNodes.includes(source) || + $selectedNodes.includes(target) + $: edgeClasses = getEdgeClasses(active, iconHovered) + $: [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }) + $: sourceRole = $roles.find(x => x._id === source) + $: targetRole = $roles.find(x => x._id === target) + $: tooltip = + sourceRole && targetRole + ? `Stop ${targetRole.uiMetadata.displayName} from inheriting ${sourceRole.uiMetadata.displayName}` + : null + + const getEdgeClasses = (active, iconHovered) => { + let classes = "" + if (active) classes += `active ` + if (iconHovered) classes += `delete ` + return classes + } + + const onEdgeMouseOver = () => { + edgeHovered = true + } + + const onEdgeMouseOut = () => { + edgeHovered = false + } + + onMount(() => { + const edge = document.querySelector(`.svelte-flow__edge[data-id="${id}"]`) + if (edge) { + edge.addEventListener("mouseover", onEdgeMouseOver) + edge.addEventListener("mouseout", onEdgeMouseOut) + } + return () => { + if (edge) { + edge.removeEventListener("mouseover", onEdgeMouseOver) + edge.removeEventListener("mouseout", onEdgeMouseOut) + } + } + }) +</script> + +<BaseEdge path={edgePath} class={edgeClasses} /> +<EdgeLabelRenderer> + <!-- svelte-ignore a11y-click-events-have-key-events --> + <!-- svelte-ignore a11y-no-static-element-interactions --> + <!-- svelte-ignore a11y-mouse-events-have-key-events --> + <div + style:transform="translate(-50%, -50%) translate({labelX}px,{labelY}px)" + class="edge-label nodrag nopan" + class:active + on:click={() => deleteEdge(id)} + on:mouseover={() => (iconHovered = true)} + on:mouseout={() => (iconHovered = false)} + > + <Icon + name="Delete" + size="S" + {tooltip} + tooltipPosition={TooltipPosition.Top} + /> + </div> +</EdgeLabelRenderer> + +<style> + .edge-label { + position: absolute; + padding: 8px; + opacity: 0; + pointer-events: none; + } + .edge-label.active { + opacity: 1; + pointer-events: all; + cursor: pointer; + } + .edge-label:hover :global(.spectrum-Icon) { + color: var(--spectrum-global-color-red-400); + } + .edge-label :global(.spectrum-Icon) { + background: var(--background-color); + color: var(--spectrum-global-color-gray-600); + } + .edge-label :global(svg) { + padding: 4px; + } + :global(.svelte-flow__edge-path.active) { + stroke: var(--spectrum-global-color-blue-400); + } + :global(.svelte-flow__edge-path.active.delete) { + stroke: var(--spectrum-global-color-red-400); + } +</style> diff --git a/packages/builder/src/components/backend/RoleEditor/RoleEditor.svelte b/packages/builder/src/components/backend/RoleEditor/RoleEditor.svelte new file mode 100644 index 0000000000..6169013d12 --- /dev/null +++ b/packages/builder/src/components/backend/RoleEditor/RoleEditor.svelte @@ -0,0 +1,8 @@ +<script> + import { SvelteFlowProvider } from "@xyflow/svelte" + import RoleFlow from "./RoleFlow.svelte" +</script> + +<SvelteFlowProvider> + <RoleFlow /> +</SvelteFlowProvider> diff --git a/packages/builder/src/components/backend/RoleEditor/RoleFlow.svelte b/packages/builder/src/components/backend/RoleEditor/RoleFlow.svelte new file mode 100644 index 0000000000..90c0a9fca8 --- /dev/null +++ b/packages/builder/src/components/backend/RoleEditor/RoleFlow.svelte @@ -0,0 +1,234 @@ +<script> + import { Heading, Helpers, notifications } from "@budibase/bbui" + import { writable, derived } from "svelte/store" + import { + SvelteFlow, + Background, + BackgroundVariant, + useSvelteFlow, + } from "@xyflow/svelte" + import "@xyflow/svelte/dist/style.css" + import RoleNode from "./RoleNode.svelte" + import EmptyStateNode from "./EmptyStateNode.svelte" + import RoleEdge from "./RoleEdge.svelte" + import BracketEdge from "./BracketEdge.svelte" + import { + autoLayout, + getAdminPosition, + getBasicPosition, + rolesToLayout, + nodeToRole, + getBounds, + } from "./utils" + import { setContext, tick } from "svelte" + import Controls from "./Controls.svelte" + import { GridResolution, MaxAutoZoom, ZoomDuration } from "./constants" + import { roles } from "stores/builder" + import { Roles } from "constants/backend" + import { getSequentialName } from "helpers/duplicate" + import { derivedMemo } from "@budibase/frontend-core" + + const flow = useSvelteFlow() + const edges = writable([]) + const nodes = writable([]) + const dragging = writable(false) + + // Derive the list of selected nodes + const selectedNodes = derived(nodes, $nodes => { + return $nodes.filter(node => node.selected).map(node => node.id) + }) + + // Derive the bounds of all custom role nodes + const bounds = derivedMemo(nodes, getBounds) + + $: handleExternalRoleChanges($roles) + $: updateBuiltins($bounds) + + // Updates nodes and edges based on external changes to roles + const handleExternalRoleChanges = roles => { + const currentNodes = $nodes + const newLayout = autoLayout(rolesToLayout(roles)) + edges.set(newLayout.edges) + + // For nodes we want to persist some metadata if possible + nodes.set( + newLayout.nodes.map(node => { + const currentNode = currentNodes.find(x => x.id === node.id) + if (!currentNode) { + return node + } + return { + ...node, + position: currentNode.position || node.position, + selected: currentNode.selected || node.selected, + } + }) + ) + } + + // Positions the basic and admin role at either edge of the flow + const updateBuiltins = bounds => { + flow.updateNode(Roles.BASIC, { + position: getBasicPosition(bounds), + }) + flow.updateNode(Roles.ADMIN, { + position: getAdminPosition(bounds), + }) + } + + // Automatically lays out all roles and edges and zooms to fit them + const layoutAndFit = () => { + const layout = autoLayout({ nodes: $nodes, edges: $edges }) + nodes.set(layout.nodes) + edges.set(layout.edges) + flow.fitView({ maxZoom: MaxAutoZoom, duration: ZoomDuration }) + } + + const createRole = async () => { + const roleId = Helpers.uuid() + await roles.save({ + name: roleId, + uiMetadata: { + displayName: getSequentialName($roles, "New role ", { + getName: role => role.uiMetadata.displayName, + }), + color: "var(--spectrum-global-color-gray-700)", + description: "Custom role", + }, + inherits: [Roles.BASIC], + }) + await tick() + layoutAndFit() + + // Select the new node + nodes.update($nodes => { + return $nodes.map(node => ({ + ...node, + selected: node.id === roleId, + })) + }) + } + + const updateRole = async (roleId, metadata) => { + const node = $nodes.find(node => node.id === roleId) + if (!node) { + return + } + // Update metadata immediately, before saving + if (metadata) { + flow.updateNodeData(roleId, metadata) + } + try { + await roles.save(nodeToRole({ node, edges: $edges })) + layoutAndFit() + } catch (error) { + notifications.error(error?.message || error || "Failed to update role") + handleExternalRoleChanges($roles) + } + } + + const deleteRole = async roleId => { + nodes.set($nodes.filter(node => node.id !== roleId)) + layoutAndFit() + const role = $roles.find(role => role._id === roleId) + if (role) { + roles.delete(role) + } + } + + const deleteEdge = async edgeId => { + const edge = $edges.find(edge => edge.id === edgeId) + edges.set($edges.filter(edge => edge.id !== edgeId)) + await updateRole(edge.target) + } + + const onConnect = async connection => { + await updateRole(connection.target) + } + + setContext("flow", { + nodes, + edges, + dragging, + selectedNodes, + bounds, + createRole, + updateRole, + deleteRole, + deleteEdge, + layoutAndFit, + }) +</script> + +<div class="title"> + <div class="heading" /> +</div> +<div class="flow"> + <SvelteFlow + fitView + {nodes} + {edges} + snapGrid={[GridResolution, GridResolution]} + nodeTypes={{ role: RoleNode, empty: EmptyStateNode }} + edgeTypes={{ role: RoleEdge, bracket: BracketEdge }} + proOptions={{ hideAttribution: true }} + fitViewOptions={{ maxZoom: MaxAutoZoom }} + defaultEdgeOptions={{ type: "role", animated: true, selectable: false }} + onconnectstart={() => dragging.set(true)} + onconnectend={() => dragging.set(false)} + onconnect={onConnect} + deleteKey={null} + > + <Background variant={BackgroundVariant.Dots} /> + <Controls /> + <div class="title"> + <Heading size="S">Manage roles</Heading> + </div> + <div class="footer">Roles inherit permissions from each other</div> + </SvelteFlow> +</div> + +<style> + .flow { + margin: -28px -40px -40px -40px; + flex: 1 1 auto; + overflow: hidden; + position: relative; + --background-color: var(--spectrum-global-color-gray-50); + --border-color: var(--spectrum-global-color-gray-300); + --edge-color: var(--spectrum-global-color-gray-500); + --handle-color: var(--spectrum-global-color-gray-600); + --selected-color: var(--spectrum-global-color-blue-400); + } + .title { + position: absolute; + top: 20px; + left: 20px; + z-index: 10; + } + .footer { + position: absolute; + left: 20px; + bottom: 20px; + color: var(--spectrum-global-color-gray-600); + z-index: 10; + } + + /* Customise svelte-flow theme */ + .flow :global(.svelte-flow) { + /* Panel */ + --xy-background-color: var(--background-color); + + /* Controls */ + --xy-controls-button-border-color: var(--border-color); + + /* Handles */ + --xy-handle-background-color: var(--handle-color); + --xy-handle-border-color: var(--handle-color); + + /* Edges */ + --xy-edge-stroke: var(--edge-color); + --xy-edge-stroke-selected: var(--edge-color); + --xy-edge-stroke-width: 2px; + } +</style> diff --git a/packages/builder/src/components/backend/RoleEditor/RoleNode.svelte b/packages/builder/src/components/backend/RoleEditor/RoleNode.svelte new file mode 100644 index 0000000000..32a95f4278 --- /dev/null +++ b/packages/builder/src/components/backend/RoleEditor/RoleNode.svelte @@ -0,0 +1,231 @@ +<script> + import { Handle, Position } from "@xyflow/svelte" + import { + Icon, + Input, + ColorPicker, + Modal, + ModalContent, + FieldLabel, + } from "@budibase/bbui" + import { NodeWidth, NodeHeight } from "./constants" + import { getContext } from "svelte" + import { roles } from "stores/builder" + import ConfirmDialog from "components/common/ConfirmDialog.svelte" + + export let data + export let id + export let selected + export let isConnectable + + const { dragging, updateRole, deleteRole } = getContext("flow") + + let anchor + let modal + let tempDisplayName + let tempDescription + let tempColor + let deleteModal + + $: nameError = validateName(tempDisplayName, $roles) + $: descriptionError = validateDescription(tempDescription) + $: invalid = nameError || descriptionError + + const validateName = (name, roles) => { + if (!name?.length) { + return "Please enter a name" + } + if (roles.some(x => x.uiMetadata.displayName === name && x._id !== id)) { + return "That name is already used by another role" + } + return null + } + + const validateDescription = description => { + if (!description?.length) { + return "Please enter a name" + } + return null + } + + const openPopover = e => { + e.stopPropagation() + tempDisplayName = data.displayName + tempDescription = data.description + tempColor = data.color + modal.show() + } + + const saveChanges = () => { + updateRole(id, { + displayName: tempDisplayName, + description: tempDescription, + color: tempColor, + }) + } +</script> + +<div + class="node" + class:dragging={$dragging} + class:selected + class:interactive={data.interactive} + class:custom={data.custom} + class:selectable={isConnectable} + style={`--color:${data.color}; --width:${NodeWidth}px; --height:${NodeHeight}px;`} + bind:this={anchor} +> + <div class="color" /> + <div class="content"> + <div class="text"> + <div class="name"> + {data.displayName} + </div> + {#if data.description} + <div class="description" title={data.description}> + {data.description} + </div> + {/if} + </div> + {#if data.custom} + <div class="buttons"> + <Icon size="S" name="Edit" hoverable on:click={openPopover} /> + <Icon size="S" name="Delete" hoverable on:click={deleteModal?.show} /> + </div> + {/if} + </div> + <Handle + type="target" + position={Position.Left} + isConnectable={isConnectable && $dragging && data.custom} + /> + <Handle type="source" position={Position.Right} {isConnectable} /> +</div> + +<ConfirmDialog + bind:this={deleteModal} + title={`Delete ${data.displayName}`} + body="Are you sure you want to delete this role? This can't be undone." + okText="Delete" + onOk={async () => await deleteRole(id)} +/> + +<Modal bind:this={modal}> + <ModalContent + title={`Edit ${data.displayName}`} + confirmText="Save" + onConfirm={saveChanges} + disabled={invalid} + > + <Input + label="Name" + value={tempDisplayName} + error={nameError} + on:change={e => (tempDisplayName = e.detail)} + /> + <Input + label="Description" + value={tempDescription} + error={descriptionError} + on:change={e => (tempDescription = e.detail)} + /> + <div> + <FieldLabel label="Color" /> + <ColorPicker value={tempColor} on:change={e => (tempColor = e.detail)} /> + </div> + </ModalContent> +</Modal> + +<style> + /* Node styles */ + .node { + position: relative; + background: var(--spectrum-global-color-gray-100); + border-radius: 4px; + width: var(--width); + height: var(--height); + display: flex; + flex-direction: row; + box-sizing: border-box; + transition: background 130ms ease-out; + } + .node.selectable:hover { + cursor: pointer; + background: var(--spectrum-global-color-gray-200); + } + .node.selectable.selected { + background: var(--spectrum-global-color-blue-100); + cursor: grab; + } + .color { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + height: 100%; + width: 10px; + flex: 0 0 10px; + background: var(--color); + } + + /* Main container */ + .content { + flex: 1 1 auto; + display: flex; + flex-direction: row; + border: 1px solid var(--border-color); + border-left-width: 0; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + padding: 12px; + gap: 6px; + } + .node.selected .content { + border-color: var(--spectrum-global-color-blue-100); + } + + /* Text */ + .text { + width: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: center; + align-items: stretch; + } + .name, + .description { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + .description { + color: var(--spectrum-global-color-gray-600); + font-size: 12px; + } + + /* Icons */ + .buttons { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + } + .buttons :global(.spectrum-Icon) { + color: var(--spectrum-global-color-gray-600); + } + + /* Handles */ + .node :global(.svelte-flow__handle) { + width: 6px; + height: 6px; + border-width: 2px; + } + .node :global(.svelte-flow__handle.target) { + background: var(--background-color); + } + .node:not(.dragging) :global(.svelte-flow__handle.target), + .node:not(.interactive) :global(.svelte-flow__handle), + .node:not(.custom) :global(.svelte-flow__handle.target) { + visibility: hidden; + pointer-events: none; + } +</style> 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 @@ <script> - import { RoleUtils } from "@budibase/frontend-core" import { StatusLight } from "@budibase/bbui" + import { roles } from "stores/builder" export let id export let size = "M" export let disabled = false - $: color = RoleUtils.getRoleColour(id) + $: color = + $roles.find(x => x._id === id)?.color || + "var(--spectrum-global-color-static-magenta-400)" </script> <StatusLight square {disabled} {size} {color} /> 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/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 @@ <script> import { Select } from "@budibase/bbui" import { roles } from "stores/builder" - import { RoleUtils } from "@budibase/frontend-core" export let value export let error export let placeholder = null + export let autoWidth = false </script> <Select bind:value on:change options={$roles} - getOptionLabel={role => role.name} + getOptionLabel={role => role.uiMetadata.displayName} getOptionValue={role => role._id} - getOptionColour={role => RoleUtils.getRoleColour(role._id)} + getOptionColour={role => role.uiMetadata.color} {placeholder} {error} + {autoWidth} /> diff --git a/packages/builder/src/components/integration/AccessLevelSelect.svelte b/packages/builder/src/components/integration/AccessLevelSelect.svelte index 05b336c3b3..02ea50e780 100644 --- a/packages/builder/src/components/integration/AccessLevelSelect.svelte +++ b/packages/builder/src/components/integration/AccessLevelSelect.svelte @@ -1,7 +1,8 @@ <script> - import { Label, notifications, Select } from "@budibase/bbui" - import { permissions, roles } from "stores/builder" + import { Label, notifications } from "@budibase/bbui" + import { permissions } from "stores/builder" import { Constants } from "@budibase/frontend-core" + import RoleSelect from "components/design/settings/controls/RoleSelect.svelte" export let query export let label @@ -52,12 +53,5 @@ {#if label} <Label>{label}</Label> {/if} - <Select - value={roleId} - on:change={e => updateRole(e.detail)} - options={$roles} - getOptionLabel={x => x.name} - getOptionValue={x => x._id} - autoWidth - /> + <RoleSelect value={roleId} on:change={e => updateRole(e.detail)} autoWidth /> {/if} diff --git a/packages/builder/src/components/integration/RestQueryViewer.svelte b/packages/builder/src/components/integration/RestQueryViewer.svelte index 0489167823..3b1356efe2 100644 --- a/packages/builder/src/components/integration/RestQueryViewer.svelte +++ b/packages/builder/src/components/integration/RestQueryViewer.svelte @@ -503,7 +503,7 @@ on:save={saveQuery} /> <div class="access"> - <Label>Access level</Label> + <Label>Access</Label> <AccessLevelSelect {query} {saveId} /> </div> </div> diff --git a/packages/builder/src/components/start/CreateAppModal.svelte b/packages/builder/src/components/start/CreateAppModal.svelte index b5fed9cdc1..83ed089006 100644 --- a/packages/builder/src/components/start/CreateAppModal.svelte +++ b/packages/builder/src/components/start/CreateAppModal.svelte @@ -118,14 +118,17 @@ if ($values.url) { data.append("url", $values.url.trim()) } - data.append("useTemplate", template != null) - if (template) { - data.append("templateName", template.name) - data.append("templateKey", template.key) - data.append("templateFile", $values.file) + + if (template?.fromFile) { + data.append("useTemplate", true) + data.append("fileToImport", $values.file) if ($values.encryptionPassword?.trim()) { data.append("encryptionPassword", $values.encryptionPassword.trim()) } + } else if (template) { + data.append("useTemplate", true) + data.append("templateName", template.name) + data.append("templateKey", template.key) } // Create App diff --git a/packages/builder/src/dataBinding.js b/packages/builder/src/dataBinding.js index 3eefb373ca..a59ce16e5a 100644 --- a/packages/builder/src/dataBinding.js +++ b/packages/builder/src/dataBinding.js @@ -725,10 +725,10 @@ const getRoleBindings = () => { return { type: "context", runtimeBinding: `'${role._id}'`, - readableBinding: `Role.${role.name}`, + readableBinding: `Role.${role.uiMetadata.displayName}`, category: "Role", icon: "UserGroup", - display: { type: "string", name: role.name }, + display: { type: "string", name: role.uiMetadata.displayName }, } }) } diff --git a/packages/builder/src/pages/builder/app/[application]/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/_layout.svelte index be8337b376..8e109d3145 100644 --- a/packages/builder/src/pages/builder/app/[application]/_layout.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_layout.svelte @@ -228,7 +228,7 @@ .top-nav { flex: 0 0 60px; background: var(--background); - padding-left: var(--spacing-xl); + padding: 0 var(--spacing-xl); display: grid; grid-template-columns: 1fr auto 1fr; flex-direction: row; @@ -269,6 +269,7 @@ flex-direction: row; justify-content: flex-end; align-items: center; + margin-right: calc(-1 * var(--spacing-xl)); } .toprightnav :global(.avatars) { diff --git a/packages/builder/src/pages/builder/app/[application]/data/roles.svelte b/packages/builder/src/pages/builder/app/[application]/data/roles.svelte new file mode 100644 index 0000000000..0177577730 --- /dev/null +++ b/packages/builder/src/pages/builder/app/[application]/data/roles.svelte @@ -0,0 +1,8 @@ +<script> + import RoleEditor from "components/backend/RoleEditor/RoleEditor.svelte" + import { builderStore } from "stores/builder" + + builderStore.selectResource("roles") +</script> + +<RoleEditor /> diff --git a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/[viewId]/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/[viewId]/index.svelte index 52bed9c72c..44fb70b9d0 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/[viewId]/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/[viewId]/index.svelte @@ -63,17 +63,15 @@ {#if calculation} <GridViewCalculationButton /> {/if} + <GridManageAccessButton /> <GridFilterButton /> <GridSortButton /> <GridSizeButton /> - <GridManageAccessButton /> {#if !calculation} <GridColumnsSettingButton /> <GridRowActionsButton /> - <GridScreensButton on:request-generate={() => generateButton?.show()} /> + <GridScreensButton on:generate={() => generateButton?.show()} /> {/if} - </svelte:fragment> - <svelte:fragment slot="controls-right"> <GridGenerateButton bind:this={generateButton} /> </svelte:fragment> <GridCreateEditRowModal /> diff --git a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte index 69a23f3a0e..e300768bf1 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte @@ -6,6 +6,7 @@ integrations, appStore, rowActions, + roles, } from "stores/builder" import { themeStore, admin, licensing } from "stores/portal" import { TableNames } from "constants" @@ -26,16 +27,20 @@ import GridRowActionsButton from "components/backend/DataTable/buttons/grid/GridRowActionsButton.svelte" import { DB_TYPE_EXTERNAL } from "constants/backend" - const userSchemaOverrides = { + let generateButton + + $: userSchemaOverrides = { firstName: { displayName: "First name", disabled: true }, lastName: { displayName: "Last name", disabled: true }, email: { displayName: "Email", disabled: true }, - roleId: { displayName: "Role", disabled: true }, status: { displayName: "Status", disabled: true }, + roleId: { + displayName: "Role", + type: "role", + disabled: true, + roles: $roles, + }, } - - let generateButton - $: autoColumnStatus = verifyAutocolumns($tables?.selected) $: duplicates = Object.values(autoColumnStatus).reduce((acc, status) => { if (status.length > 1) { @@ -141,17 +146,11 @@ <GridRelationshipButton /> {/if} {#if !isUsersTable} - <GridRowActionsButton /> - <GridScreensButton on:request-generate={() => generateButton?.show()} /> - <GridAutomationsButton - on:request-generate={() => generateButton?.show()} - /> <GridImportButton /> - {/if} - <GridExportButton /> - </svelte:fragment> - <svelte:fragment slot="controls-right"> - {#if !isUsersTable} + <GridExportButton /> + <GridRowActionsButton /> + <GridScreensButton on:generate={() => generateButton?.show()} /> + <GridAutomationsButton on:generate={() => generateButton?.show()} /> <GridGenerateButton bind:this={generateButton} /> {/if} </svelte:fragment> diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte index 60c908c7c5..88ddf6f9a5 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte @@ -5,9 +5,11 @@ export let body export let icon = "HelpOutline" export let quiet = false + export let warning = false + export let error = false </script> -<div class="info" class:noTitle={!title} class:quiet> +<div class="info" class:noTitle={!title} class:warning class:error class:quiet> {#if title} <div class="title"> <Icon name={icon} /> @@ -17,7 +19,7 @@ {@html body} {:else} <span class="icon"> - <Icon name={icon} /> + <Icon size="S" name={icon} /> </span> <!-- eslint-disable-next-line svelte/no-at-html-tags --> {@html body} @@ -25,6 +27,23 @@ </div> <style> + .info { + padding: var(--spacing-m) var(--spacing-l); + background-color: var(--spectrum-global-color-gray-200); + border-radius: var(--border-radius-s); + font-size: 13px; + } + .warning { + background: rgba(255, 200, 0, 0.2); + } + .error { + background: rgba(255, 0, 0, 0.2); + } + .noTitle { + display: flex; + align-items: center; + gap: var(--spacing-m); + } .title { font-size: 12px; font-weight: 600; diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json index ff58a66221..84e1bd75b8 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json @@ -14,7 +14,7 @@ { "name": "Layout", "icon": "ClassicGridView", - "children": ["container", "section", "sidepanel", "modal"] + "children": ["container", "sidepanel", "modal"] }, { "name": "Data", diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ScreenList/RoleIndicator.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ScreenList/RoleIndicator.svelte index 4b7f26709c..09e29d806a 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ScreenList/RoleIndicator.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ScreenList/RoleIndicator.svelte @@ -1,5 +1,4 @@ <script> - import { RoleUtils } from "@budibase/frontend-core" import { Tooltip, StatusLight } from "@budibase/bbui" import { roles } from "stores/builder" import { Roles } from "constants/backend" @@ -8,12 +7,13 @@ let showTooltip = false - $: color = RoleUtils.getRoleColour(roleId) $: role = $roles.find(role => role._id === roleId) + $: color = + role?.uiMetadata.color || "var(--spectrum-global-color-static-magenta-400)" $: tooltip = roleId === Roles.PUBLIC ? "Open to the public" - : `Requires ${role?.name} access` + : `Requires ${role?.uiMetadata.displayName || "Unknown role"} access` </script> <!-- svelte-ignore a11y-no-static-element-interactions --> diff --git a/packages/builder/src/pages/builder/portal/settings/ai/index.svelte b/packages/builder/src/pages/builder/portal/settings/ai/index.svelte index 42de9f19ae..bbdf46a24e 100644 --- a/packages/builder/src/pages/builder/portal/settings/ai/index.svelte +++ b/packages/builder/src/pages/builder/portal/settings/ai/index.svelte @@ -31,10 +31,7 @@ async function fetchAIConfig() { try { - const aiDoc = await API.getConfig(ConfigTypes.AI) - if (aiDoc._id) { - fullAIConfig = aiDoc - } + fullAIConfig = await API.getConfig(ConfigTypes.AI) } catch (error) { notifications.error("Error fetching AI config") } @@ -66,6 +63,7 @@ } // Add new or update existing custom AI Config fullAIConfig.config[id] = editingAIConfig + fullAIConfig.type = ConfigTypes.AI } try { diff --git a/packages/builder/src/pages/builder/portal/users/groups/_components/GroupAppsTableRenderer.svelte b/packages/builder/src/pages/builder/portal/users/groups/_components/GroupAppsTableRenderer.svelte index 48b1bfbce6..37e44a277e 100644 --- a/packages/builder/src/pages/builder/portal/users/groups/_components/GroupAppsTableRenderer.svelte +++ b/packages/builder/src/pages/builder/portal/users/groups/_components/GroupAppsTableRenderer.svelte @@ -4,15 +4,16 @@ export let value export let row - $: count = getCount(Object.keys(value || {}).length) - const getCount = () => { + $: count = getCount(row, value) + + const getCount = (row, value) => { return sdk.users.hasAppBuilderPermissions(row) ? row.builder.apps.length + Object.keys(row.roles || {}).filter(appId => row.builder.apps.includes(appId) ).length - : value?.length || 0 + : Object.keys(value || {}).length } </script> diff --git a/packages/builder/src/pages/builder/portal/users/users/_components/AppRoleTableRenderer.svelte b/packages/builder/src/pages/builder/portal/users/users/_components/AppRoleTableRenderer.svelte index 0f19bb3e1f..03a3e626a4 100644 --- a/packages/builder/src/pages/builder/portal/users/users/_components/AppRoleTableRenderer.svelte +++ b/packages/builder/src/pages/builder/portal/users/users/_components/AppRoleTableRenderer.svelte @@ -1,23 +1,28 @@ <script> import { StatusLight } from "@budibase/bbui" - import { RoleUtils, Constants } from "@budibase/frontend-core" + import { Constants } from "@budibase/frontend-core" import { roles } from "stores/builder" import { capitalise } from "helpers" export let value + $: role = $roles.find(x => x._id === value) + const getRoleLabel = roleId => { - const role = $roles.find(x => x._id === roleId) return roleId === Constants.Roles.CREATOR ? capitalise(Constants.Roles.CREATOR.toLowerCase()) - : role?.name || "Custom role" + : role?.uiMetadata.displayName || role?.name || "Custom role" } </script> {#if value === Constants.Roles.CREATOR} Can edit {:else} - <StatusLight square color={RoleUtils.getRoleColour(value)}> + <StatusLight + square + color={role?.uiMetadata.color || + "var(--spectrum-global-color-static-magenta-400)"} + > Can use as {getRoleLabel(value)} </StatusLight> {/if} diff --git a/packages/builder/src/pages/builder/portal/users/users/_components/RolesTagsTableRenderer.svelte b/packages/builder/src/pages/builder/portal/users/users/_components/RolesTagsTableRenderer.svelte deleted file mode 100644 index 402344dedd..0000000000 --- a/packages/builder/src/pages/builder/portal/users/users/_components/RolesTagsTableRenderer.svelte +++ /dev/null @@ -1,9 +0,0 @@ -<script> - import TagsTableRenderer from "./TagsTableRenderer.svelte" - - export let value - - $: roles = value?.filter(role => role != null).map(role => role.name) ?? [] -</script> - -<TagsTableRenderer value={roles} /> diff --git a/packages/builder/src/pages/builder/portal/users/users/_components/TagsTableRenderer.svelte b/packages/builder/src/pages/builder/portal/users/users/_components/TagsTableRenderer.svelte deleted file mode 100644 index 8b2fd3c522..0000000000 --- a/packages/builder/src/pages/builder/portal/users/users/_components/TagsTableRenderer.svelte +++ /dev/null @@ -1,35 +0,0 @@ -<script> - import { Tag, Tags } from "@budibase/bbui" - - export let value - - const displayLimit = 5 - - $: values = value?.filter(value => value != null) ?? [] - $: tags = values.slice(0, displayLimit) - $: leftover = values.length - tags.length -</script> - -<div class="tag-renderer"> - <Tags> - {#each tags as tag} - <Tag> - {tag} - </Tag> - {/each} - {#if leftover} - <Tag>+{leftover} more</Tag> - {/if} - </Tags> -</div> - -<style> - .tag-renderer :global(.spectrum-Tags-item:hover) { - color: var(--spectrum-alias-label-text-color); - border-color: var(--spectrum-alias-border-color-darker-default); - cursor: pointer; - } - .tag-renderer :global(.spectrum-Tags-itemLabel) { - cursor: pointer; - } -</style> diff --git a/packages/builder/src/pages/builder/portal/users/users/_components/UpdateRolesModal.svelte b/packages/builder/src/pages/builder/portal/users/users/_components/UpdateRolesModal.svelte deleted file mode 100644 index 9ad41ad652..0000000000 --- a/packages/builder/src/pages/builder/portal/users/users/_components/UpdateRolesModal.svelte +++ /dev/null @@ -1,74 +0,0 @@ -<script> - import { createEventDispatcher } from "svelte" - import { Body, Select, ModalContent, notifications } from "@budibase/bbui" - import { users } from "stores/portal" - import { sdk } from "@budibase/shared-core" - - export let app - export let user - - const NO_ACCESS = "NO_ACCESS" - - const dispatch = createEventDispatcher() - - const roles = app.roles - let options = roles - .filter(role => role._id !== "PUBLIC") - .map(role => ({ value: role._id, label: role.name })) - - if (!sdk.users.isBuilder(user, app?.appId)) { - options.push({ value: NO_ACCESS, label: "No Access" }) - } - let selectedRole = user?.roles?.[app?._id] - - async function updateUserRoles() { - try { - if (selectedRole === NO_ACCESS) { - // Remove the user role - const filteredRoles = { ...user.roles } - delete filteredRoles[app?._id] - await users.save({ - ...user, - roles: { - ...filteredRoles, - }, - }) - } else { - // Add the user role - await users.save({ - ...user, - roles: { - ...user.roles, - [app._id]: selectedRole, - }, - }) - } - notifications.success("Role updated") - dispatch("update") - } catch (error) { - notifications.error("Failed to update role") - } - } -</script> - -<ModalContent - onConfirm={updateUserRoles} - title="Update App Role" - confirmText="Update role" - cancelText="Cancel" - size="M" - showCloseIcon={false} -> - <Body> - Update {user.email}'s role for <strong>{app.name}</strong>. - </Body> - <Select - placeholder={null} - bind:value={selectedRole} - on:change - {options} - label="Role" - getOptionLabel={role => role.label} - getOptionValue={role => role.value} - /> -</ModalContent> 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/Section.svelte b/packages/client/src/components/app/deprecated/Section.svelte similarity index 97% rename from packages/client/src/components/app/Section.svelte rename to packages/client/src/components/app/deprecated/Section.svelte index b86c5cc352..245d280dc0 100644 --- a/packages/client/src/components/app/Section.svelte +++ b/packages/client/src/components/app/deprecated/Section.svelte @@ -1,6 +1,6 @@ <script> import { getContext } from "svelte" - import Placeholder from "./Placeholder.svelte" + import Placeholder from "../Placeholder.svelte" const { styleable, builderStore } = getContext("sdk") const component = getContext("component") diff --git a/packages/client/src/components/app/forms/FieldGroup.svelte b/packages/client/src/components/app/forms/FieldGroup.svelte index 9b495338e1..814ee3f76e 100644 --- a/packages/client/src/components/app/forms/FieldGroup.svelte +++ b/packages/client/src/components/app/forms/FieldGroup.svelte @@ -1,6 +1,6 @@ <script> import { getContext, setContext } from "svelte" - import Section from "../Section.svelte" + import Section from "../deprecated/Section.svelte" export let labelPosition = "above" export let type = "oneColumn" diff --git a/packages/client/src/components/app/index.js b/packages/client/src/components/app/index.js index 63cdb95ac1..92e1327f96 100644 --- a/packages/client/src/components/app/index.js +++ b/packages/client/src/components/app/index.js @@ -14,7 +14,6 @@ export { default as Placeholder } from "./Placeholder.svelte" // User facing components export { default as container } from "./container/Container.svelte" -export { default as section } from "./Section.svelte" export { default as dataprovider } from "./DataProvider.svelte" export { default as divider } from "./Divider.svelte" export { default as screenslot } from "./ScreenSlot.svelte" @@ -50,3 +49,4 @@ export { default as navigation } from "./deprecated/Navigation.svelte" export { default as cardhorizontal } from "./deprecated/CardHorizontal.svelte" export { default as stackedlist } from "./deprecated/StackedList.svelte" export { default as card } from "./deprecated/Card.svelte" +export { default as section } from "./deprecated/Section.svelte" 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 @@ <script> import { Heading, Select, ActionButton } from "@budibase/bbui" - import { devToolsStore, appStore, roleStore } from "../../stores" + import { devToolsStore, appStore } from "../../stores" import { getContext, onMount } from "svelte" + import { API } from "api" const context = getContext("context") const SELF_ROLE = "self" - let staticRoleList + let roles - $: previewOptions = buildRoleList(staticRoleList) + $: previewOptions = buildRoleList(roles) - function buildRoleList(roleIds) { + function buildRoleList(roles) { const list = [] list.push({ label: "View as yourself", value: SELF_ROLE, }) - if (!roleIds) { + if (!roles) { return list } - for (let roleId of roleIds) { + for (let role of roles) { list.push({ - label: `View as ${roleId.toLowerCase()} user`, - value: roleId, + label: `View as ${role.uiMetadata?.displayName || role.name}`, + value: role._id, }) } return list @@ -31,7 +32,7 @@ onMount(async () => { // make sure correct before starting await devToolsStore.actions.changeRole(SELF_ROLE) - staticRoleList = await roleStore.actions.fetchAccessibleRoles() + roles = await API.getRoles() }) </script> 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/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 @@ +<script> + import { StatusLight } from "@budibase/bbui" + + export let value + export let schema + + $: role = schema.roles?.find(x => x._id === value) +</script> + +<div class="role-cell"> + <div class="light"> + <StatusLight + square + size="L" + color={role?.uiMetadata?.color || + "var(--spectrum-global-color-static-magenta-400)"} + /> + </div> + <div class="value"> + {role?.uiMetadata?.displayName || role?.name || "Unknown role"} + </div> +</div> + +<style> + .role-cell { + flex: 1 1 auto; + padding: var(--cell-padding); + align-self: stretch; + display: flex; + align-items: flex-start; + overflow: hidden; + gap: var(--cell-padding); + } + .light { + height: 20px; + display: grid; + place-items: center; + } + .value { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 20px; + } +</style> diff --git a/packages/frontend-core/src/components/grid/lib/renderers.js b/packages/frontend-core/src/components/grid/lib/renderers.js index 75211f3231..a860d01b53 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 => { if (column.calculationType) { 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<CreateAppRequest, App>) { 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<CreateAppRequest, App>) { 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<CreateAppRequest, App>) { 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<CreateAppRequest, App>) { }) } -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<Row>( + 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<Row>((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<CreateAppRequest>, app: App) { let creationFns: ((app: App) => Promise<void>)[] = [] - 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<CreateAppRequest, App>, 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/automation.ts b/packages/server/src/api/controllers/automation.ts index 6177868303..b19218647b 100644 --- a/packages/server/src/api/controllers/automation.ts +++ b/packages/server/src/api/controllers/automation.ts @@ -159,6 +159,7 @@ export async function trigger(ctx: UserCtx) { automation, { fields: ctx.request.body.fields, + user: sdk.users.getUserContextBindings(ctx.user), timeout: ctx.request.body.timeout * 1000 || env.AUTOMATION_THREAD_TIMEOUT, }, @@ -183,6 +184,7 @@ export async function trigger(ctx: UserCtx) { await triggers.externalTrigger(automation, { ...ctx.request.body, appId: ctx.appId, + user: sdk.users.getUserContextBindings(ctx.user), }) ctx.body = { message: `Automation ${automation._id} has been triggered.`, @@ -212,6 +214,7 @@ export async function test(ctx: UserCtx) { { ...testInput, appId: ctx.appId, + user: sdk.users.getUserContextBindings(ctx.user), }, { getResponses: true } ) 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<SaveRoleRequest, SaveRoleResponse>) { 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<SaveRoleRequest, SaveRoleResponse>) { }, }) } + builderSocket?.emitRoleUpdate(ctx, role) } export async function destroy(ctx: UserCtx<void, DestroyRoleResponse>) { @@ -216,6 +230,7 @@ export async function destroy(ctx: UserCtx<void, DestroyRoleResponse>) { ctx.message = `Role ${ctx.params.roleId} deleted successfully` ctx.status = 200 + builderSocket?.emitRoleDeletion(ctx, role) } export async function accessible(ctx: UserCtx<void, AccessibleRolesResponse>) { @@ -223,35 +238,23 @@ export async function accessible(ctx: UserCtx<void, AccessibleRolesResponse>) { 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/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts index 60775ce628..4d40476b7c 100644 --- a/packages/server/src/api/controllers/row/index.ts +++ b/packages/server/src/api/controllers/row/index.ts @@ -65,7 +65,14 @@ export async function patch( } ctx.status = 200 ctx.eventEmitter && - ctx.eventEmitter.emitRow(`row:update`, appId, row, table, oldRow) + ctx.eventEmitter.emitRow({ + eventName: `row:update`, + appId, + row, + table, + oldRow, + user: sdk.users.getUserContextBindings(ctx.user), + }) ctx.message = `${table.name} updated successfully.` ctx.body = row gridSocket?.emitRowUpdate(ctx, row) @@ -96,7 +103,14 @@ export const save = async (ctx: UserCtx<Row, Row>) => { sdk.rows.save(sourceId, ctx.request.body, ctx.user?._id) ) ctx.status = 200 - ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:save`, appId, row, table) + ctx.eventEmitter && + ctx.eventEmitter.emitRow({ + eventName: `row:save`, + appId, + row, + table, + user: sdk.users.getUserContextBindings(ctx.user), + }) ctx.message = `${table.name} saved successfully` // prefer squashed for response ctx.body = row || squashed @@ -168,10 +182,15 @@ async function deleteRows(ctx: UserCtx<DeleteRowRequest>) { } for (let row of rows) { - ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row) + ctx.eventEmitter && + ctx.eventEmitter.emitRow({ + eventName: `row:delete`, + appId, + row, + user: sdk.users.getUserContextBindings(ctx.user), + }) gridSocket?.emitRowDeletion(ctx, row) } - return rows } @@ -184,7 +203,13 @@ async function deleteRow(ctx: UserCtx<DeleteRowRequest>) { await quotas.removeRow() } - ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, resp.row) + ctx.eventEmitter && + ctx.eventEmitter.emitRow({ + eventName: `row:delete`, + appId, + row: resp.row, + user: sdk.users.getUserContextBindings(ctx.user), + }) gridSocket?.emitRowDeletion(ctx, resp.row) return resp diff --git a/packages/server/src/api/controllers/rowAction/run.ts b/packages/server/src/api/controllers/rowAction/run.ts index c4b6a276bf..05bdb7bace 100644 --- a/packages/server/src/api/controllers/rowAction/run.ts +++ b/packages/server/src/api/controllers/rowAction/run.ts @@ -5,6 +5,6 @@ export async function run(ctx: Ctx<RowActionTriggerRequest, void>) { const { tableId, actionId } = ctx.params const { rowId } = ctx.request.body - await sdk.rowActions.run(tableId, actionId, rowId) + await sdk.rowActions.run(tableId, actionId, rowId, ctx.user) ctx.status = 200 } diff --git a/packages/server/src/api/controllers/static/index.ts b/packages/server/src/api/controllers/static/index.ts index b4b4fdea5d..1d04811019 100644 --- a/packages/server/src/api/controllers/static/index.ts +++ b/packages/server/src/api/controllers/static/index.ts @@ -2,11 +2,12 @@ import { InvalidFileExtensions } from "@budibase/shared-core" import AppComponent from "./templates/BudibaseApp.svelte" import { join } from "../../../utilities/centralPath" import * as uuid from "uuid" -import { devClientVersion, ObjectStoreBuckets } from "../../../constants" +import { ObjectStoreBuckets } from "../../../constants" import { processString } from "@budibase/string-templates" import { loadHandlebarsFile, NODE_MODULES_PATH, + shouldServeLocally, TOP_LEVEL_PATH, } from "../../../utilities/fileSystem" import env from "../../../environment" @@ -257,25 +258,29 @@ export const serveBuilderPreview = async function (ctx: Ctx) { export const serveClientLibrary = async function (ctx: Ctx) { const version = ctx.request.query.version + if (Array.isArray(version)) { + ctx.throw(400) + } + const appId = context.getAppId() || (ctx.request.query.appId as string) let rootPath = join(NODE_MODULES_PATH, "@budibase", "client", "dist") if (!appId) { ctx.throw(400, "No app ID provided - cannot fetch client library.") } - if (env.isProd() || (env.isDev() && version !== devClientVersion)) { + + const serveLocally = shouldServeLocally(version || "") + if (!serveLocally) { ctx.body = await objectStore.getReadStream( ObjectStoreBuckets.APPS, objectStore.clientLibraryPath(appId!) ) ctx.set("Content-Type", "application/javascript") - } else if (env.isDev() && version === devClientVersion) { + } else { // incase running from TS directly const tsPath = join(require.resolve("@budibase/client"), "..") return send(ctx, "budibase-client.js", { root: !fs.existsSync(rootPath) ? tsPath : rootPath, }) - } else { - ctx.throw(500, "Unable to retrieve client library.") } } 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/rowAction.spec.ts b/packages/server/src/api/routes/tests/rowAction.spec.ts index a84e3402ca..c0b5c4210f 100644 --- a/packages/server/src/api/routes/tests/rowAction.spec.ts +++ b/packages/server/src/api/routes/tests/rowAction.spec.ts @@ -767,7 +767,6 @@ describe("/rowsActions", () => { it("can trigger an automation given valid data", async () => { expect(await getAutomationLogs()).toBeEmpty() await config.api.rowAction.trigger(viewId, rowAction.id, { rowId }) - const automationLogs = await getAutomationLogs() expect(automationLogs).toEqual([ expect.objectContaining({ @@ -785,6 +784,10 @@ describe("/rowsActions", () => { ...(await config.api.table.get(tableId)), views: expect.anything(), }, + user: expect.objectContaining({ + _id: "ro_ta_users_" + config.getUser()._id, + }), + automation: expect.objectContaining({ _id: rowAction.automationId, }), 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/automations/tests/scenarios/scenarios.spec.ts b/packages/server/src/automations/tests/scenarios/scenarios.spec.ts index 850a04a95a..358aba35c8 100644 --- a/packages/server/src/automations/tests/scenarios/scenarios.spec.ts +++ b/packages/server/src/automations/tests/scenarios/scenarios.spec.ts @@ -482,4 +482,38 @@ describe("Automation Scenarios", () => { } ) }) + + it("Check user is passed through from row trigger", async () => { + const table = await config.createTable() + + const builder = createAutomationBuilder({ + name: "Test a user is successfully passed from the trigger", + }) + + const results = await builder + .rowUpdated( + { tableId: table._id! }, + { + row: { name: "Test", description: "TEST" }, + id: "1234", + } + ) + .serverLog({ text: "{{ [user].[email] }}" }) + .run() + + expect(results.steps[0].outputs.message).toContain("example.com") + }) + + it("Check user is passed through from app trigger", async () => { + const builder = createAutomationBuilder({ + name: "Test a user is successfully passed from the trigger", + }) + + const results = await builder + .appAction({ fields: {} }) + .serverLog({ text: "{{ [user].[email] }}" }) + .run() + + expect(results.steps[0].outputs.message).toContain("example.com") + }) }) diff --git a/packages/server/src/automations/triggers.ts b/packages/server/src/automations/triggers.ts index 110ccfa37a..70fda1f237 100644 --- a/packages/server/src/automations/triggers.ts +++ b/packages/server/src/automations/triggers.ts @@ -19,6 +19,7 @@ import { AutomationStoppedReason, AutomationStatus, AutomationRowEvent, + UserBindings, } from "@budibase/types" import { executeInThread } from "../threads/automation" import { dataFilters, sdk } from "@budibase/shared-core" @@ -140,7 +141,12 @@ function rowPassesFilters(row: Row, filters: SearchFilters) { export async function externalTrigger( automation: Automation, - params: { fields: Record<string, any>; timeout?: number; appId?: string }, + params: { + fields: Record<string, any> + timeout?: number + appId?: string + user?: UserBindings + }, { getResponses }: { getResponses?: boolean } = {} ): Promise<any> { if (automation.disabled) { diff --git a/packages/server/src/constants/index.ts b/packages/server/src/constants/index.ts index 316d27b3fc..bac838b53e 100644 --- a/packages/server/src/constants/index.ts +++ b/packages/server/src/constants/index.ts @@ -152,8 +152,6 @@ export enum AutomationErrors { FAILURE_CONDITION = "FAILURE_CONDITION_MET", } -export const devClientVersion = "0.0.0" - // pass through the list from the auth/core lib export const ObjectStoreBuckets = objectStore.ObjectStoreBuckets export const MAX_AUTOMATION_RECURRING_ERRORS = 5 diff --git a/packages/server/src/definitions/automations.ts b/packages/server/src/definitions/automations.ts index 9433075da7..d551584fd1 100644 --- a/packages/server/src/definitions/automations.ts +++ b/packages/server/src/definitions/automations.ts @@ -1,4 +1,4 @@ -import { AutomationResults, LoopStepType } from "@budibase/types" +import { AutomationResults, LoopStepType, UserBindings } from "@budibase/types" export interface LoopInput { option: LoopStepType @@ -18,5 +18,6 @@ export interface AutomationContext extends AutomationResults { stepsById: Record<string, any> stepsByName: Record<string, any> env?: Record<string, string> + user?: UserBindings trigger: any } diff --git a/packages/server/src/events/AutomationEmitter.ts b/packages/server/src/events/AutomationEmitter.ts index 32fd130929..a63273bdc0 100644 --- a/packages/server/src/events/AutomationEmitter.ts +++ b/packages/server/src/events/AutomationEmitter.ts @@ -31,7 +31,17 @@ class AutomationEmitter { } } - async emitRow(eventName: string, appId: string, row: Row, table?: Table) { + async emitRow({ + eventName, + appId, + row, + table, + }: { + eventName: string + appId: string + row: Row + table?: Table + }) { let MAX_AUTOMATION_CHAIN = await this.getMaxAutomationChain() // don't emit even if we've reached max automation chain diff --git a/packages/server/src/events/BudibaseEmitter.ts b/packages/server/src/events/BudibaseEmitter.ts index 8feb36bbf5..c8983096d0 100644 --- a/packages/server/src/events/BudibaseEmitter.ts +++ b/packages/server/src/events/BudibaseEmitter.ts @@ -1,6 +1,6 @@ import { EventEmitter } from "events" import { rowEmission, tableEmission } from "./utils" -import { Table, Row } from "@budibase/types" +import { Table, Row, User } from "@budibase/types" /** * keeping event emitter in one central location as it might be used for things other than @@ -13,14 +13,22 @@ import { Table, Row } from "@budibase/types" * This is specifically quite important for template strings used in automations. */ class BudibaseEmitter extends EventEmitter { - emitRow( - eventName: string, - appId: string, - row: Row, - table?: Table, + emitRow({ + eventName, + appId, + row, + table, + oldRow, + user, + }: { + eventName: string + appId: string + row: Row + table?: Table oldRow?: Row - ) { - rowEmission({ emitter: this, eventName, appId, row, table, oldRow }) + user: User + }) { + rowEmission({ emitter: this, eventName, appId, row, table, oldRow, user }) } emitTable(eventName: string, appId: string, table?: Table) { diff --git a/packages/server/src/events/utils.ts b/packages/server/src/events/utils.ts index b972c8e473..5e4a1bebbf 100644 --- a/packages/server/src/events/utils.ts +++ b/packages/server/src/events/utils.ts @@ -1,4 +1,4 @@ -import { Table, Row } from "@budibase/types" +import { Table, Row, User } from "@budibase/types" import BudibaseEmitter from "./BudibaseEmitter" type BBEventOpts = { @@ -9,6 +9,7 @@ type BBEventOpts = { row?: Row oldRow?: Row metadata?: any + user?: User } interface BBEventTable extends Table { @@ -24,6 +25,7 @@ type BBEvent = { id?: string revision?: string metadata?: any + user?: User } export function rowEmission({ @@ -34,12 +36,14 @@ export function rowEmission({ table, metadata, oldRow, + user, }: BBEventOpts) { let event: BBEvent = { row, oldRow, appId, tableId: row?.tableId, + user, } if (table) { event.table = table 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/sdk/app/rowActions.ts b/packages/server/src/sdk/app/rowActions.ts index 4b128ae003..08a173ee13 100644 --- a/packages/server/src/sdk/app/rowActions.ts +++ b/packages/server/src/sdk/app/rowActions.ts @@ -4,6 +4,7 @@ import { AutomationTriggerStepId, SEPARATOR, TableRowActions, + User, VirtualDocumentType, } from "@budibase/types" import { generateRowActionsID } from "../../db/utils" @@ -236,7 +237,12 @@ export async function remove(tableId: string, rowActionId: string) { }) } -export async function run(tableId: any, rowActionId: any, rowId: string) { +export async function run( + tableId: any, + rowActionId: any, + rowId: string, + user: User +) { const table = await sdk.tables.getTable(tableId) if (!table) { throw new HTTPError("Table not found", 404) @@ -260,6 +266,7 @@ export async function run(tableId: any, rowActionId: any, rowId: string) { row, table, }, + user, appId: context.getAppId(), }, { getResponses: true } diff --git a/packages/server/src/sdk/users/utils.ts b/packages/server/src/sdk/users/utils.ts index 74389a1444..8266d3e28a 100644 --- a/packages/server/src/sdk/users/utils.ts +++ b/packages/server/src/sdk/users/utils.ts @@ -12,6 +12,7 @@ import { UserMetadata, Database, ContextUserMetadata, + UserBindings, } from "@budibase/types" export function combineMetadataAndUser( @@ -125,7 +126,7 @@ export async function syncGlobalUsers() { } } -export function getUserContextBindings(user: ContextUser) { +export function getUserContextBindings(user: ContextUser): UserBindings { if (!user) { return {} } 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<App> => { - const files = app.templateFile ? { templateFile: app.templateFile } : {} - delete app.templateFile + const files = app.fileToImport ? { fileToImport: app.fileToImport } : {} + delete app.fileToImport return await this._post<App>("/api/applications", { fields: app, files, diff --git a/packages/server/src/tests/utilities/structures.ts b/packages/server/src/tests/utilities/structures.ts index b38cc6484f..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( diff --git a/packages/server/src/threads/automation.ts b/packages/server/src/threads/automation.ts index 3b47634663..0f17b44424 100644 --- a/packages/server/src/threads/automation.ts +++ b/packages/server/src/threads/automation.ts @@ -26,6 +26,7 @@ import { BranchStep, LoopStep, SearchFilters, + UserBindings, } from "@budibase/types" import { AutomationContext, TriggerOutput } from "../definitions/automations" import { WorkerCallback } from "./definitions" @@ -75,6 +76,7 @@ class Orchestrator { private loopStepOutputs: LoopStep[] private stopped: boolean private executionOutput: Omit<AutomationContext, "stepsByName" | "stepsById"> + private currentUser: UserBindings | undefined constructor(job: AutomationJob) { let automation = job.data.automation @@ -106,6 +108,7 @@ class Orchestrator { this.updateExecutionOutput(triggerId, triggerStepId, null, triggerOutput) this.loopStepOutputs = [] this.stopped = false + this.currentUser = triggerOutput.user } cleanupTriggerOutputs(stepId: string, triggerOutput: TriggerOutput) { @@ -258,6 +261,7 @@ class Orchestrator { automationId: this.automation._id, }) this.context.env = await sdkUtils.getEnvironmentVariables() + this.context.user = this.currentUser let metadata @@ -572,7 +576,6 @@ class Orchestrator { originalStepInput, this.processContext(this.context) ) - inputs = automationUtils.cleanInputValues(inputs, step.schema.inputs) const outputs = await stepFn({ diff --git a/packages/server/src/utilities/fileSystem/app.ts b/packages/server/src/utilities/fileSystem/app.ts index c708a9422b..9bd88ba0b1 100644 --- a/packages/server/src/utilities/fileSystem/app.ts +++ b/packages/server/src/utilities/fileSystem/app.ts @@ -1,8 +1,8 @@ import { budibaseTempDir } from "../budibaseDir" import fs from "fs" import { join } from "path" -import { ObjectStoreBuckets, devClientVersion } from "../../constants" -import { updateClientLibrary } from "./clientLibrary" +import { ObjectStoreBuckets } from "../../constants" +import { shouldServeLocally, updateClientLibrary } from "./clientLibrary" import env from "../../environment" import { objectStore, context } from "@budibase/backend-core" import { TOP_LEVEL_PATH } from "./filesystem" @@ -40,7 +40,7 @@ export const getComponentLibraryManifest = async (library: string) => { const db = context.getAppDB() const app = await db.get<App>(DocumentType.APP_METADATA) - if (app.version === devClientVersion || env.isTest()) { + if (shouldServeLocally(app.version) || env.isTest()) { const paths = [ join(TOP_LEVEL_PATH, "packages/client", filename), join(process.cwd(), "client", filename), diff --git a/packages/server/src/utilities/fileSystem/clientLibrary.ts b/packages/server/src/utilities/fileSystem/clientLibrary.ts index c994502995..faa328fdc5 100644 --- a/packages/server/src/utilities/fileSystem/clientLibrary.ts +++ b/packages/server/src/utilities/fileSystem/clientLibrary.ts @@ -1,3 +1,4 @@ +import semver from "semver" import path, { join } from "path" import { ObjectStoreBuckets } from "../../constants" import fs from "fs" @@ -183,3 +184,19 @@ export async function revertClientLibrary(appId: string) { return JSON.parse(await manifestSrc) } + +export function shouldServeLocally(version: string) { + if (env.isProd() || !env.isDev()) { + return false + } + + if (version === "0.0.0") { + return true + } + + const parsedSemver = semver.parse(version) + if (parsedSemver?.build?.[0] === "local") { + return true + } + return false +} 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/automation.ts b/packages/types/src/documents/app/automation/automation.ts index effe99a328..1af892d8d1 100644 --- a/packages/types/src/documents/app/automation/automation.ts +++ b/packages/types/src/documents/app/automation/automation.ts @@ -261,6 +261,7 @@ export type UpdatedRowEventEmitter = { oldRow: Row table: Table appId: string + user: User } export enum LoopStepType { 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<T extends Hosting> = { 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/types/src/documents/global/user.ts b/packages/types/src/documents/global/user.ts index a1c5b2506f..829a171843 100644 --- a/packages/types/src/documents/global/user.ts +++ b/packages/types/src/documents/global/user.ts @@ -68,6 +68,16 @@ export interface User extends Document { appSort?: string } +export interface UserBindings extends Document { + firstName?: string + lastName?: string + email?: string + status?: string + roleId?: string | null + globalId?: string + userId?: string +} + export enum UserStatus { ACTIVE = "active", INACTIVE = "inactive", diff --git a/packages/types/src/sdk/automations/index.ts b/packages/types/src/sdk/automations/index.ts index d04f126c32..9ceded03ee 100644 --- a/packages/types/src/sdk/automations/index.ts +++ b/packages/types/src/sdk/automations/index.ts @@ -1,4 +1,9 @@ -import { Automation, AutomationMetadata, Row } from "../../documents" +import { + Automation, + AutomationMetadata, + Row, + UserBindings, +} from "../../documents" import { Job } from "bull" export interface AutomationDataEvent { @@ -8,6 +13,7 @@ export interface AutomationDataEvent { timeout?: number row?: Row oldRow?: Row + user?: UserBindings } export interface AutomationData { 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<Config>) { 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<void, GetPublicOIDCConfigResponse>) { 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"