diff --git a/packages/builder/cypress/support/commands.js b/packages/builder/cypress/support/commands.js index 0c177e6177..94faa124a8 100644 --- a/packages/builder/cypress/support/commands.js +++ b/packages/builder/cypress/support/commands.js @@ -111,7 +111,7 @@ Cypress.Commands.add("addRow", values => { }) }) -Cypress.Commands.add("createUser", (email, password, accessLevel) => { +Cypress.Commands.add("createUser", (email, password, role) => { // Create User cy.contains("Users").click() @@ -126,7 +126,7 @@ Cypress.Commands.add("createUser", (email, password, accessLevel) => { .type(email) cy.get("select") .first() - .select(accessLevel) + .select(role) // Save cy.get(".buttons") diff --git a/packages/builder/package.json b/packages/builder/package.json index a4908de3da..542c35205e 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -63,7 +63,7 @@ } }, "dependencies": { - "@budibase/bbui": "^1.51.0", + "@budibase/bbui": "^1.52.2", "@budibase/client": "^0.3.8", "@budibase/colorpicker": "^1.0.1", "@budibase/svelte-ag-grid": "^0.0.16", @@ -107,6 +107,7 @@ "rollup-plugin-alias": "^1.5.2", "rollup-plugin-copy": "^3.0.0", "rollup-plugin-css-only": "^2.1.0", + "rollup-plugin-html": "^0.2.1", "rollup-plugin-livereload": "^1.0.0", "rollup-plugin-node-builtins": "^2.1.2", "rollup-plugin-node-globals": "^1.4.0", diff --git a/packages/builder/rollup.config.js b/packages/builder/rollup.config.js index 4afb8084bd..2d5ec52f52 100644 --- a/packages/builder/rollup.config.js +++ b/packages/builder/rollup.config.js @@ -11,6 +11,7 @@ import copy from "rollup-plugin-copy" import css from "rollup-plugin-css-only" import replace from "rollup-plugin-replace" import json from "@rollup/plugin-json" +import html from "rollup-plugin-html" import path from "path" @@ -75,10 +76,6 @@ export default { { src: "src/index.html", dest: outputpath }, { src: "src/favicon.png", dest: outputpath }, { src: "assets", dest: outputpath }, - { - src: "node_modules/@budibase/client/dist/budibase-client.esm.mjs", - dest: outputpath, - }, { src: "node_modules/@budibase/bbui/dist/bbui.css", dest: outputpath, @@ -147,5 +144,6 @@ export default { // instead of npm run dev), minify production && terser(), json(), + html(), ], } diff --git a/packages/builder/src/builderStore/getNewComponentName.js b/packages/builder/src/builderStore/getNewComponentName.js index a69bec21ad..98ca05b827 100644 --- a/packages/builder/src/builderStore/getNewComponentName.js +++ b/packages/builder/src/builderStore/getNewComponentName.js @@ -2,6 +2,8 @@ import { walkProps } from "./storeUtils" import { get_capitalised_name } from "../helpers" import { get } from "svelte/store" import { allScreens } from "builderStore" +import { FrontendTypes } from "../constants" +import { currentAsset } from "." export default function(component, state) { const capitalised = get_capitalised_name( @@ -19,14 +21,16 @@ export default function(component, state) { }) } - // check page first - findMatches(state.pages[state.currentPageName].props) + // check layouts first + for (let layout of state.layouts) { + findMatches(layout.props) + } // if viewing screen, check current screen for duplicate - if (state.currentFrontEndType === "screen") { - findMatches(state.currentPreviewItem.props) + if (state.currentFrontEndType === FrontendTypes.SCREEN) { + findMatches(get(currentAsset).props) } else { - // viewing master page - need to find against all screens + // viewing a layout - need to find against all screens for (let screen of get(allScreens)) { findMatches(screen.props) } diff --git a/packages/builder/src/builderStore/index.js b/packages/builder/src/builderStore/index.js index ae77889404..503d9b08a7 100644 --- a/packages/builder/src/builderStore/index.js +++ b/packages/builder/src/builderStore/index.js @@ -4,37 +4,74 @@ import { getAutomationStore } from "./store/automation/" import { getThemeStore } from "./store/theme" import { derived } from "svelte/store" import analytics from "analytics" +import { LAYOUT_NAMES } from "../constants" +import { makePropsSafe } from "components/userInterface/assetParsing/createProps" export const store = getFrontendStore() export const backendUiStore = getBackendUiStore() export const automationStore = getAutomationStore() export const themeStore = getThemeStore() +export const currentAsset = derived(store, $store => { + const layout = $store.layouts + ? $store.layouts.find(layout => layout._id === $store.currentAssetId) + : null + + if (layout) return layout + + const screen = $store.screens + ? $store.screens.find(screen => screen._id === $store.currentAssetId) + : null + + if (screen) return screen + + return null +}) + +export const selectedComponent = derived( + [store, currentAsset], + ([$store, $currentAsset]) => { + if (!$currentAsset || !$store.selectedComponentId) return null + + function traverse(node, callback) { + if (node._id === $store.selectedComponentId) return callback(node) + + if (node._children) { + node._children.forEach(child => traverse(child, callback)) + } + + if (node.props) { + traverse(node.props, callback) + } + } + + let component + traverse($currentAsset, found => { + const componentIdentifier = found._component ?? found.props._component + const componentDef = componentIdentifier.startsWith("##") + ? found + : $store.components[componentIdentifier] + + component = makePropsSafe(componentDef, found) + }) + + return component + } +) + +export const currentAssetName = derived(store, () => { + return currentAsset.name +}) + +// leave this as before for consistency export const allScreens = derived(store, $store => { - let screens = [] - if ($store.pages == null) { - return screens - } - for (let page of Object.values($store.pages)) { - screens = screens.concat(page._screens) - } - return screens + return $store.screens }) -export const currentScreens = derived(store, $store => { - const currentScreens = $store.pages[$store.currentPageName]?._screens - if (currentScreens == null) { - return [] - } - return Array.isArray(currentScreens) - ? currentScreens - : Object.values(currentScreens) -}) - -export const selectedPage = derived(store, $store => { - if (!$store.pages) return null - - return $store.pages[$store.currentPageName || "main"] +export const mainLayout = derived(store, $store => { + return $store.layouts?.find( + layout => layout.props?._id === LAYOUT_NAMES.MASTER.PRIVATE + ) }) export const initialise = async () => { diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index 50c4347583..7be4f68f25 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -4,34 +4,36 @@ import { createProps, getBuiltin, makePropsSafe, -} from "components/userInterface/pagesParsing/createProps" -import { allScreens, backendUiStore, selectedPage } from "builderStore" -import { generate_screen_css } from "../generate_css" +} from "components/userInterface/assetParsing/createProps" +import { + allScreens, + backendUiStore, + currentAsset, + mainLayout, + selectedComponent, +} from "builderStore" import { fetchComponentLibDefinitions } from "../loadComponentLibraries" import api from "../api" -import { DEFAULT_PAGES_OBJECT } from "../../constants" +import { FrontendTypes } from "../../constants" import getNewComponentName from "../getNewComponentName" import analytics from "analytics" import { findChildComponentType, generateNewIdsForComponent, getComponentDefinition, - getParent, + findParent, } from "../storeUtils" const INITIAL_FRONTEND_STATE = { apps: [], name: "", description: "", - pages: DEFAULT_PAGES_OBJECT, - mainUi: {}, - unauthenticatedUi: {}, + layouts: [], + screens: [], components: [], - currentPreviewItem: null, - currentComponentInfo: null, currentFrontEndType: "none", - currentPageName: "", - currentComponentProps: null, + currentAssetId: "", + selectedComponentId: "", errors: [], hasAppPackage: false, libraries: null, @@ -43,52 +45,13 @@ export const getFrontendStore = () => { const store = writable({ ...INITIAL_FRONTEND_STATE }) store.actions = { - // TODO: REFACTOR initialise: async pkg => { + const { layouts, screens, application } = pkg + store.update(state => { - state.appId = pkg.application._id + state.appId = application._id return state }) - const screens = await api.get("/api/screens").then(r => r.json()) - - const mainScreens = screens.filter(screen => - screen._id.includes(pkg.pages.main._id) - ), - unauthScreens = screens.filter(screen => - screen._id.includes(pkg.pages.unauthenticated._id) - ) - pkg.pages = { - main: { - ...pkg.pages.main, - _screens: mainScreens, - }, - unauthenticated: { - ...pkg.pages.unauthenticated, - _screens: unauthScreens, - }, - } - - // if the app has just been created - // we need to build the CSS and save - if (pkg.justCreated) { - for (let pageName of ["main", "unauthenticated"]) { - const page = pkg.pages[pageName] - store.actions.screens.regenerateCss(page) - for (let screen of page._screens) { - store.actions.screens.regenerateCss(screen) - } - - await api.post(`/api/pages/${page._id}`, { - page: { - componentLibraries: pkg.application.componentLibraries, - ...page, - }, - screens: page._screens, - }) - } - } - - pkg.justCreated = false const components = await fetchComponentLibDefinitions(pkg.application._id) @@ -99,7 +62,8 @@ export const getFrontendStore = () => { name: pkg.application.name, description: pkg.application.description, appId: pkg.application._id, - pages: pkg.pages, + layouts, + screens, hasAppPackage: true, builtins: [getBuiltin("##builtin/screenslot")], appInstance: pkg.application.instance, @@ -107,20 +71,6 @@ export const getFrontendStore = () => { await backendUiStore.actions.database.select(pkg.application.instance) }, - selectPageOrScreen: type => { - store.update(state => { - state.currentFrontEndType = type - - const page = get(selectedPage) - - const pageOrScreen = type === "page" ? page : page._screens[0] - - state.currentComponentInfo = pageOrScreen ? pageOrScreen.props : null - state.currentPreviewItem = pageOrScreen - state.currentView = "detail" - return state - }) - }, routing: { fetch: async () => { const response = await api.get("/api/routing") @@ -133,167 +83,166 @@ export const getFrontendStore = () => { }, }, screens: { - select: screenId => { + select: async screenId => { + let promise store.update(state => { const screen = get(allScreens).find(screen => screen._id === screenId) - state.currentPreviewItem = screen - state.currentFrontEndType = "screen" + state.currentFrontEndType = FrontendTypes.SCREEN + state.currentAssetId = screenId state.currentView = "detail" - store.actions.screens.regenerateCssForCurrentScreen() - const safeProps = makePropsSafe( - state.components[screen.props._component], - screen.props - ) - screen.props = safeProps - state.currentComponentInfo = safeProps + promise = store.actions.screens.regenerateCss(screen) + state.selectedComponentId = screen.props._id return state }) + await promise }, create: async screen => { - let savePromise + screen = await store.actions.screens.save(screen) store.update(state => { - state.currentPreviewItem = screen - state.currentComponentInfo = screen.props - state.currentFrontEndType = "screen" - - if (state.currentPreviewItem) { - store.actions.screens.regenerateCss(state.currentPreviewItem) - } - - savePromise = store.actions.screens.save(screen) + state.currentAssetId = screen._id + state.selectedComponentId = screen.props._id + state.currentFrontEndType = FrontendTypes.SCREEN return state }) - - await savePromise + return screen }, save: async screen => { - const page = get(selectedPage) - const currentPageScreens = page._screens - const creatingNewScreen = screen._id === undefined + const response = await api.post(`/api/screens`, screen) + screen = await response.json() - let savePromise - const response = await api.post(`/api/screens/${page._id}`, screen) - const json = await response.json() - screen._rev = json.rev - screen._id = json.id - const foundScreen = page._screens.findIndex(el => el._id === screen._id) - if (foundScreen !== -1) { - page._screens.splice(foundScreen, 1) - } - page._screens.push(screen) - - // TODO: should carry out all server updates to screen in a single call store.update(state => { - page._screens = currentPageScreens + const foundScreen = state.screens.findIndex( + el => el._id === screen._id + ) + if (foundScreen !== -1) { + state.screens.splice(foundScreen, 1) + } + state.screens.push(screen) if (creatingNewScreen) { - state.currentPreviewItem = screen const safeProps = makePropsSafe( state.components[screen.props._component], screen.props ) - state.currentComponentInfo = safeProps + state.selectedComponentId = safeProps._id screen.props = safeProps } - savePromise = store.actions.pages.save() - return state }) - if (savePromise) await savePromise + return screen }, - regenerateCss: screen => { - screen._css = generate_screen_css([screen.props]) + regenerateCss: async asset => { + const response = await api.post("/api/css/generate", asset) + asset._css = (await response.json())?.css }, - regenerateCssForCurrentScreen: () => { - const { currentPreviewItem } = get(store) - if (currentPreviewItem) { - store.actions.screens.regenerateCss(currentPreviewItem) + regenerateCssForCurrentScreen: async () => { + const asset = get(currentAsset) + if (asset) { + await store.actions.screens.regenerateCss(asset) } }, delete: async screens => { - let deletePromise - const screensToDelete = Array.isArray(screens) ? screens : [screens] + const screenDeletePromises = [] store.update(state => { - const currentPage = get(selectedPage) - for (let screenToDelete of screensToDelete) { - // Remove screen from current page as well - // TODO: Should be done server side - currentPage._screens = currentPage._screens.filter( - scr => scr._id !== screenToDelete._id + state.screens = state.screens.filter( + screen => screen._id !== screenToDelete._id ) - - deletePromise = api.delete( - `/api/screens/${screenToDelete._id}/${screenToDelete._rev}` + screenDeletePromises.push( + api.delete( + `/api/screens/${screenToDelete._id}/${screenToDelete._rev}` + ) ) } return state }) - await deletePromise + await Promise.all(screenDeletePromises) }, }, preview: { saveSelected: async () => { const state = get(store) - if (state.currentFrontEndType !== "page") { - await store.actions.screens.save(state.currentPreviewItem) + const selectedAsset = get(currentAsset) + + if (state.currentFrontEndType !== FrontendTypes.LAYOUT) { + await store.actions.screens.save(selectedAsset) + } else { + await store.actions.layouts.save(selectedAsset) } - await store.actions.pages.save() }, }, - pages: { - select: pageName => { + layouts: { + select: async layoutId => { store.update(state => { - const currentPage = state.pages[pageName] + const layout = store.actions.layouts.find(layoutId) - state.currentFrontEndType = "page" + state.currentFrontEndType = FrontendTypes.LAYOUT state.currentView = "detail" - state.currentPageName = pageName - // This is the root of many problems. - // Uncaught (in promise) TypeError: Cannot read property '_component' of undefined - // it appears that the currentPage sometimes has _props instead of props - // why - const safeProps = makePropsSafe( - state.components[currentPage.props._component], - currentPage.props - ) - state.currentComponentInfo = safeProps - currentPage.props = safeProps - state.currentPreviewItem = state.pages[pageName] - store.actions.screens.regenerateCssForCurrentScreen() - - for (let screen of get(allScreens)) { - screen._css = generate_screen_css([screen.props]) - } + state.currentAssetId = layout._id + state.selectedComponentId = layout.props._id return state }) - }, - save: async page => { - const storeContents = get(store) - const pageName = storeContents.currentPageName || "main" - const pageToSave = page || storeContents.pages[pageName] + let cssPromises = [] + cssPromises.push(store.actions.screens.regenerateCssForCurrentScreen()) - // TODO: revisit. This sends down a very weird payload - const response = await api.post(`/api/pages/${pageToSave._id}`, { - page: { - componentLibraries: storeContents.pages.componentLibraries, - ...pageToSave, - }, - screens: pageToSave._screens, - }) + for (let screen of get(allScreens)) { + cssPromises.push(store.actions.screens.regenerateCss(screen)) + } + await Promise.all(cssPromises) + }, + save: async layout => { + const layoutToSave = cloneDeep(layout) + delete layoutToSave._css + + const response = await api.post(`/api/layouts`, layoutToSave) const json = await response.json() - if (!json.ok) throw new Error("Error updating page") + store.update(state => { + const layoutIdx = state.layouts.findIndex( + stateLayout => stateLayout._id === json._id + ) + + if (layoutIdx >= 0) { + // update existing layout + state.layouts.splice(layoutIdx, 1, json) + } else { + // save new layout + state.layouts.push(json) + } + + state.currentAssetId = json._id + state.selectedComponentId = json.props._id + return state + }) + }, + find: layoutId => { + if (!layoutId) { + return get(mainLayout) + } + const storeContents = get(store) + return storeContents.layouts.find(layout => layout._id === layoutId) + }, + delete: async layoutToDelete => { + const response = await api.delete( + `/api/layouts/${layoutToDelete._id}/${layoutToDelete._rev}` + ) + + if (response.status !== 200) { + const json = await response.json() + throw new Error(json.message) + } store.update(state => { - state.pages[pageName]._rev = json.rev + state.layouts = state.layouts.filter( + layout => layout._id !== layoutToDelete._id + ) return state }) }, @@ -301,17 +250,19 @@ export const getFrontendStore = () => { components: { select: component => { store.update(state => { - const componentDef = component._component.startsWith("##") - ? component - : state.components[component._component] - state.currentComponentInfo = makePropsSafe(componentDef, component) + state.selectedComponentId = component._id state.currentView = "component" return state }) }, create: (componentToAdd, presetProps) => { + const selectedAsset = get(currentAsset) + store.update(state => { function findSlot(component_array) { + if (!component_array) { + return false + } for (let component of component_array) { if (component._component === "##builtin/screenslot") { return true @@ -324,7 +275,7 @@ export const getFrontendStore = () => { if ( componentToAdd.startsWith("##") && - findSlot(state.pages[state.currentPageName].props._children) + findSlot(selectedAsset?.props._children) ) { return state } @@ -340,29 +291,34 @@ export const getFrontendStore = () => { _instanceName: instanceName, }) - const currentComponent = - state.components[state.currentComponentInfo._component] + const selected = get(selectedComponent) - const targetParent = currentComponent.children - ? state.currentComponentInfo - : getParent( - state.currentPreviewItem.props, - state.currentComponentInfo - ) + const currentComponentDefinition = + state.components[selected._component] - // Don't continue if there's no parent - if (!targetParent) { - return state + const allowsChildren = currentComponentDefinition.children + + // Determine where to put the new component. + let targetParent + if (allowsChildren) { + // Child of the selected component + targetParent = selected + } else { + // Sibling of selected component + targetParent = findParent(selectedAsset.props, selected) } - targetParent._children = targetParent._children.concat( - newComponent.props - ) + // Don't continue if there's no parent + if (!targetParent) return state + + // Push the new component + targetParent._children.push(newComponent.props) store.actions.preview.saveSelected() state.currentView = "component" - state.currentComponentInfo = newComponent.props + state.selectedComponentId = newComponent.props._id + analytics.captureEvent("Added Component", { name: newComponent.props._component, }) @@ -370,14 +326,12 @@ export const getFrontendStore = () => { }) }, copy: (component, cut = false) => { + const selectedAsset = get(currentAsset) store.update(state => { state.componentToPaste = cloneDeep(component) state.componentToPaste.isCut = cut if (cut) { - const parent = getParent( - state.currentPreviewItem.props, - component._id - ) + const parent = findParent(selectedAsset.props, component._id) parent._children = parent._children.filter( child => child._id !== component._id ) @@ -387,7 +341,9 @@ export const getFrontendStore = () => { return state }) }, - paste: (targetComponent, mode) => { + paste: async (targetComponent, mode) => { + const selectedAsset = get(currentAsset) + let promises = [] store.update(state => { if (!state.componentToPaste) return state @@ -406,54 +362,56 @@ export const getFrontendStore = () => { return state } - const parent = getParent( - state.currentPreviewItem.props, - targetComponent - ) + const parent = findParent(selectedAsset.props, targetComponent) const targetIndex = parent._children.indexOf(targetComponent) const index = mode === "above" ? targetIndex : targetIndex + 1 parent._children.splice(index, 0, cloneDeep(componentToPaste)) - store.actions.screens.regenerateCssForCurrentScreen() - store.actions.preview.saveSelected() + promises.push(store.actions.screens.regenerateCssForCurrentScreen()) + promises.push(store.actions.preview.saveSelected()) store.actions.components.select(componentToPaste) return state }) + await Promise.all(promises) }, - updateStyle: (type, name, value) => { - store.update(state => { - if (!state.currentComponentInfo._styles) { - state.currentComponentInfo._styles = {} - } - state.currentComponentInfo._styles[type][name] = value + updateStyle: async (type, name, value) => { + let promises = [] + const selected = get(selectedComponent) - store.actions.screens.regenerateCssForCurrentScreen() + store.update(state => { + if (!selected._styles) { + selected._styles = {} + } + selected._styles[type][name] = value + + promises.push(store.actions.screens.regenerateCssForCurrentScreen()) // save without messing with the store - store.actions.preview.saveSelected() + promises.push(store.actions.preview.saveSelected()) return state }) + await Promise.all(promises) }, updateProp: (name, value) => { store.update(state => { - let current_component = state.currentComponentInfo + let current_component = get(selectedComponent) current_component[name] = value - state.currentComponentInfo = current_component + state.selectedComponentId = current_component._id store.actions.preview.saveSelected() return state }) }, findRoute: component => { // Gets all the components to needed to construct a path. - const tempStore = get(store) + const selectedAsset = get(currentAsset) let pathComponents = [] let parent = component let root = false while (!root) { - parent = getParent(tempStore.currentPreviewItem.props, parent) + parent = findParent(selectedAsset.props, parent) if (!parent) { root = true } else { @@ -461,7 +419,7 @@ export const getFrontendStore = () => { } } - // Remove root entry since it's the screen or page layout. + // Remove root entry since it's the screen or layout. // Reverse array since we need the correct order of the IDs const reversedComponents = pathComponents.reverse().slice(1) @@ -476,11 +434,12 @@ export const getFrontendStore = () => { }, links: { save: async (url, title) => { - let savePromise + let promises = [] + const layout = get(mainLayout) store.update(state => { - // Try to extract a nav component from the master screen + // Try to extract a nav component from the master layout const nav = findChildComponentType( - state.pages.main, + layout, "@budibase/standard-components/navigation" ) if (nav) { @@ -513,18 +472,18 @@ export const getFrontendStore = () => { }).props } - // Save page and regenerate all CSS because otherwise weird things happen + // Save layout and regenerate all CSS because otherwise weird things happen nav._children = [...nav._children, newLink] - state.currentPageName = "main" - store.actions.screens.regenerateCss(state.pages.main) - for (let screen of state.pages.main._screens) { - store.actions.screens.regenerateCss(screen) + state.currentAssetId = layout._id + promises.push(store.actions.screens.regenerateCss(layout)) + for (let screen of get(allScreens)) { + promises.push(store.actions.screens.regenerateCss(screen)) } - savePromise = store.actions.pages.save() + promises.push(store.actions.layouts.save(layout)) } return state }) - await savePromise + await Promise.all(promises) }, }, }, diff --git a/packages/builder/src/builderStore/store/screenTemplates/utils/Component.js b/packages/builder/src/builderStore/store/screenTemplates/utils/Component.js index 84de7e15ea..bd03fc7cdc 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/utils/Component.js +++ b/packages/builder/src/builderStore/store/screenTemplates/utils/Component.js @@ -14,7 +14,6 @@ export class Component extends BaseStructure { active: {}, selected: {}, }, - _code: "", type: "", _instanceName: "", _children: [], diff --git a/packages/builder/src/builderStore/store/screenTemplates/utils/Screen.js b/packages/builder/src/builderStore/store/screenTemplates/utils/Screen.js index 523296e959..00bd43ec2c 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/utils/Screen.js +++ b/packages/builder/src/builderStore/store/screenTemplates/utils/Screen.js @@ -4,6 +4,7 @@ export class Screen extends BaseStructure { constructor() { super(true) this._json = { + layoutId: "layout_private_master", props: { _id: "", _component: "", @@ -18,7 +19,7 @@ export class Screen extends BaseStructure { }, routing: { route: "", - accessLevelId: "", + roleId: "BASIC", }, name: "screen-id", } diff --git a/packages/builder/src/builderStore/storeUtils.js b/packages/builder/src/builderStore/storeUtils.js index 9c9d1ef940..4ee2dd7ccc 100644 --- a/packages/builder/src/builderStore/storeUtils.js +++ b/packages/builder/src/builderStore/storeUtils.js @@ -1,15 +1,21 @@ -import { getBuiltin } from "components/userInterface/pagesParsing/createProps" +import { getBuiltin } from "components/userInterface/assetParsing/createProps" import { uuid } from "./uuid" import getNewComponentName from "./getNewComponentName" -export const getParent = (rootProps, child) => { +/** + * Find the parent component of the passed in child. + * @param {Object} rootProps - props to search for the parent in + * @param {String|Object} child - id of the child or the child itself to find the parent of + */ +export const findParent = (rootProps, child) => { let parent - walkProps(rootProps, (p, breakWalk) => { + walkProps(rootProps, (props, breakWalk) => { if ( - p._children && - (p._children.includes(child) || p._children.some(c => c._id === child)) + props._children && + (props._children.includes(child) || + props._children.some(c => c._id === child)) ) { - parent = p + parent = props breakWalk() } }) diff --git a/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte b/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte index f064ff923c..7c26e236a6 100644 --- a/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte +++ b/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte @@ -51,14 +51,13 @@ const screens = screenTemplates($store, [table]) .filter(template => defaultScreens.includes(template.id)) .map(template => template.create()) - store.actions.pages.select("main") for (let screen of screens) { // Record the table that created this screen so we can link it later screen.autoTableId = table._id await store.actions.screens.create(screen) } - // Create autolink to newly created list page + // Create autolink to newly created list screen const listScreen = screens.find(screen => screen.props._instanceName.endsWith("List") ) diff --git a/packages/builder/src/components/start/CreateAppModal.svelte b/packages/builder/src/components/start/CreateAppModal.svelte index 2eb644cdfb..b88397b6c4 100644 --- a/packages/builder/src/components/start/CreateAppModal.svelte +++ b/packages/builder/src/components/start/CreateAppModal.svelte @@ -58,9 +58,7 @@ password: string().required( "Please enter a password for your first user." ), - accessLevelId: string().required( - "You need to select an access level for your user." - ), + roleId: string().required("You need to select a role for your user."), }, ] @@ -81,9 +79,7 @@ if (hasKey) { validationSchemas.shift() - validationSchemas = validationSchemas steps.shift() - steps = steps } // Handles form navigation @@ -157,9 +153,8 @@ const pkg = await applicationPkg.json() if (applicationPkg.ok) { backendUiStore.actions.reset() - pkg.justCreated = true await store.actions.initialise(pkg) - automationStore.actions.fetch() + await automationStore.actions.fetch() } else { throw new Error(pkg) } @@ -168,7 +163,7 @@ const user = { email: $createAppStore.values.email, password: $createAppStore.values.password, - accessLevelId: $createAppStore.values.accessLevelId, + roleId: $createAppStore.values.roleId, } const userResp = await api.post(`/api/users`, user) const json = await userResp.json() diff --git a/packages/builder/src/components/start/Steps/User.svelte b/packages/builder/src/components/start/Steps/User.svelte index e778d5f8e7..397a06be94 100644 --- a/packages/builder/src/components/start/Steps/User.svelte +++ b/packages/builder/src/components/start/Steps/User.svelte @@ -21,7 +21,7 @@ placeholder="Password" type="password" error={blurred.password && validationErrors.password} /> - diff --git a/packages/builder/src/components/userInterface/AppPreview/CurrentItemPreview.svelte b/packages/builder/src/components/userInterface/AppPreview/CurrentItemPreview.svelte index 535b947f5f..ccb0153e45 100644 --- a/packages/builder/src/components/userInterface/AppPreview/CurrentItemPreview.svelte +++ b/packages/builder/src/components/userInterface/AppPreview/CurrentItemPreview.svelte @@ -1,10 +1,13 @@
- {#if $store.currentPreviewItem} - ', - _appId: "inst_app_2cc_ca3383f896034e9295345c05f7dfca0c", _instanceName: "Rick Astley Video", _children: [], }, @@ -97,7 +92,64 @@ exports.HOME_SCREEN = { }, routing: { route: "/", - accessLevelId: BUILTIN_LEVEL_IDS.BASIC, + roleId: BUILTIN_ROLE_IDS.BASIC, }, - name: "d834fea2-1b3e-4320-ab34-f9009f5ecc59", -} + name: "home-screen", +}) + +exports.createLoginScreen = app => ({ + description: "", + url: "", + layoutId: BASE_LAYOUT_PROP_IDS.PUBLIC, + props: { + _instanceName: "LoginScreenContainer", + _id: "5beb4c7b-3c8b-49b2-b8b3-d447dc76dda7", + _component: "@budibase/standard-components/container", + _styles: { + normal: { + flex: "1 1 auto", + display: "flex", + "flex-direction": "column", + "justify-content": "center", + "align-items": "center", + }, + hover: {}, + active: {}, + selected: {}, + }, + type: "div", + _children: [ + { + _id: "781e497e-2e7c-11eb-adc1-0242ac120002", + _component: "@budibase/standard-components/login", + _styles: { + normal: { + padding: "64px", + background: "rgba(255, 255, 255, 0.4)", + "border-radius": "0.5rem", + "margin-top": "0px", + "box-shadow": + "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)", + "font-size": "16px", + "font-family": "Inter", + flex: "0 1 auto", + }, + hover: {}, + active: {}, + selected: {}, + }, + logo: + "https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg", + title: `Log in to ${app.name}`, + buttonText: "Log In", + _children: [], + _instanceName: "Login", + }, + ], + }, + routing: { + route: "/", + roleId: BUILTIN_ROLE_IDS.PUBLIC, + }, + name: "login-screen", +}) diff --git a/packages/server/src/db/utils.js b/packages/server/src/db/utils.js index 02ffd5019c..ea4ab27b0c 100644 --- a/packages/server/src/db/utils.js +++ b/packages/server/src/db/utils.js @@ -10,10 +10,10 @@ const DocumentTypes = { AUTOMATION: "au", LINK: "li", APP: "app", - ACCESS_LEVEL: "ac", + ROLE: "role", WEBHOOK: "wh", INSTANCE: "inst", - PAGE: "page", + LAYOUT: "layout", SCREEN: "screen", } @@ -169,18 +169,48 @@ exports.getAppParams = (appId = null, otherProps = {}) => { } /** - * Generates a new access level ID. - * @returns {string} The new access level ID which the access level doc can be stored under. + * Generates a new role ID. + * @returns {string} The new role ID which the role doc can be stored under. */ -exports.generateAccessLevelID = () => { - return `${DocumentTypes.ACCESS_LEVEL}${SEPARATOR}${newid()}` +exports.generateRoleID = () => { + return `${DocumentTypes.ROLE}${SEPARATOR}${newid()}` } /** - * Gets parameters for retrieving an access level, this is a utility function for the getDocParams function. + * Gets parameters for retrieving a role, this is a utility function for the getDocParams function. */ -exports.getAccessLevelParams = (accessLevelId = null, otherProps = {}) => { - return getDocParams(DocumentTypes.ACCESS_LEVEL, accessLevelId, otherProps) +exports.getRoleParams = (roleId = null, otherProps = {}) => { + return getDocParams(DocumentTypes.ROLE, roleId, otherProps) +} + +/** + * Generates a new layout ID. + * @returns {string} The new layout ID which the layout doc can be stored under. + */ +exports.generateLayoutID = id => { + return `${DocumentTypes.LAYOUT}${SEPARATOR}${id || newid()}` +} + +/** + * Gets parameters for retrieving layout, this is a utility function for the getDocParams function. + */ +exports.getLayoutParams = (layoutId = null, otherProps = {}) => { + return getDocParams(DocumentTypes.LAYOUT, layoutId, otherProps) +} + +/** + * Generates a new screen ID. + * @returns {string} The new screen ID which the screen doc can be stored under. + */ +exports.generateScreenID = () => { + return `${DocumentTypes.SCREEN}${SEPARATOR}${newid()}` +} + +/** + * Gets parameters for retrieving screens, this is a utility function for the getDocParams function. + */ +exports.getScreenParams = (screenId = null, otherProps = {}) => { + return getDocParams(DocumentTypes.SCREEN, screenId, otherProps) } /** @@ -191,36 +221,6 @@ exports.generateWebhookID = () => { return `${DocumentTypes.WEBHOOK}${SEPARATOR}${newid()}` } -/** - * Generates a new page ID. - * @returns {string} The new page ID which the page doc can be stored under. - */ -exports.generatePageID = () => { - return `${DocumentTypes.PAGE}${SEPARATOR}${newid()}` -} - -/** - * Gets parameters for retrieving pages, this is a utility function for the getDocParams function. - */ -exports.getPageParams = (pageId = null, otherProps = {}) => { - return getDocParams(DocumentTypes.PAGE, pageId, otherProps) -} - -/** - * Generates a new screen ID. - * @returns {string} The new screen ID which the screen doc can be stored under. - */ -exports.generateScreenID = pageId => { - return `${DocumentTypes.SCREEN}${SEPARATOR}${pageId}${SEPARATOR}${newid()}` -} - -/** - * Gets parameters for retrieving screens for a particular page, this is a utility function for the getDocParams function. - */ -exports.getScreenParams = (pageId = null, otherProps = {}) => { - return getDocParams(DocumentTypes.SCREEN, pageId, otherProps) -} - /** * Gets parameters for retrieving a webhook, this is a utility function for the getDocParams function. */ diff --git a/packages/server/src/middleware/authenticated.js b/packages/server/src/middleware/authenticated.js index 497c24699d..277c2b28db 100644 --- a/packages/server/src/middleware/authenticated.js +++ b/packages/server/src/middleware/authenticated.js @@ -1,9 +1,6 @@ const jwt = require("jsonwebtoken") const STATUS_CODES = require("../utilities/statusCodes") -const { - getAccessLevel, - BUILTIN_LEVELS, -} = require("../utilities/security/accessLevels") +const { getRole, BUILTIN_ROLES } = require("../utilities/security/roles") const { AuthTypes } = require("../constants") const { getAppId, getCookieName, setCookie, isClient } = require("../utilities") @@ -35,7 +32,7 @@ module.exports = async (ctx, next) => { ctx.appId = appId ctx.user = { appId, - accessLevel: BUILTIN_LEVELS.PUBLIC, + role: BUILTIN_ROLES.PUBLIC, } await next() return @@ -49,7 +46,7 @@ module.exports = async (ctx, next) => { ctx.user = { ...jwtPayload, appId: appId, - accessLevel: await getAccessLevel(appId, jwtPayload.accessLevelId), + role: await getRole(appId, jwtPayload.roleId), } } catch (err) { ctx.throw(err.status || STATUS_CODES.FORBIDDEN, err.text) diff --git a/packages/server/src/middleware/authorized.js b/packages/server/src/middleware/authorized.js index 5f4b78b97e..f18cf3b5c8 100644 --- a/packages/server/src/middleware/authorized.js +++ b/packages/server/src/middleware/authorized.js @@ -1,4 +1,4 @@ -const { BUILTIN_LEVEL_IDS } = require("../utilities/security/accessLevels") +const { BUILTIN_ROLE_IDS } = require("../utilities/security/roles") const { PermissionTypes, doesHavePermission, @@ -7,7 +7,7 @@ const env = require("../environment") const { apiKeyTable } = require("../db/dynamoClient") const { AuthTypes } = require("../constants") -const ADMIN_ACCESS = [BUILTIN_LEVEL_IDS.ADMIN, BUILTIN_LEVEL_IDS.BUILDER] +const ADMIN_ROLES = [BUILTIN_ROLE_IDS.ADMIN, BUILTIN_ROLE_IDS.BUILDER] const LOCAL_PASS = new RegExp(["webhooks/trigger", "webhooks/schema"].join("|")) @@ -47,9 +47,9 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => { ctx.throw(403, "User not found") } - const accessLevel = ctx.user.accessLevel + const role = ctx.user.role const permissions = ctx.user.permissions - if (ADMIN_ACCESS.indexOf(accessLevel._id) !== -1) { + if (ADMIN_ROLES.indexOf(role._id) !== -1) { return next() } diff --git a/packages/server/src/utilities/builder/compileStaticAssets.js b/packages/server/src/utilities/builder/compileStaticAssets.js new file mode 100644 index 0000000000..763fccd35a --- /dev/null +++ b/packages/server/src/utilities/builder/compileStaticAssets.js @@ -0,0 +1,83 @@ +const { + ensureDir, + constants, + copyFile, + writeFile, + readdir, + readFile, + existsSync, +} = require("fs-extra") +const { join } = require("../centralPath") +const { budibaseAppsDir } = require("../budibaseDir") + +const CSS_DIRECTORY = "css" + +/** + * Compile all the non-db static web assets that are required for the running of + * a budibase application. This includes CSS, the JSON structure of the DOM and + * the client library, a script responsible for reading the JSON structure + * and rendering the application. + * @param {string} appId id of the application we want to compile static assets for + * @param {array|object} assets a list of screens or screen layouts for which the CSS should be extracted and stored. + */ +module.exports = async (appId, assets) => { + const publicPath = join(budibaseAppsDir(), appId, "public") + await ensureDir(publicPath) + for (let asset of Array.isArray(assets) ? assets : [assets]) { + await buildCssBundle(publicPath, asset) + await copyClientLib(publicPath) + // remove props that shouldn't be present when written to DB + if (asset._css) { + delete asset._css + } + } + return assets +} + +/** + * Reads the _css property of all screens and the screen layouts, and creates a singular CSS + * bundle for the app at /public/bundle.css + * @param {String} publicPath - path to the public assets directory of the budibase application + * @param {Object} asset a single screen or screen layout which is being updated + */ +const buildCssBundle = async (publicPath, asset) => { + const cssPath = join(publicPath, CSS_DIRECTORY) + let cssString = "" + + // create a singular CSS file for this asset + const assetCss = asset._css ? asset._css.trim() : "" + if (assetCss.length !== 0) { + await ensureDir(cssPath) + await writeFile(join(cssPath, asset._id), assetCss) + } + + // bundle up all the CSS in the directory into one top level CSS file + if (existsSync(cssPath)) { + const cssFiles = await readdir(cssPath) + for (let filename of cssFiles) { + const css = await readFile(join(cssPath, filename)) + cssString += css + } + } + + await writeFile(join(publicPath, "bundle.css"), cssString) +} + +/** + * Copy the budibase client library and sourcemap from NPM to /public/. + * The client library is then served as a static asset when the budibase application + * is running in preview or prod + * @param {String} publicPath - path to write the client library to + */ +const copyClientLib = async publicPath => { + const sourcepath = require.resolve("@budibase/client") + const destPath = join(publicPath, "budibase-client.js") + + await copyFile(sourcepath, destPath, constants.COPYFILE_FICLONE) + + await copyFile( + sourcepath + ".map", + destPath + ".map", + constants.COPYFILE_FICLONE + ) +} diff --git a/packages/server/src/utilities/builder/compileStaticAssetsForPage.js b/packages/server/src/utilities/builder/compileStaticAssetsForPage.js deleted file mode 100644 index c91ba24bb3..0000000000 --- a/packages/server/src/utilities/builder/compileStaticAssetsForPage.js +++ /dev/null @@ -1,102 +0,0 @@ -const { ensureDir, constants, copyFile, writeFile } = require("fs-extra") -const { join } = require("../centralPath") -const { budibaseAppsDir } = require("../budibaseDir") - -/** - * Compile all the non-db static web assets that are required for the running of - * a budibase application. This includes CSS, the JSON structure of the DOM and - * the client library, a script responsible for reading the JSON structure - * and rendering the application. - * @param {} appId - id of the application we want to compile static assets for - * @param {*} pageName - name of the page that the assets will be served for - * @param {*} pkg - app package information/metadata - */ -module.exports = async (appId, pageName, pkg) => { - const pagePath = join(budibaseAppsDir(), appId, "public", pageName) - - pkg.screens = pkg.screens || [] - - await ensureDir(pagePath) - - await buildPageCssBundle(pagePath, pkg) - - await buildFrontendAppDefinition(pagePath, pkg) - - await copyClientLib(pagePath) -} - -/** - * Reads the _css property of a page and its screens, and creates a singular CSS - * bundle for the page at /public//bundle.css - * @param {String} publicPagePath - path to the public assets directory of the budibase application - * @param {Object} pkg - app package information - * @param {"main" | "unauthenticated"} pageName - the pagename of the page we are compiling CSS for. - */ -const buildPageCssBundle = async (publicPagePath, pkg) => { - let cssString = "" - - for (let screen of pkg.screens || []) { - if (!screen._css) continue - if (screen._css.trim().length === 0) { - delete screen._css - continue - } - cssString += screen._css - } - - if (pkg.page._css) cssString += pkg.page._css - - writeFile(join(publicPagePath, "bundle.css"), cssString) -} - -/** - * Copy the budibase client library and sourcemap from NPM to /public/. - * The client library is then served as a static asset when the budibase application - * is running in preview or prod - * @param {String} pagePath - path to write the client library to - */ -const copyClientLib = async pagePath => { - const sourcepath = require.resolve("@budibase/client") - const destPath = join(pagePath, "budibase-client.js") - - await copyFile(sourcepath, destPath, constants.COPYFILE_FICLONE) - - await copyFile( - sourcepath + ".map", - destPath + ".map", - constants.COPYFILE_FICLONE - ) -} - -/** - * Build the frontend definition for a budibase application. This includes all page and screen information, - * and is injected into the budibase client library to tell it how to start constructing - * the DOM from components defined in the frontendDefinition. - * @param {String} pagePath - path to the public folder of the page where the definition will be written - * @param {Object} pkg - app package information from which the frontendDefinition will be built. - */ -const buildFrontendAppDefinition = async (pagePath, pkg) => { - const filename = join(pagePath, "clientFrontendDefinition.js") - - // Delete CSS code from the page and screens so it's not injected - delete pkg.page._css - - for (let screen of pkg.screens) { - if (screen._css) { - delete pkg.page._css - } - } - - const clientUiDefinition = JSON.stringify({ - page: pkg.page, - screens: pkg.screens, - libraries: ["@budibase/standard-components"], - }) - - await writeFile( - filename, - ` - window['##BUDIBASE_FRONTEND_DEFINITION##'] = ${clientUiDefinition}; - ` - ) -} diff --git a/packages/builder/src/builderStore/generate_css.js b/packages/server/src/utilities/builder/generateCss.js similarity index 72% rename from packages/builder/src/builderStore/generate_css.js rename to packages/server/src/utilities/builder/generateCss.js index 2bb5a3bd2e..c3d72c741f 100644 --- a/packages/builder/src/builderStore/generate_css.js +++ b/packages/server/src/utilities/builder/generateCss.js @@ -1,21 +1,21 @@ -export const generate_screen_css = component_arr => { +exports.generateAssetCss = component_arr => { let styles = "" for (const { _styles, _id, _children, _component } of component_arr) { let [componentName] = _component.match(/[a-z]*$/) Object.keys(_styles).forEach(selector => { - const cssString = generate_css(_styles[selector]) + const cssString = exports.generateCss(_styles[selector]) if (cssString) { - styles += apply_class(_id, componentName, cssString, selector) + styles += exports.applyClass(_id, componentName, cssString, selector) } }) if (_children && _children.length) { - styles += generate_screen_css(_children) + "\n" + styles += exports.generateAssetCss(_children) + "\n" } } return styles.trim() } -export const generate_css = style => { +exports.generateCss = style => { let cssString = Object.entries(style).reduce((str, [key, value]) => { if (typeof value === "string") { if (value) { @@ -33,7 +33,7 @@ export const generate_css = style => { return (cssString || "").trim() } -export const apply_class = (id, name = "element", styles, selector) => { +exports.applyClass = (id, name = "element", styles, selector) => { if (selector === "normal") { return `.${name}-${id} {\n${styles}\n}` } else { diff --git a/packages/server/src/utilities/builder/setBuilderToken.js b/packages/server/src/utilities/builder/setBuilderToken.js index f3adf079ad..ed374a1d8e 100644 --- a/packages/server/src/utilities/builder/setBuilderToken.js +++ b/packages/server/src/utilities/builder/setBuilderToken.js @@ -1,4 +1,4 @@ -const { BUILTIN_LEVEL_IDS } = require("../security/accessLevels") +const { BUILTIN_ROLE_IDS } = require("../security/roles") const { BUILTIN_PERMISSION_NAMES } = require("../security/permissions") const env = require("../../environment") const CouchDB = require("../../db") @@ -10,7 +10,7 @@ const APP_PREFIX = DocumentTypes.APP + SEPARATOR module.exports = async (ctx, appId, version) => { const builderUser = { userId: "BUILDER", - accessLevelId: BUILTIN_LEVEL_IDS.BUILDER, + roleId: BUILTIN_ROLE_IDS.BUILDER, permissions: [BUILTIN_PERMISSION_NAMES.ADMIN], version, } diff --git a/packages/server/src/utilities/mustache.js b/packages/server/src/utilities/mustache.js new file mode 100644 index 0000000000..0428bdc03d --- /dev/null +++ b/packages/server/src/utilities/mustache.js @@ -0,0 +1,73 @@ +const handlebars = require("handlebars") + +handlebars.registerHelper("object", value => { + return new handlebars.SafeString(JSON.stringify(value)) +}) + +/** + * When running mustache statements to execute on the context of the automation it possible user's may input mustache + * in a few different forms, some of which are invalid but are logically valid. An example of this would be the mustache + * statement "{{steps[0].revision}}" here it is obvious the user is attempting to access an array or object using array + * like operators. These are not supported by Mustache and therefore the statement will fail. This function will clean up + * the mustache statement so it instead reads as "{{steps.0.revision}}" which is valid and will work. It may also be expanded + * to include any other mustache statement cleanup that has been deemed necessary for the system. + * + * @param {string} string The string which *may* contain mustache statements, it is OK if it does not contain any. + * @returns {string} The string that was input with cleaned up mustache statements as required. + */ +function cleanMustache(string) { + let charToReplace = { + "[": ".", + "]": "", + } + let regex = new RegExp(/{{[^}}]*}}/g) + let matches = string.match(regex) + if (matches == null) { + return string + } + for (let match of matches) { + let baseIdx = string.indexOf(match) + for (let key of Object.keys(charToReplace)) { + let idxChar = match.indexOf(key) + if (idxChar !== -1) { + string = + string.slice(baseIdx, baseIdx + idxChar) + + charToReplace[key] + + string.slice(baseIdx + idxChar + 1) + } + } + } + return string +} + +/** + * Given an input object this will recurse through all props to try and update + * any handlebars/mustache statements within. + * @param {object|array} inputs The input structure which is to be recursed, it is important to note that + * if the structure contains any cycles then this will fail. + * @param {object} context The context that handlebars should fill data from. + * @returns {object|array} The structure input, as fully updated as possible. + */ +function recurseMustache(inputs, context) { + // JSON stringify will fail if there are any cycles, stops infinite recursion + try { + JSON.stringify(inputs) + } catch (err) { + throw "Unable to process inputs to JSON, cannot recurse" + } + for (let key of Object.keys(inputs)) { + let val = inputs[key] + if (typeof val === "string") { + val = cleanMustache(inputs[key]) + const template = handlebars.compile(val) + inputs[key] = template(context) + } + // this covers objects and arrays + else if (typeof val === "object") { + inputs[key] = recurseMustache(inputs[key], context) + } + } + return inputs +} + +exports.recurseMustache = recurseMustache diff --git a/packages/server/src/utilities/security/accessLevels.js b/packages/server/src/utilities/security/accessLevels.js deleted file mode 100644 index 344dd20930..0000000000 --- a/packages/server/src/utilities/security/accessLevels.js +++ /dev/null @@ -1,150 +0,0 @@ -const CouchDB = require("../../db") -const { cloneDeep } = require("lodash/fp") - -const BUILTIN_IDS = { - ADMIN: "ADMIN", - POWER: "POWER_USER", - BASIC: "BASIC", - PUBLIC: "PUBLIC", - BUILDER: "BUILDER", -} - -function AccessLevel(id, name, inherits) { - this._id = id - this.name = name - if (inherits) { - this.inherits = inherits - } -} - -exports.BUILTIN_LEVELS = { - ADMIN: new AccessLevel(BUILTIN_IDS.ADMIN, "Admin", BUILTIN_IDS.POWER), - POWER: new AccessLevel(BUILTIN_IDS.POWER, "Power", BUILTIN_IDS.BASIC), - BASIC: new AccessLevel(BUILTIN_IDS.BASIC, "Basic", BUILTIN_IDS.PUBLIC), - PUBLIC: new AccessLevel(BUILTIN_IDS.PUBLIC, "Public"), - BUILDER: new AccessLevel(BUILTIN_IDS.BUILDER, "Builder"), -} - -exports.BUILTIN_LEVEL_ID_ARRAY = Object.values(exports.BUILTIN_LEVELS).map( - level => level._id -) - -exports.BUILTIN_LEVEL_NAME_ARRAY = Object.values(exports.BUILTIN_LEVELS).map( - level => level.name -) - -function isBuiltin(accessLevel) { - return exports.BUILTIN_LEVEL_ID_ARRAY.indexOf(accessLevel) !== -1 -} - -/** - * Gets the access level object, this is mainly useful for two purposes, to check if the level exists and - * to check if the access level inherits any others. - * @param {string} appId The app in which to look for the access level. - * @param {string|null} accessLevelId The level ID to lookup. - * @returns {Promise} The access level object, which may contain an "inherits" property. - */ -exports.getAccessLevel = async (appId, accessLevelId) => { - if (!accessLevelId) { - return null - } - let accessLevel - if (isBuiltin(accessLevelId)) { - accessLevel = cloneDeep( - Object.values(exports.BUILTIN_LEVELS).find( - level => level._id === accessLevelId - ) - ) - } else { - const db = new CouchDB(appId) - accessLevel = await db.get(accessLevelId) - } - return accessLevel -} - -/** - * Returns an ordered array of the user's inherited access level IDs, this can be used - * to determine if a user can access something that requires a specific access level. - * @param {string} appId The ID of the application from which access levels should be obtained. - * @param {string} userAccessLevelId The user's access level, this can be found in their access token. - * @returns {Promise} returns an ordered array of the access levels, with the first being their - * highest level of access and the last being the lowest level. - */ -exports.getUserAccessLevelHierarchy = async (appId, userAccessLevelId) => { - // special case, if they don't have a level then they are a public user - if (!userAccessLevelId) { - return [BUILTIN_IDS.PUBLIC] - } - let accessLevelIds = [userAccessLevelId] - let userAccess = await exports.getAccessLevel(appId, userAccessLevelId) - // check if inherited makes it possible - while ( - userAccess && - userAccess.inherits && - accessLevelIds.indexOf(userAccess.inherits) === -1 - ) { - accessLevelIds.push(userAccess.inherits) - // go to get the inherited incase it inherits anything - userAccess = await exports.getAccessLevel(appId, userAccess.inherits) - } - // add the user's actual level at the end (not at start as that stops iteration - return accessLevelIds -} - -class AccessController { - constructor(appId) { - this.appId = appId - this.userHierarchies = {} - } - - async hasAccess(tryingAccessLevelId, userAccessLevelId) { - // special cases, the screen has no access level, the access levels are the same or the user - // is currently in the builder - if ( - tryingAccessLevelId == null || - tryingAccessLevelId === "" || - tryingAccessLevelId === userAccessLevelId || - userAccessLevelId === BUILTIN_IDS.BUILDER - ) { - return true - } - let accessLevelIds = this.userHierarchies[userAccessLevelId] - if (!accessLevelIds) { - accessLevelIds = await exports.getUserAccessLevelHierarchy( - this.appId, - userAccessLevelId - ) - this.userHierarchies[userAccessLevelId] = userAccessLevelId - } - - return accessLevelIds.indexOf(tryingAccessLevelId) !== -1 - } - - async checkScreensAccess(screens, userAccessLevelId) { - let accessibleScreens = [] - // don't want to handle this with Promise.all as this would mean all custom access levels would be - // retrieved at same time, it is likely a custom levels will be re-used and therefore want - // to work in sync for performance save - for (let screen of screens) { - const accessible = await this.checkScreenAccess(screen, userAccessLevelId) - if (accessible) { - accessibleScreens.push(accessible) - } - } - return accessibleScreens - } - - async checkScreenAccess(screen, userAccessLevelId) { - const accessLevelId = - screen && screen.routing ? screen.routing.accessLevelId : null - if (await this.hasAccess(accessLevelId, userAccessLevelId)) { - return screen - } - return null - } -} - -exports.AccessController = AccessController -exports.BUILTIN_LEVEL_IDS = BUILTIN_IDS -exports.isBuiltin = isBuiltin -exports.AccessLevel = AccessLevel diff --git a/packages/server/src/utilities/security/roles.js b/packages/server/src/utilities/security/roles.js new file mode 100644 index 0000000000..8a2b343c7a --- /dev/null +++ b/packages/server/src/utilities/security/roles.js @@ -0,0 +1,143 @@ +const CouchDB = require("../../db") +const { cloneDeep } = require("lodash/fp") + +const BUILTIN_IDS = { + ADMIN: "ADMIN", + POWER: "POWER_USER", + BASIC: "BASIC", + PUBLIC: "PUBLIC", + BUILDER: "BUILDER", +} + +function Role(id, name, inherits) { + this._id = id + this.name = name + if (inherits) { + this.inherits = inherits + } +} + +exports.BUILTIN_ROLES = { + ADMIN: new Role(BUILTIN_IDS.ADMIN, "Admin", BUILTIN_IDS.POWER), + POWER: new Role(BUILTIN_IDS.POWER, "Power", BUILTIN_IDS.BASIC), + BASIC: new Role(BUILTIN_IDS.BASIC, "Basic", BUILTIN_IDS.PUBLIC), + PUBLIC: new Role(BUILTIN_IDS.PUBLIC, "Public"), + BUILDER: new Role(BUILTIN_IDS.BUILDER, "Builder"), +} + +exports.BUILTIN_ROLE_ID_ARRAY = Object.values(exports.BUILTIN_ROLES).map( + level => level._id +) + +exports.BUILTIN_ROLE_NAME_ARRAY = Object.values(exports.BUILTIN_ROLES).map( + level => level.name +) + +function isBuiltin(role) { + return exports.BUILTIN_ROLE_ID_ARRAY.indexOf(role) !== -1 +} + +/** + * Gets the role object, this is mainly useful for two purposes, to check if the level exists and + * to check if the role inherits any others. + * @param {string} appId The app in which to look for the role. + * @param {string|null} roleId The level ID to lookup. + * @returns {Promise} The role object, which may contain an "inherits" property. + */ +exports.getRole = async (appId, roleId) => { + if (!roleId) { + return null + } + let role + if (isBuiltin(roleId)) { + role = cloneDeep( + Object.values(exports.BUILTIN_ROLES).find(role => role._id === roleId) + ) + } else { + const db = new CouchDB(appId) + role = await db.get(roleId) + } + return role +} + +/** + * Returns an ordered array of the user's inherited role IDs, this can be used + * to determine if a user can access something that requires a specific role. + * @param {string} appId The ID of the application from which roles should be obtained. + * @param {string} userRoleId The user's role ID, this can be found in their access token. + * @returns {Promise} returns an ordered array of the roles, with the first being their + * highest level of access and the last being the lowest level. + */ +exports.getUserRoleHierarchy = async (appId, userRoleId) => { + // special case, if they don't have a role then they are a public user + if (!userRoleId) { + return [BUILTIN_IDS.PUBLIC] + } + let roleIds = [userRoleId] + let userRole = await exports.getRole(appId, userRoleId) + // check if inherited makes it possible + while ( + userRole && + userRole.inherits && + roleIds.indexOf(userRole.inherits) === -1 + ) { + roleIds.push(userRole.inherits) + // go to get the inherited incase it inherits anything + userRole = await exports.getRole(appId, userRole.inherits) + } + return roleIds +} + +class AccessController { + constructor(appId) { + this.appId = appId + this.userHierarchies = {} + } + + async hasAccess(tryingRoleId, userRoleId) { + // special cases, the screen has no role, the roles are the same or the user + // is currently in the builder + if ( + tryingRoleId == null || + tryingRoleId === "" || + tryingRoleId === userRoleId || + tryingRoleId === BUILTIN_IDS.BUILDER + ) { + return true + } + let roleIds = this.userHierarchies[userRoleId] + if (!roleIds) { + roleIds = await exports.getUserRoleHierarchy(this.appId, userRoleId) + this.userHierarchies[userRoleId] = roleIds + } + + return roleIds.indexOf(tryingRoleId) !== -1 + } + + async checkScreensAccess(screens, userRoleId) { + let accessibleScreens = [] + // don't want to handle this with Promise.all as this would mean all custom roles would be + // retrieved at same time, it is likely a custom role will be re-used and therefore want + // to work in sync for performance save + for (let screen of screens) { + const accessible = await this.checkScreenAccess(screen, userRoleId) + if (accessible) { + accessibleScreens.push(accessible) + } + } + return accessibleScreens + } + + async checkScreenAccess(screen, userRoleId) { + const roleId = screen && screen.routing ? screen.routing.roleId : null + if (await this.hasAccess(roleId, userRoleId)) { + return screen + } + return null + } +} + +exports.AccessController = AccessController +exports.BUILTIN_ROLE_IDS = BUILTIN_IDS +exports.isBuiltin = isBuiltin +exports.Role = Role