Merge branch 'v3-ui' into queryui-default

This commit is contained in:
Sam Rose 2024-10-23 13:59:53 +01:00
commit 17c9d9f9e1
No known key found for this signature in database
74 changed files with 1908 additions and 718 deletions

View File

@ -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
)
}

View File

@ -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")

View File

@ -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
@ -8,6 +9,7 @@
export let url = null
export let hoverable = false
export let showArrow = false
export let selected = false
</script>
<a
@ -15,9 +17,12 @@
class="list-item"
class:hoverable={hoverable || url != null}
on:click
class:selected
>
<div class="left">
{#if icon}
{#if icon === "StatusLight"}
<StatusLight square size="L" color={iconColor} />
{:else if icon}
<Icon name={icon} color={iconColor} />
{/if}
<div class="list-item__text">
@ -43,7 +48,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;
@ -66,15 +71,20 @@
}
.hoverable:hover {
cursor: pointer;
}
.hoverable:not(.selected):hover {
background: var(--spectrum-global-color-gray-200);
}
.selected {
background: var(--spectrum-global-color-blue-100);
}
.left,
.right {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-s);
gap: var(--spacing-m);
}
.left {
width: 0;

View File

@ -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"

View File

@ -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",

View File

@ -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>

View File

@ -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>

View File

@ -34,7 +34,7 @@
const generateAutomation = () => {
popover?.hide()
dispatch("request-generate")
dispatch("generate")
}
</script>

View File

@ -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

View File

@ -22,7 +22,7 @@
const generateScreen = () => {
popover?.hide()
dispatch("request-generate")
dispatch("generate")
}
</script>

View File

@ -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}
/>

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,8 @@
<script>
import { SvelteFlowProvider } from "@xyflow/svelte"
import RoleFlow from "./RoleFlow.svelte"
</script>
<SvelteFlowProvider>
<RoleFlow />
</SvelteFlowProvider>

View File

@ -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>

View File

@ -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>

View File

@ -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"

View File

@ -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,
}
}

View File

@ -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} />

View File

@ -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 => {

View File

@ -59,6 +59,7 @@
bind:this={drawer}
title="Filtering"
on:drawerHide
on:drawerShow
on:drawerShow={() => {
// Reset to the currently available value.
localFilters = Helpers.cloneDeep(value)

View File

@ -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}
/>

View File

@ -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}

View File

@ -503,7 +503,7 @@
on:save={saveQuery}
/>
<div class="access">
<Label>Access level</Label>
<Label>Access</Label>
<AccessLevelSelect {query} {saveId} />
</div>
</div>

View File

@ -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

View File

@ -6,7 +6,6 @@ export const TriggerStepID = {
WEBHOOK: "WEBHOOK",
APP: "APP",
CRON: "CRON",
ROW_ACTION: "ROW_ACTION",
}
export const ActionStepID = {

View File

@ -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 },
}
})
}

View File

@ -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) {

View File

@ -0,0 +1,8 @@
<script>
import RoleEditor from "components/backend/RoleEditor/RoleEditor.svelte"
import { builderStore } from "stores/builder"
builderStore.selectResource("roles")
</script>
<RoleEditor />

View File

@ -56,15 +56,13 @@
buttonsCollapsed
>
<svelte:fragment slot="controls">
<GridManageAccessButton />
<GridFilterButton />
<GridSortButton />
<GridSizeButton />
<GridColumnsSettingButton />
<GridManageAccessButton />
<GridRowActionsButton />
<GridScreensButton on:request-generate={() => generateButton?.show()} />
</svelte:fragment>
<svelte:fragment slot="controls-right">
<GridScreensButton on:generate={() => generateButton?.show()} />
<GridGenerateButton bind:this={generateButton} />
</svelte:fragment>
<GridCreateEditRowModal />

View File

@ -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>

View File

@ -4,9 +4,11 @@
export let title
export let body
export let icon = "HelpOutline"
export let warning = false
export let error = false
</script>
<div class="info" class:noTitle={!title}>
<div class="info" class:noTitle={!title} class:warning class:error>
{#if title}
<div class="title">
<Icon name={icon} />
@ -16,7 +18,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}
@ -24,6 +26,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;
@ -39,17 +58,7 @@
.icon {
color: var(--spectrum-global-color-gray-600);
}
.info {
padding: var(--spacing-m) var(--spacing-l) var(--spacing-l) var(--spacing-l);
background-color: var(--background-alt);
border-radius: var(--border-radius-s);
font-size: 13px;
}
.noTitle {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
.info :global(a) {
color: inherit;
transition: color 130ms ease-out;

View File

@ -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 -->

View File

@ -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 {

View File

@ -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>

View File

@ -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}

View File

@ -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} />

View File

@ -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>

View File

@ -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>

View File

@ -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,
}
}

View File

@ -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)
})

View File

@ -126,6 +126,9 @@
}
const extendQuery = (defaultQuery, extensions) => {
if (!Object.keys(extensions).length) {
return defaultQuery
}
const extended = {
[LogicalOperator.AND]: {
conditions: [

View File

@ -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>

View File

@ -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],

View File

@ -1,3 +1,7 @@
<script context="module">
const NumberFormatter = Intl.NumberFormat()
</script>
<script>
import TextCell from "./TextCell.svelte"
@ -9,6 +13,24 @@
const newValue = isNaN(float) ? null : float
onChange(newValue)
}
const formatNumber = value => {
const type = typeof value
if (type !== "string" && type !== "number") {
return ""
}
if (type === "string" && !value.trim().length) {
return ""
}
const res = NumberFormatter.format(value)
return res === "NaN" ? value : res
}
</script>
<TextCell {...$$props} onChange={numberOnChange} bind:api type="number" />
<TextCell
{...$$props}
onChange={numberOnChange}
bind:api
type="number"
format={formatNumber}
/>

View File

@ -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>

View File

@ -7,11 +7,13 @@
export let type = "text"
export let readonly = false
export let api
export let format = null
let input
let active = false
$: editable = focused && !readonly
$: displayValue = format?.(value) ?? value ?? ""
const handleChange = e => {
onChange(e.target.value)
@ -52,7 +54,7 @@
{:else}
<div class="text-cell" class:number={type === "number"}>
<div class="value">
{value ?? ""}
{displayValue}
</div>
</div>
{/if}

View File

@ -16,6 +16,7 @@ import AttachmentSingleCell from "../cells/AttachmentSingleCell.svelte"
import BBReferenceCell from "../cells/BBReferenceCell.svelte"
import SignatureCell from "../cells/SignatureCell.svelte"
import BBReferenceSingleCell from "../cells/BBReferenceSingleCell.svelte"
import RoleCell from "../cells/RoleCell.svelte"
const TypeComponentMap = {
[FieldType.STRING]: TextCell,
@ -35,6 +36,9 @@ const TypeComponentMap = {
[FieldType.JSON]: JSONCell,
[FieldType.BB_REFERENCE]: BBReferenceCell,
[FieldType.BB_REFERENCE_SINGLE]: BBReferenceSingleCell,
// Custom types for UI only
role: RoleCell,
}
export const getCellRenderer = column => {
return (

View File

@ -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)"
)
}

View File

@ -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",
}

View File

@ -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) {

View File

@ -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))
}

View File

@ -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()

View File

@ -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", () => {

View File

@ -2087,7 +2087,6 @@ describe.each([
)
tableId = table._id!
// @ts-ignore - until AI implemented
const rowValues: Record<keyof typeof fullSchema, any> = {
[FieldType.STRING]: generator.guid(),
[FieldType.LONGFORM]: generator.paragraph(),

View File

@ -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", () => {

View File

@ -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)
)

View File

@ -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)

View File

@ -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
}
}

View File

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

View File

@ -8,31 +8,31 @@ import {
} from "../../automations"
import {
AIOperationEnum,
AutoFieldSubType,
Automation,
AutomationActionStepId,
AutomationEventType,
AutomationResults,
AutomationStatus,
AutomationStep,
AutomationStepType,
AutomationTrigger,
AutomationTriggerStepId,
BBReferenceFieldSubType,
CreateViewRequest,
Datasource,
FieldSchema,
FieldType,
INTERNAL_TABLE_SOURCE_ID,
JsonFieldSubType,
LoopStepType,
Query,
Role,
SourceName,
Table,
INTERNAL_TABLE_SOURCE_ID,
TableSourceType,
Query,
Webhook,
WebhookActionType,
AutomationEventType,
LoopStepType,
FieldSchema,
BBReferenceFieldSubType,
JsonFieldSubType,
AutoFieldSubType,
Role,
CreateViewRequest,
} from "@budibase/types"
import { LoopInput } from "../../definitions/automations"
import { merge } from "lodash"
@ -439,7 +439,7 @@ export function updateRowAutomationWithFilters(
appId: string,
tableId: string
): Automation {
const automation: Automation = {
return {
name: "updateRowWithFilters",
type: "automation",
appId,
@ -472,7 +472,6 @@ export function updateRowAutomationWithFilters(
},
},
}
return automation
}
export function basicAutomationResults(
@ -606,7 +605,6 @@ export function fullSchemaWithoutLinks({
}): {
[type in Exclude<FieldType, FieldType.LINK>]: FieldSchema & { type: type }
} {
// @ts-ignore - until AI implemented
return {
[FieldType.STRING]: {
name: "string",

View File

@ -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

View File

@ -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")

View File

@ -97,6 +97,7 @@ export enum BuilderSocketEvent {
SelectResource = "SelectResource",
AppPublishChange = "AppPublishChange",
AutomationChange = "AutomationChange",
RoleChange = "RoleChange",
}
export const SocketSessionTTL = 60

View File

@ -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 }
}

View File

@ -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
>
}
: {})

View File

@ -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)

View File

@ -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
View File

@ -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"