diff --git a/packages/backend-core/src/security/roles.ts b/packages/backend-core/src/security/roles.ts index c14178cacb1..7d9a11db483 100644 --- a/packages/backend-core/src/security/roles.ts +++ b/packages/backend-core/src/security/roles.ts @@ -213,6 +213,22 @@ export function getBuiltinRole(roleId: string): Role | undefined { return cloneDeep(role) } +export function validInherits( + allRoles: RoleDoc[], + inherits?: string | string[] +): boolean { + if (!inherits) { + return false + } + const find = (id: string) => allRoles.find(r => roleIDsAreEqual(r._id!, id)) + if (Array.isArray(inherits)) { + const filtered = inherits.filter(roleId => find(roleId)) + return inherits.length !== 0 && filtered.length === inherits.length + } else { + return !!find(inherits) + } +} + /** * Works through the inheritance ranks to see how far up the builtin stack this ID is. */ @@ -290,7 +306,7 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string { : roleId1 } -export function compareRoleIds(roleId1: string, roleId2: string) { +export function roleIDsAreEqual(roleId1: string, roleId2: string) { // make sure both role IDs are prefixed correctly return prefixRoleID(roleId1) === prefixRoleID(roleId2) } @@ -323,7 +339,7 @@ export function findRole( roleId = prefixRoleID(roleId) } const dbRole = roles.find( - role => role._id && compareRoleIds(role._id, roleId) + role => role._id && roleIDsAreEqual(role._id, roleId) ) if (!dbRole && !isBuiltin(roleId) && opts?.defaultPublic) { return cloneDeep(BUILTIN_ROLES.PUBLIC) @@ -557,7 +573,7 @@ export class AccessController { } return ( - roleIds?.find(roleId => compareRoleIds(roleId, tryingRoleId)) !== + roleIds?.find(roleId => roleIDsAreEqual(roleId, tryingRoleId)) !== undefined ) } diff --git a/packages/bbui/src/InlineAlert/InlineAlert.svelte b/packages/bbui/src/InlineAlert/InlineAlert.svelte index 3b98936f627..edfa760eb8a 100644 --- a/packages/bbui/src/InlineAlert/InlineAlert.svelte +++ b/packages/bbui/src/InlineAlert/InlineAlert.svelte @@ -8,6 +8,7 @@ export let onConfirm = undefined export let buttonText = "" export let cta = false + $: icon = selectIcon(type) // if newlines used, convert them to different elements $: split = message.split("\n") diff --git a/packages/bbui/src/List/ListItem.svelte b/packages/bbui/src/List/ListItem.svelte index 5b6152781a9..699df2d4560 100644 --- a/packages/bbui/src/List/ListItem.svelte +++ b/packages/bbui/src/List/ListItem.svelte @@ -1,5 +1,6 @@
- {#if icon} + {#if icon === "StatusLight"} + + {:else if icon} {/if}
@@ -43,7 +48,7 @@ diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridAutomationsButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridAutomationsButton.svelte index 9670bb2f358..b2448eeaf31 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridAutomationsButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridAutomationsButton.svelte @@ -34,7 +34,7 @@ const generateAutomation = () => { popover?.hide() - dispatch("request-generate") + dispatch("generate") } diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridGenerateButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridGenerateButton.svelte index 5cc3aca19e4..5d1ec835d54 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridGenerateButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridGenerateButton.svelte @@ -91,7 +91,7 @@ - +
magic wand Generate diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridScreensButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridScreensButton.svelte index 701a286112a..db446b3c9e5 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridScreensButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridScreensButton.svelte @@ -22,7 +22,7 @@ const generateScreen = () => { popover?.hide() - dispatch("request-generate") + dispatch("generate") } diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditUser.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditUser.svelte index a9990d0c2b3..5ac2beab65c 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditUser.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditUser.svelte @@ -125,7 +125,7 @@ label="Role" bind:value={row.roleId} options={$roles} - getOptionLabel={role => role.name} + getOptionLabel={role => role.uiMetadata.displayName} getOptionValue={role => role._id} disabled={!creating} /> diff --git a/packages/builder/src/components/backend/DataTable/modals/EditRoles.svelte b/packages/builder/src/components/backend/DataTable/modals/EditRoles.svelte deleted file mode 100644 index 6cec28de1dc..00000000000 --- a/packages/builder/src/components/backend/DataTable/modals/EditRoles.svelte +++ /dev/null @@ -1,174 +0,0 @@ - - - - {#if errors.length} - - {/if} - - x._id} - getOptionLabel={x => x.name} - disabled={shouldDisableRoleInput} - /> - {/if} -
- {#if !isCreating && !builtInRoles.includes(selectedRole.name)} - - {/if} -
-
diff --git a/packages/builder/src/components/backend/DataTable/modals/ManageAccessModal.svelte b/packages/builder/src/components/backend/DataTable/modals/ManageAccessModal.svelte deleted file mode 100644 index bc5c437c572..00000000000 --- a/packages/builder/src/components/backend/DataTable/modals/ManageAccessModal.svelte +++ /dev/null @@ -1,127 +0,0 @@ - - -Specify the minimum access level role for this data. -
- - - {#each Object.keys(computedPermissions) as level} - - (tempDisplayName = e.detail)} + /> + (tempDescription = e.detail)} + /> +
+ + (tempColor = e.detail)} /> +
+ + + + diff --git a/packages/builder/src/components/backend/RoleEditor/constants.js b/packages/builder/src/components/backend/RoleEditor/constants.js new file mode 100644 index 00000000000..6f188e2141e --- /dev/null +++ b/packages/builder/src/components/backend/RoleEditor/constants.js @@ -0,0 +1,9 @@ +export const ZoomDuration = 300 +export const MaxAutoZoom = 1.2 +export const GridResolution = 20 +export const NodeHeight = GridResolution * 3 +export const NodeWidth = GridResolution * 12 +export const NodeHSpacing = GridResolution * 6 +export const NodeVSpacing = GridResolution * 2 +export const MinHeight = GridResolution * 10 +export const EmptyStateID = "empty" diff --git a/packages/builder/src/components/backend/RoleEditor/utils.js b/packages/builder/src/components/backend/RoleEditor/utils.js new file mode 100644 index 00000000000..a958fc6401b --- /dev/null +++ b/packages/builder/src/components/backend/RoleEditor/utils.js @@ -0,0 +1,245 @@ +import dagre from "@dagrejs/dagre" +import { + NodeWidth, + NodeHeight, + GridResolution, + NodeHSpacing, + NodeVSpacing, + MinHeight, + EmptyStateID, +} from "./constants" +import { getNodesBounds, Position } from "@xyflow/svelte" +import { Roles } from "constants/backend" +import { roles } from "stores/builder" +import { get } from "svelte/store" + +// Calculates the bounds of all custom nodes +export const getBounds = nodes => { + const interactiveNodes = nodes.filter(node => node.data.interactive) + + // Empty state bounds which line up with bounds after adding first node + if (!interactiveNodes.length) { + return { + x: 0, + y: -3.5 * GridResolution, + width: 12 * GridResolution, + height: 10 * GridResolution, + } + } + let bounds = getNodesBounds(interactiveNodes) + + // Enforce a min size + if (bounds.height < MinHeight) { + const diff = MinHeight - bounds.height + bounds.height = MinHeight + bounds.y -= diff / 2 + } + return bounds +} + +// Gets the position of the basic role +export const getBasicPosition = bounds => ({ + x: bounds.x - NodeHSpacing - NodeWidth, + y: bounds.y + bounds.height / 2 - NodeHeight / 2, +}) + +// Gets the position of the admin role +export const getAdminPosition = bounds => ({ + x: bounds.x + bounds.width + NodeHSpacing, + y: bounds.y + bounds.height / 2 - NodeHeight / 2, +}) + +// Filters out invalid nodes and edges +const preProcessLayout = ({ nodes, edges }) => { + const ignoredIds = [Roles.PUBLIC, Roles.BASIC, Roles.ADMIN, EmptyStateID] + const targetlessIds = [Roles.POWER] + return { + nodes: nodes.filter(node => { + // Filter out ignored IDs + if (ignoredIds.includes(node.id)) { + return false + } + return true + }), + edges: edges.filter(edge => { + // Filter out edges from ignored IDs + if ( + ignoredIds.includes(edge.source) || + ignoredIds.includes(edge.target) + ) { + return false + } + // Filter out edges which have the same source and target + if (edge.source === edge.target) { + return false + } + // Filter out edges which target targetless roles + if (targetlessIds.includes(edge.target)) { + return false + } + return true + }), + } +} + +// Updates positions of nodes and edges into a nice graph structure +export const dagreLayout = ({ nodes, edges }) => { + const dagreGraph = new dagre.graphlib.Graph() + dagreGraph.setDefaultEdgeLabel(() => ({})) + dagreGraph.setGraph({ + rankdir: "LR", + ranksep: NodeHSpacing, + nodesep: NodeVSpacing, + }) + nodes.forEach(node => { + dagreGraph.setNode(node.id, { width: NodeWidth, height: NodeHeight }) + }) + edges.forEach(edge => { + dagreGraph.setEdge(edge.source, edge.target) + }) + dagre.layout(dagreGraph) + nodes.forEach(node => { + const pos = dagreGraph.node(node.id) + node.targetPosition = Position.Left + node.sourcePosition = Position.Right + node.position = { + x: Math.round((pos.x - NodeWidth / 2) / GridResolution) * GridResolution, + y: Math.round((pos.y - NodeHeight / 2) / GridResolution) * GridResolution, + } + }) + return { nodes, edges } +} + +const postProcessLayout = ({ nodes, edges }) => { + // Add basic and admin nodes at each edge + const bounds = getBounds(nodes) + const $roles = get(roles) + nodes.push({ + ...roleToNode($roles.find(role => role._id === Roles.BASIC)), + position: getBasicPosition(bounds), + }) + nodes.push({ + ...roleToNode($roles.find(role => role._id === Roles.ADMIN)), + position: getAdminPosition(bounds), + }) + + // Add custom edges for basic and admin brackets + edges.push({ + id: "basic-bracket", + source: Roles.BASIC, + target: Roles.ADMIN, + type: "bracket", + }) + edges.push({ + id: "admin-bracket", + source: Roles.ADMIN, + target: Roles.BASIC, + type: "bracket", + }) + + // Add empty state node if required + if (!nodes.some(node => node.data.interactive)) { + nodes.push({ + id: EmptyStateID, + type: "empty", + position: { + x: bounds.x + bounds.width / 2 - NodeWidth / 2, + y: bounds.y + bounds.height / 2 - NodeHeight / 2, + }, + data: {}, + measured: { + width: NodeWidth, + height: NodeHeight, + }, + deletable: false, + draggable: false, + connectable: false, + selectable: false, + }) + } + + return { nodes, edges } +} + +// Automatically lays out the graph, sanitising and enriching the structure +export const autoLayout = ({ nodes, edges }) => { + return postProcessLayout(dagreLayout(preProcessLayout({ nodes, edges }))) +} + +// Converts a role doc into a node structure +export const roleToNode = role => { + const custom = ![ + Roles.PUBLIC, + Roles.BASIC, + Roles.POWER, + Roles.ADMIN, + Roles.BUILDER, + ].includes(role._id) + const interactive = custom || role._id === Roles.POWER + return { + id: role._id, + sourcePosition: Position.Right, + targetPosition: Position.Left, + type: "role", + position: { x: 0, y: 0 }, + data: { + ...role.uiMetadata, + custom, + interactive, + }, + measured: { + width: NodeWidth, + height: NodeHeight, + }, + deletable: custom, + draggable: interactive, + connectable: interactive, + selectable: interactive, + } +} + +// Converts a node structure back into a role doc +export const nodeToRole = ({ node, edges }) => ({ + ...get(roles).find(role => role._id === node.id), + inherits: edges + .filter(x => x.target === node.id) + .map(x => x.source) + .concat(Roles.BASIC), + uiMetadata: { + displayName: node.data.displayName, + color: node.data.color, + description: node.data.description, + }, +}) + +// Builds a default layout from an array of roles +export const rolesToLayout = roles => { + let nodes = [] + let edges = [] + + // Add all nodes and edges + for (let role of roles) { + // Add node for this role + nodes.push(roleToNode(role)) + + // Add edges for this role + let inherits = [] + if (role.inherits) { + inherits = Array.isArray(role.inherits) ? role.inherits : [role.inherits] + } + for (let sourceRole of inherits) { + if (!roles.some(x => x._id === sourceRole)) { + continue + } + edges.push({ + id: `${sourceRole}-${role._id}`, + source: sourceRole, + target: role._id, + }) + } + } + return { + nodes, + edges, + } +} diff --git a/packages/builder/src/components/common/RoleIcon.svelte b/packages/builder/src/components/common/RoleIcon.svelte index 1bd6ba49bcc..3b48935e0c5 100644 --- a/packages/builder/src/components/common/RoleIcon.svelte +++ b/packages/builder/src/components/common/RoleIcon.svelte @@ -1,12 +1,14 @@ diff --git a/packages/builder/src/components/common/RoleSelect.svelte b/packages/builder/src/components/common/RoleSelect.svelte index 6006b8ab8d1..38b84e964d9 100644 --- a/packages/builder/src/components/common/RoleSelect.svelte +++ b/packages/builder/src/components/common/RoleSelect.svelte @@ -3,7 +3,7 @@ import { roles } from "stores/builder" import { licensing } from "stores/portal" - import { Constants, RoleUtils } from "@budibase/frontend-core" + import { Constants } from "@budibase/frontend-core" import { createEventDispatcher } from "svelte" import { capitalise } from "helpers" @@ -49,7 +49,8 @@ let options = roles .filter(role => allowedRoles.includes(role._id)) .map(role => ({ - name: enrichLabel(role.name), + color: role.uiMetadata.color, + name: enrichLabel(role.uiMetadata.displayName), _id: role._id, })) if (allowedRoles.includes(Constants.Roles.CREATOR)) { @@ -64,7 +65,8 @@ // Allow all core roles let options = roles.map(role => ({ - name: enrichLabel(role.name), + color: role.uiMetadata.color, + name: enrichLabel(role.uiMetadata.displayName), _id: role._id, })) @@ -100,7 +102,7 @@ if (role._id === Constants.Roles.CREATOR || role._id === RemoveID) { return null } - return RoleUtils.getRoleColour(role._id) + return role.color || "var(--spectrum-global-color-static-magenta-400)" } const getIcon = role => { diff --git a/packages/builder/src/components/design/settings/controls/RoleSelect.svelte b/packages/builder/src/components/design/settings/controls/RoleSelect.svelte index 5b5daac1830..b99e58f2058 100644 --- a/packages/builder/src/components/design/settings/controls/RoleSelect.svelte +++ b/packages/builder/src/components/design/settings/controls/RoleSelect.svelte @@ -1,20 +1,21 @@ role.label} - getOptionValue={role => role.value} - /> - diff --git a/packages/builder/src/stores/builder/roles.js b/packages/builder/src/stores/builder/roles.js index ac395aa232d..fd3581f1d47 100644 --- a/packages/builder/src/stores/builder/roles.js +++ b/packages/builder/src/stores/builder/roles.js @@ -1,16 +1,34 @@ -import { writable } from "svelte/store" +import { derived, writable, get } from "svelte/store" import { API } from "api" import { RoleUtils } from "@budibase/frontend-core" export function createRolesStore() { - const { subscribe, update, set } = writable([]) + const store = writable([]) + const enriched = derived(store, $store => { + return $store.map(role => ({ + ...role, + + // Ensure we have new metadata for all roles + uiMetadata: { + displayName: role.uiMetadata?.displayName || role.name, + color: + role.uiMetadata?.color || "var(--spectrum-global-color-magenta-400)", + description: role.uiMetadata?.description || "Custom role", + }, + })) + }) function setRoles(roles) { - set( + store.set( roles.sort((a, b) => { const priorityA = RoleUtils.getRolePriority(a._id) const priorityB = RoleUtils.getRolePriority(b._id) - return priorityA > priorityB ? -1 : 1 + if (priorityA !== priorityB) { + return priorityA > priorityB ? -1 : 1 + } + const nameA = a.uiMetadata?.displayName || a.name + const nameB = b.uiMetadata?.displayName || b.name + return nameA < nameB ? -1 : 1 }) ) } @@ -29,17 +47,43 @@ export function createRolesStore() { roleId: role?._id, roleRev: role?._rev, }) - update(state => state.filter(existing => existing._id !== role._id)) + await actions.fetch() }, save: async role => { const savedRole = await API.saveRole(role) await actions.fetch() return savedRole }, + replace: (roleId, role) => { + // Handles external updates of roles + if (!roleId) { + return + } + + // Handle deletion + if (!role) { + store.update(state => state.filter(x => x._id !== roleId)) + return + } + + // Add new role + const index = get(store).findIndex(x => x._id === role._id) + if (index === -1) { + store.update(state => [...state, role]) + } + + // Update existing role + else if (role) { + store.update(state => { + state[index] = role + return [...state] + }) + } + }, } return { - subscribe, + subscribe: enriched.subscribe, ...actions, } } diff --git a/packages/builder/src/stores/builder/websocket.js b/packages/builder/src/stores/builder/websocket.js index 7df5ab9adba..8a0d83abc10 100644 --- a/packages/builder/src/stores/builder/websocket.js +++ b/packages/builder/src/stores/builder/websocket.js @@ -9,6 +9,7 @@ import { snippets, datasources, tables, + roles, } from "stores/builder" import { get } from "svelte/store" import { auth, appsStore } from "stores/portal" @@ -56,12 +57,18 @@ export const createBuilderWebsocket = appId => { datasources.replaceDatasource(id, datasource) }) + // Role events + socket.onOther(BuilderSocketEvent.RoleChange, ({ id, role }) => { + roles.replace(id, role) + }) + // Design section events socket.onOther(BuilderSocketEvent.ScreenChange, ({ id, screen }) => { screenStore.replace(id, screen) }) + + // App events socket.onOther(BuilderSocketEvent.AppMetadataChange, ({ metadata }) => { - //Sync app metadata across the stores appStore.syncMetadata(metadata) themeStore.syncMetadata(metadata) navigationStore.syncMetadata(metadata) @@ -79,7 +86,7 @@ export const createBuilderWebsocket = appId => { } ) - // Automations + // Automation events socket.onOther(BuilderSocketEvent.AutomationChange, ({ id, automation }) => { automationStore.actions.replace(id, automation) }) diff --git a/packages/client/src/components/devtools/DevToolsHeader.svelte b/packages/client/src/components/devtools/DevToolsHeader.svelte index 55b705e7175..eca085a88a6 100644 --- a/packages/client/src/components/devtools/DevToolsHeader.svelte +++ b/packages/client/src/components/devtools/DevToolsHeader.svelte @@ -1,28 +1,29 @@ diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte index 5d5f06872d0..cd6b61af800 100644 --- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte @@ -66,7 +66,7 @@ focus: () => api?.focus?.(), blur: () => api?.blur?.(), isActive: () => api?.isActive?.() ?? false, - onKeyDown: (...params) => api?.onKeyDown(...params), + onKeyDown: (...params) => api?.onKeyDown?.(...params), isReadonly: () => readonly, getType: () => column.schema.type, getValue: () => row[column.name], diff --git a/packages/frontend-core/src/components/grid/cells/RoleCell.svelte b/packages/frontend-core/src/components/grid/cells/RoleCell.svelte new file mode 100644 index 00000000000..82d1e26aa7d --- /dev/null +++ b/packages/frontend-core/src/components/grid/cells/RoleCell.svelte @@ -0,0 +1,45 @@ + + +
+
+ +
+
+ {role?.uiMetadata?.displayName || role?.name || "Unknown role"} +
+
+ + diff --git a/packages/frontend-core/src/components/grid/lib/renderers.js b/packages/frontend-core/src/components/grid/lib/renderers.js index f9e26d920a8..70700f9417d 100644 --- a/packages/frontend-core/src/components/grid/lib/renderers.js +++ b/packages/frontend-core/src/components/grid/lib/renderers.js @@ -16,6 +16,7 @@ import AttachmentSingleCell from "../cells/AttachmentSingleCell.svelte" import BBReferenceCell from "../cells/BBReferenceCell.svelte" import SignatureCell from "../cells/SignatureCell.svelte" import BBReferenceSingleCell from "../cells/BBReferenceSingleCell.svelte" +import RoleCell from "../cells/RoleCell.svelte" const TypeComponentMap = { [FieldType.STRING]: TextCell, @@ -35,6 +36,9 @@ const TypeComponentMap = { [FieldType.JSON]: JSONCell, [FieldType.BB_REFERENCE]: BBReferenceCell, [FieldType.BB_REFERENCE_SINGLE]: BBReferenceSingleCell, + + // Custom types for UI only + role: RoleCell, } export const getCellRenderer = column => { return ( diff --git a/packages/frontend-core/src/utils/roles.js b/packages/frontend-core/src/utils/roles.js index 1ae9d3ac142..913d452e7cd 100644 --- a/packages/frontend-core/src/utils/roles.js +++ b/packages/frontend-core/src/utils/roles.js @@ -7,20 +7,7 @@ const RolePriorities = { [Roles.BASIC]: 2, [Roles.PUBLIC]: 1, } -const RoleColours = { - [Roles.ADMIN]: "var(--spectrum-global-color-static-red-400)", - [Roles.CREATOR]: "var(--spectrum-global-color-static-magenta-600)", - [Roles.POWER]: "var(--spectrum-global-color-static-orange-400)", - [Roles.BASIC]: "var(--spectrum-global-color-static-green-400)", - [Roles.PUBLIC]: "var(--spectrum-global-color-static-blue-400)", -} export const getRolePriority = role => { return RolePriorities[role] ?? 0 } - -export const getRoleColour = roleId => { - return ( - RoleColours[roleId] ?? "var(--spectrum-global-color-static-magenta-400)" - ) -} diff --git a/packages/server/src/api/controllers/role.ts b/packages/server/src/api/controllers/role.ts index f26b4bae690..10477119837 100644 --- a/packages/server/src/api/controllers/role.ts +++ b/packages/server/src/api/controllers/role.ts @@ -18,9 +18,11 @@ import { UserCtx, UserMetadata, DocumentType, + PermissionLevel, } from "@budibase/types" import { RoleColor, sdk as sharedSdk, helpers } from "@budibase/shared-core" import sdk from "../../sdk" +import { builderSocket } from "../../websockets" const UpdateRolesOptions = { CREATED: "created", @@ -34,11 +36,11 @@ async function removeRoleFromOthers(roleId: string) { let changed = false if (Array.isArray(role.inherits)) { const newInherits = role.inherits.filter( - id => !roles.compareRoleIds(id, roleId) + id => !roles.roleIDsAreEqual(id, roleId) ) changed = role.inherits.length !== newInherits.length role.inherits = newInherits - } else if (role.inherits && roles.compareRoleIds(role.inherits, roleId)) { + } else if (role.inherits && roles.roleIDsAreEqual(role.inherits, roleId)) { role.inherits = roles.BUILTIN_ROLE_IDS.PUBLIC changed = true } @@ -124,6 +126,17 @@ export async function save(ctx: UserCtx) { ctx.throw(400, "Cannot change custom role name") } + // custom roles should always inherit basic - if they don't inherit anything else + if (!inherits && roles.validInherits(allRoles, dbRole?.inherits)) { + inherits = dbRole?.inherits + } else if (!roles.validInherits(allRoles, inherits)) { + inherits = [roles.BUILTIN_ROLE_IDS.BASIC] + } + // assume write permission level for newly created roles + if (isCreate && !permissionId) { + permissionId = PermissionLevel.WRITE + } + const role = new roles.Role(_id, name, permissionId, { displayName: uiMetadata?.displayName || name, description: uiMetadata?.description || "Custom role", @@ -177,6 +190,7 @@ export async function save(ctx: UserCtx) { }, }) } + builderSocket?.emitRoleUpdate(ctx, role) } export async function destroy(ctx: UserCtx) { @@ -216,6 +230,7 @@ export async function destroy(ctx: UserCtx) { ctx.message = `Role ${ctx.params.roleId} deleted successfully` ctx.status = 200 + builderSocket?.emitRoleDeletion(ctx, role) } export async function accessible(ctx: UserCtx) { @@ -223,35 +238,23 @@ export async function accessible(ctx: UserCtx) { if (!roleId) { roleId = roles.BUILTIN_ROLE_IDS.PUBLIC } + // If a custom role is provided in the header, filter out higher level roles + const roleHeader = ctx.header[Header.PREVIEW_ROLE] + if (Array.isArray(roleHeader)) { + ctx.throw(400, `Too many roles specified in ${Header.PREVIEW_ROLE} header`) + } + const isBuilder = ctx.user && sharedSdk.users.isAdminOrBuilder(ctx.user) let roleIds: string[] = [] - if (ctx.user && sharedSdk.users.isAdminOrBuilder(ctx.user)) { + if (!roleHeader && isBuilder) { const appId = context.getAppId() if (appId) { roleIds = await roles.getAllRoleIds(appId) } + } else if (isBuilder && roleHeader) { + roleIds = await roles.getUserRoleIdHierarchy(roleHeader) } else { roleIds = await roles.getUserRoleIdHierarchy(roleId!) } - // If a custom role is provided in the header, filter out higher level roles - const roleHeader = ctx.header?.[Header.PREVIEW_ROLE] as string - if (roleHeader && !Object.keys(roles.BUILTIN_ROLE_IDS).includes(roleHeader)) { - const role = await roles.getRole(roleHeader) - const inherits = role?.inherits - const orderedRoles = roleIds.reverse() - let filteredRoles = [roleHeader] - for (let role of orderedRoles) { - filteredRoles = [role, ...filteredRoles] - if ( - (Array.isArray(inherits) && inherits.includes(role)) || - role === inherits - ) { - break - } - } - filteredRoles.pop() - roleIds = [roleHeader, ...filteredRoles] - } - ctx.body = roleIds.map(roleId => roles.getExternalRoleID(roleId)) } diff --git a/packages/server/src/api/routes/tests/role.spec.ts b/packages/server/src/api/routes/tests/role.spec.ts index 56682953427..adb83ca7932 100644 --- a/packages/server/src/api/routes/tests/role.spec.ts +++ b/packages/server/src/api/routes/tests/role.spec.ts @@ -38,6 +38,26 @@ describe("/roles", () => { _id: dbCore.prefixRoleID(res._id!), }) }) + + it("handle a role with invalid inherits", async () => { + const role = basicRole() + role.inherits = ["not_real", "some_other_not_real"] + + const res = await config.api.roles.save(role, { + status: 200, + }) + expect(res.inherits).toEqual([BUILTIN_ROLE_IDS.BASIC]) + }) + + it("handle a role with no inherits", async () => { + const role = basicRole() + role.inherits = [] + + const res = await config.api.roles.save(role, { + status: 200, + }) + expect(res.inherits).toEqual([BUILTIN_ROLE_IDS.BASIC]) + }) }) describe("update", () => { @@ -149,6 +169,17 @@ describe("/roles", () => { { status: 400, body: { message: LOOP_ERROR } } ) }) + + it("handle updating a role, without its inherits", async () => { + const res = await config.api.roles.save({ + ...basicRole(), + inherits: [BUILTIN_ROLE_IDS.ADMIN], + }) + // remove the roles so that it will default back to DB roles, then save again + delete res.inherits + const updatedRes = await config.api.roles.save(res) + expect(updatedRes.inherits).toEqual([BUILTIN_ROLE_IDS.ADMIN]) + }) }) describe("fetch", () => { @@ -298,6 +329,23 @@ describe("/roles", () => { } ) }) + + it("should fetch preview role correctly even without basic specified", async () => { + const role = await config.api.roles.save(basicRole()) + // have to forcefully delete the inherits from DB - technically can't + // happen anymore - but good test case + await dbCore.getDB(config.appId!).put({ + ...role, + _id: dbCore.prefixRoleID(role._id!), + inherits: [], + }) + await config.withHeaders({ "x-budibase-role": role.name }, async () => { + const res = await config.api.roles.accessible({ + status: 200, + }) + expect(res).toEqual([role.name]) + }) + }) }) describe("accessible - multi-inheritance", () => { diff --git a/packages/server/src/api/routes/tests/screen.spec.ts b/packages/server/src/api/routes/tests/screen.spec.ts index 5dfe3d2a44b..894710ca272 100644 --- a/packages/server/src/api/routes/tests/screen.spec.ts +++ b/packages/server/src/api/routes/tests/screen.spec.ts @@ -86,7 +86,6 @@ describe("/screens", () => { status: 200, } ) - // basic and role1 screen expect(res.screens.length).toEqual(screenIds.length) expect(res.screens.map(s => s._id).sort()).toEqual(screenIds.sort()) }) @@ -107,6 +106,25 @@ describe("/screens", () => { screen2._id!, ]) }) + + it("should be able to fetch basic and screen 1 with role1 in role header", async () => { + await config.withHeaders( + { + "x-budibase-role": role1._id!, + }, + async () => { + const res = await config.api.application.getDefinition( + config.prodAppId!, + { + status: 200, + } + ) + const screenIds = [screen._id!, screen1._id!] + expect(res.screens.length).toEqual(screenIds.length) + expect(res.screens.map(s => s._id).sort()).toEqual(screenIds.sort()) + } + ) + }) }) describe("save", () => { diff --git a/packages/server/src/middleware/currentapp.ts b/packages/server/src/middleware/currentapp.ts index d6163772980..a8ef8bb2512 100644 --- a/packages/server/src/middleware/currentapp.ts +++ b/packages/server/src/middleware/currentapp.ts @@ -56,22 +56,9 @@ export default async (ctx: UserCtx, next: any) => { ctx.request && (ctx.request.headers[constants.Header.PREVIEW_ROLE] as string) if (isBuilder && isDevApp && roleHeader) { - // Ensure the role is valid by ensuring a definition exists - try { - if (roleHeader) { - const role = await roles.getRole(roleHeader) - if (role) { - roleId = roleHeader - - // Delete admin and builder flags so that the specified role is honoured - ctx.user = users.removePortalUserPermissions( - ctx.user - ) as ContextUser - } - } - } catch (error) { - // Swallow error and do nothing - } + roleId = roleHeader + // Delete admin and builder flags so that the specified role is honoured + ctx.user = users.removePortalUserPermissions(ctx.user) as ContextUser } } diff --git a/packages/server/src/tests/utilities/structures.ts b/packages/server/src/tests/utilities/structures.ts index b38cc6484fa..b63d6d1a78b 100644 --- a/packages/server/src/tests/utilities/structures.ts +++ b/packages/server/src/tests/utilities/structures.ts @@ -8,31 +8,31 @@ import { } from "../../automations" import { AIOperationEnum, + AutoFieldSubType, Automation, AutomationActionStepId, + AutomationEventType, AutomationResults, AutomationStatus, AutomationStep, AutomationStepType, AutomationTrigger, AutomationTriggerStepId, + BBReferenceFieldSubType, + CreateViewRequest, Datasource, + FieldSchema, FieldType, + INTERNAL_TABLE_SOURCE_ID, + JsonFieldSubType, + LoopStepType, + Query, + Role, SourceName, Table, - INTERNAL_TABLE_SOURCE_ID, TableSourceType, - Query, Webhook, WebhookActionType, - AutomationEventType, - LoopStepType, - FieldSchema, - BBReferenceFieldSubType, - JsonFieldSubType, - AutoFieldSubType, - Role, - CreateViewRequest, } from "@budibase/types" import { LoopInput } from "../../definitions/automations" import { merge } from "lodash" @@ -439,7 +439,7 @@ export function updateRowAutomationWithFilters( appId: string, tableId: string ): Automation { - const automation: Automation = { + return { name: "updateRowWithFilters", type: "automation", appId, @@ -472,7 +472,6 @@ export function updateRowAutomationWithFilters( }, }, } - return automation } export function basicAutomationResults( diff --git a/packages/server/src/websockets/builder.ts b/packages/server/src/websockets/builder.ts index 702a9410956..cf92d68ef3b 100644 --- a/packages/server/src/websockets/builder.ts +++ b/packages/server/src/websockets/builder.ts @@ -11,6 +11,7 @@ import { Screen, App, Automation, + Role, } from "@budibase/types" import { gridSocket } from "./index" import { clearLock, updateLock } from "../utilities/redis" @@ -100,6 +101,20 @@ export default class BuilderSocket extends BaseSocket { }) } + emitRoleUpdate(ctx: any, role: Role) { + this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.RoleChange, { + id: role._id, + role, + }) + } + + emitRoleDeletion(ctx: any, role: Role) { + this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.RoleChange, { + id: role._id, + role: null, + }) + } + emitTableUpdate(ctx: any, table: Table, options?: EmitOptions) { if (table.sourceId == null || table.sourceId === "") { throw new Error("Table sourceId is not set") diff --git a/packages/shared-core/src/constants/index.ts b/packages/shared-core/src/constants/index.ts index 6c49625937f..11bb79c8d3d 100644 --- a/packages/shared-core/src/constants/index.ts +++ b/packages/shared-core/src/constants/index.ts @@ -97,6 +97,7 @@ export enum BuilderSocketEvent { SelectResource = "SelectResource", AppPublishChange = "AppPublishChange", AutomationChange = "AutomationChange", + RoleChange = "RoleChange", } export const SocketSessionTTL = 60 diff --git a/yarn.lock b/yarn.lock index 1198e98ad6b..5dfef16e0e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2343,6 +2343,18 @@ enabled "2.0.x" kuler "^2.0.0" +"@dagrejs/dagre@1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@dagrejs/dagre/-/dagre-1.1.4.tgz#66f9c0e2b558308f2c268f60e2c28f22ee17e339" + integrity sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg== + dependencies: + "@dagrejs/graphlib" "2.2.4" + +"@dagrejs/graphlib@2.2.4": + version "2.2.4" + resolved "https://registry.yarnpkg.com/@dagrejs/graphlib/-/graphlib-2.2.4.tgz#d77bfa9ff49e2307c0c6e6b8b26b5dd3c05816c4" + integrity sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw== + "@datadog/native-appsec@7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@datadog/native-appsec/-/native-appsec-7.0.0.tgz#a380174dd49aef2d9bb613a0ec8ead6dc7822095" @@ -5093,6 +5105,11 @@ resolved "https://registry.yarnpkg.com/@spectrum-css/vars/-/vars-4.3.1.tgz#d333fa41909f691c8750b5c15ad9ba029df2248e" integrity sha512-rX6Iasu9BsFMVgEN0vGRPm9dmSxva+IK/uqQAa9HM0lliwqUiFrJxrFXHHpiAgNuux/U4srEJwbSpGzfF+CegQ== +"@svelte-put/shortcut@^3.1.0": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@svelte-put/shortcut/-/shortcut-3.1.1.tgz#aba4d7407024d5cff38727e12925c8f81e877079" + integrity sha512-2L5EYTZXiaKvbEelVkg5znxqvfZGZai3m97+cAiUBhLZwXnGtviTDpHxOoZBsqz41szlfRMcamW/8o0+fbW3ZQ== + "@sveltejs/vite-plugin-svelte@1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-1.4.0.tgz#412a735de489ca731d0c780c2b410f45dd95b392" @@ -5451,6 +5468,45 @@ dependencies: "@types/node" "*" +"@types/d3-color@*": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" + integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== + +"@types/d3-drag@^3.0.7": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02" + integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-interpolate@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" + integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== + dependencies: + "@types/d3-color" "*" + +"@types/d3-selection@*", "@types/d3-selection@^3.0.10": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.10.tgz#98cdcf986d0986de6912b5892e7c015a95ca27fe" + integrity sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg== + +"@types/d3-transition@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.8.tgz#677707f5eed5b24c66a1918cde05963021351a8f" + integrity sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-zoom@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b" + integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw== + dependencies: + "@types/d3-interpolate" "*" + "@types/d3-selection" "*" + "@types/debug@*": version "4.1.7" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82" @@ -6578,6 +6634,28 @@ loupe "^3.1.1" tinyrainbow "^1.2.0" +"@xyflow/svelte@^0.1.18": + version "0.1.18" + resolved "https://registry.yarnpkg.com/@xyflow/svelte/-/svelte-0.1.18.tgz#ba2f9f72adc64ff6f71a5ad03cf759af8d7c9748" + integrity sha512-P2td3XcvMk36pnhyRUAXtmwfd7sv1KAHVF29YZUNndYlgxG98vwj1UoyyuXwCHIiyu82GgowaTppHCNPXsvNSg== + dependencies: + "@svelte-put/shortcut" "^3.1.0" + "@xyflow/system" "0.0.41" + classcat "^5.0.4" + +"@xyflow/system@0.0.41": + version "0.0.41" + resolved "https://registry.yarnpkg.com/@xyflow/system/-/system-0.0.41.tgz#6c314b2bbca594aec4d7cdb56efb003be6727d21" + integrity sha512-XAjs8AUA0YMfYD91cT6pLGALwbsPS64s2WBHyULqL1m0gTqXqaUSLK1P7qA/Q8HecN0RFbqlM2tPO8bmZXP0YQ== + dependencies: + "@types/d3-drag" "^3.0.7" + "@types/d3-selection" "^3.0.10" + "@types/d3-transition" "^3.0.8" + "@types/d3-zoom" "^3.0.8" + d3-drag "^3.0.0" + d3-selection "^3.0.0" + d3-zoom "^3.0.0" + "@yarnpkg/lockfile@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" @@ -8244,6 +8322,11 @@ cjs-module-lexer@^1.0.0, cjs-module-lexer@^1.2.2: resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz#6c370ab19f8a3394e318fe682686ec0ac684d107" integrity sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ== +classcat@^5.0.4: + version "5.0.5" + resolved "https://registry.yarnpkg.com/classcat/-/classcat-5.0.5.tgz#8c209f359a93ac302404a10161b501eba9c09c77" + integrity sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w== + clean-stack@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" @@ -9159,6 +9242,68 @@ curlconverter@3.21.0: string.prototype.startswith "^1.0.0" yamljs "^0.3.0" +"d3-color@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" + integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== + +"d3-dispatch@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e" + integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg== + +"d3-drag@2 - 3", d3-drag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba" + integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg== + dependencies: + d3-dispatch "1 - 3" + d3-selection "3" + +"d3-ease@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" + integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== + +"d3-interpolate@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" + integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== + dependencies: + d3-color "1 - 3" + +"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" + integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== + +"d3-timer@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" + integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== + +"d3-transition@2 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f" + integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w== + dependencies: + d3-color "1 - 3" + d3-dispatch "1 - 3" + d3-ease "1 - 3" + d3-interpolate "1 - 3" + d3-timer "1 - 3" + +d3-zoom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3" + integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw== + dependencies: + d3-dispatch "1 - 3" + d3-drag "2 - 3" + d3-interpolate "1 - 3" + d3-selection "2 - 3" + d3-transition "2 - 3" + dargs@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/dargs/-/dargs-7.0.0.tgz#04015c41de0bcb69ec84050f3d9be0caf8d6d5cc"