Merge branch 'v3-ui' of github.com:Budibase/budibase into view-calculation-ui
This commit is contained in:
commit
470ace7dad
|
@ -83,7 +83,7 @@ function getPackageJsonFields(): {
|
|||
if (isDev() && !isTest()) {
|
||||
try {
|
||||
const lerna = getParentFile("lerna.json")
|
||||
localVersion = lerna.version
|
||||
localVersion = `${lerna.version}+local`
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -34,7 +34,7 @@
|
|||
|
||||
const generateAutomation = () => {
|
||||
popover?.hide()
|
||||
dispatch("request-generate")
|
||||
dispatch("generate")
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
|
||||
const generateScreen = () => {
|
||||
popover?.hide()
|
||||
dispatch("request-generate")
|
||||
dispatch("generate")
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,8 @@
|
|||
<script>
|
||||
import { SvelteFlowProvider } from "@xyflow/svelte"
|
||||
import RoleFlow from "./RoleFlow.svelte"
|
||||
</script>
|
||||
|
||||
<SvelteFlowProvider>
|
||||
<RoleFlow />
|
||||
</SvelteFlowProvider>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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"
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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} />
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -503,7 +503,7 @@
|
|||
on:save={saveQuery}
|
||||
/>
|
||||
<div class="access">
|
||||
<Label>Access level</Label>
|
||||
<Label>Access</Label>
|
||||
<AccessLevelSelect {query} {saveId} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
<script>
|
||||
import RoleEditor from "components/backend/RoleEditor/RoleEditor.svelte"
|
||||
import { builderStore } from "stores/builder"
|
||||
|
||||
builderStore.selectResource("roles")
|
||||
</script>
|
||||
|
||||
<RoleEditor />
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
{
|
||||
"name": "Layout",
|
||||
"icon": "ClassicGridView",
|
||||
"children": ["container", "section", "sidepanel", "modal"]
|
||||
"children": ["container", "sidepanel", "modal"]
|
||||
},
|
||||
{
|
||||
"name": "Data",
|
||||
|
|
|
@ -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 -->
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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} />
|
|
@ -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>
|
|
@ -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>
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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")
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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>
|
|
@ -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) {
|
||||
|
|
|
@ -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)"
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 }
|
||||
)
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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 {}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -97,6 +97,7 @@ export enum BuilderSocketEvent {
|
|||
SelectResource = "SelectResource",
|
||||
AppPublishChange = "AppPublishChange",
|
||||
AutomationChange = "AutomationChange",
|
||||
RoleChange = "RoleChange",
|
||||
}
|
||||
|
||||
export const SocketSessionTTL = 60
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
@ -261,6 +261,7 @@ export type UpdatedRowEventEmitter = {
|
|||
oldRow: Row
|
||||
table: Table
|
||||
appId: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export enum LoopStepType {
|
||||
|
|
|
@ -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
|
||||
>
|
||||
}
|
||||
: {})
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
145
yarn.lock
145
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"
|
||||
|
|
Loading…
Reference in New Issue