diff --git a/packages/backend-core/src/environment.ts b/packages/backend-core/src/environment.ts
index 4e93e8d9ee..7d3a9f18f5 100644
--- a/packages/backend-core/src/environment.ts
+++ b/packages/backend-core/src/environment.ts
@@ -83,7 +83,7 @@ function getPackageJsonFields(): {
   if (isDev() && !isTest()) {
     try {
       const lerna = getParentFile("lerna.json")
-      localVersion = lerna.version
+      localVersion = `${lerna.version}+local`
     } catch {
       //
     }
diff --git a/packages/backend-core/src/security/roles.ts b/packages/backend-core/src/security/roles.ts
index c14178cacb..7d9a11db48 100644
--- a/packages/backend-core/src/security/roles.ts
+++ b/packages/backend-core/src/security/roles.ts
@@ -213,6 +213,22 @@ export function getBuiltinRole(roleId: string): Role | undefined {
   return cloneDeep(role)
 }
 
+export function validInherits(
+  allRoles: RoleDoc[],
+  inherits?: string | string[]
+): boolean {
+  if (!inherits) {
+    return false
+  }
+  const find = (id: string) => allRoles.find(r => roleIDsAreEqual(r._id!, id))
+  if (Array.isArray(inherits)) {
+    const filtered = inherits.filter(roleId => find(roleId))
+    return inherits.length !== 0 && filtered.length === inherits.length
+  } else {
+    return !!find(inherits)
+  }
+}
+
 /**
  * Works through the inheritance ranks to see how far up the builtin stack this ID is.
  */
@@ -290,7 +306,7 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string {
     : roleId1
 }
 
-export function compareRoleIds(roleId1: string, roleId2: string) {
+export function roleIDsAreEqual(roleId1: string, roleId2: string) {
   // make sure both role IDs are prefixed correctly
   return prefixRoleID(roleId1) === prefixRoleID(roleId2)
 }
@@ -323,7 +339,7 @@ export function findRole(
     roleId = prefixRoleID(roleId)
   }
   const dbRole = roles.find(
-    role => role._id && compareRoleIds(role._id, roleId)
+    role => role._id && roleIDsAreEqual(role._id, roleId)
   )
   if (!dbRole && !isBuiltin(roleId) && opts?.defaultPublic) {
     return cloneDeep(BUILTIN_ROLES.PUBLIC)
@@ -557,7 +573,7 @@ export class AccessController {
     }
 
     return (
-      roleIds?.find(roleId => compareRoleIds(roleId, tryingRoleId)) !==
+      roleIds?.find(roleId => roleIDsAreEqual(roleId, tryingRoleId)) !==
       undefined
     )
   }
diff --git a/packages/bbui/src/InlineAlert/InlineAlert.svelte b/packages/bbui/src/InlineAlert/InlineAlert.svelte
index 3b98936f62..edfa760eb8 100644
--- a/packages/bbui/src/InlineAlert/InlineAlert.svelte
+++ b/packages/bbui/src/InlineAlert/InlineAlert.svelte
@@ -8,6 +8,7 @@
   export let onConfirm = undefined
   export let buttonText = ""
   export let cta = false
+
   $: icon = selectIcon(type)
   // if newlines used, convert them to different elements
   $: split = message.split("\n")
diff --git a/packages/bbui/src/List/ListItem.svelte b/packages/bbui/src/List/ListItem.svelte
index ee6e325921..e979b2b684 100644
--- a/packages/bbui/src/List/ListItem.svelte
+++ b/packages/bbui/src/List/ListItem.svelte
@@ -1,5 +1,6 @@
 <script>
   import Icon from "../Icon/Icon.svelte"
+  import StatusLight from "../StatusLight/StatusLight.svelte"
 
   export let icon = null
   export let iconColor = null
@@ -15,12 +16,14 @@
   href={url}
   class="list-item"
   class:hoverable={hoverable || url != null}
-  class:selected
   class:large={!!subtitle}
   on:click
+  class:selected
 >
   <div class="list-item__left">
-    {#if icon}
+    {#if icon === "StatusLight"}
+      <StatusLight square size="L" color={iconColor} />
+    {:else if icon}
       <div class="list-item__icon">
         <Icon name={icon} color={iconColor} size={subtitle ? "XL" : "M"} />
       </div>
@@ -48,7 +51,7 @@
 
 <style>
   .list-item {
-    padding: var(--spacing-m);
+    padding: var(--spacing-m) var(--spacing-l);
     background: var(--spectrum-global-color-gray-75);
     display: flex;
     flex-direction: row;
@@ -72,10 +75,16 @@
     border-bottom-left-radius: 4px;
     border-bottom-right-radius: 4px;
   }
-  .list-item.hoverable:not(.selected):hover {
+  .hoverable:hover {
+    cursor: pointer;
+  }
+  .hoverable:not(.selected):hover {
     background: var(--spectrum-global-color-gray-200);
     border-color: var(--spectrum-global-color-gray-400);
   }
+  .selected {
+    background: var(--spectrum-global-color-blue-100);
+  }
 
   /* Selection is only meant for standalone list items (non stacked) so we just set a fixed border radius */
   .list-item.selected {
@@ -121,7 +130,7 @@
     display: flex;
     flex-direction: row;
     align-items: center;
-    gap: var(--spacing-s);
+    gap: var(--spacing-m);
   }
   .list-item.large .list-item__left,
   .list-item.large .list-item__right {
diff --git a/packages/bbui/src/index.js b/packages/bbui/src/index.js
index 083b4ecfd5..3efafed156 100644
--- a/packages/bbui/src/index.js
+++ b/packages/bbui/src/index.js
@@ -21,6 +21,7 @@ export { default as EnvDropdown } from "./Form/EnvDropdown.svelte"
 export { default as Multiselect } from "./Form/Multiselect.svelte"
 export { default as Search } from "./Form/Search.svelte"
 export { default as RichTextField } from "./Form/RichTextField.svelte"
+export { default as FieldLabel } from "./Form/FieldLabel.svelte"
 export { default as Slider } from "./Form/Slider.svelte"
 export { default as File } from "./Form/File.svelte"
 
diff --git a/packages/builder/package.json b/packages/builder/package.json
index aec0b509f0..46e454f5b9 100644
--- a/packages/builder/package.json
+++ b/packages/builder/package.json
@@ -59,12 +59,14 @@
     "@codemirror/state": "^6.2.0",
     "@codemirror/theme-one-dark": "^6.1.2",
     "@codemirror/view": "^6.11.2",
+    "@dagrejs/dagre": "1.1.4",
     "@fontsource/source-sans-pro": "^5.0.3",
     "@fortawesome/fontawesome-svg-core": "^6.4.2",
     "@fortawesome/free-brands-svg-icons": "^6.4.2",
     "@fortawesome/free-solid-svg-icons": "^6.4.2",
     "@spectrum-css/page": "^3.0.1",
     "@spectrum-css/vars": "^3.0.1",
+    "@xyflow/svelte": "^0.1.18",
     "@zerodevx/svelte-json-view": "^1.0.7",
     "codemirror": "^5.65.16",
     "cron-parser": "^4.9.0",
diff --git a/packages/builder/src/components/automation/AutomationPanel/CreateAutomationModal.svelte b/packages/builder/src/components/automation/AutomationPanel/CreateAutomationModal.svelte
index 365d3d358f..35fdba970a 100644
--- a/packages/builder/src/components/automation/AutomationPanel/CreateAutomationModal.svelte
+++ b/packages/builder/src/components/automation/AutomationPanel/CreateAutomationModal.svelte
@@ -1,4 +1,5 @@
 <script>
+  import { goto } from "@roxi/routify"
   import { automationStore } from "stores/builder"
   import {
     notifications,
@@ -32,11 +33,12 @@
         triggerVal.stepId,
         triggerVal
       )
-      await automationStore.actions.create(name, trigger)
+      const automation = await automationStore.actions.create(name, trigger)
       if (triggerVal.stepId === TriggerStepID.WEBHOOK) {
         webhookModal.show()
       }
       notifications.success(`Automation ${name} created`)
+      $goto(`../automation/${automation._id}`)
     } catch (error) {
       notifications.error("Error creating automation")
     }
diff --git a/packages/builder/src/components/backend/DataTable/buttons/EditRolesButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/EditRolesButton.svelte
deleted file mode 100644
index 3b31de170c..0000000000
--- a/packages/builder/src/components/backend/DataTable/buttons/EditRolesButton.svelte
+++ /dev/null
@@ -1,15 +0,0 @@
-<script>
-  import { Button, Modal } from "@budibase/bbui"
-  import EditRolesModal from "../modals/EditRoles.svelte"
-
-  let modal
-</script>
-
-<div>
-  <Button secondary icon="UsersLock" on:click on:click={modal.show}>
-    Edit roles
-  </Button>
-</div>
-<Modal bind:this={modal} on:show on:hide>
-  <EditRolesModal />
-</Modal>
diff --git a/packages/builder/src/components/backend/DataTable/buttons/ManageAccessButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/ManageAccessButton.svelte
index 22a55ecc03..2826d8d986 100644
--- a/packages/builder/src/components/backend/DataTable/buttons/ManageAccessButton.svelte
+++ b/packages/builder/src/components/backend/DataTable/buttons/ManageAccessButton.svelte
@@ -1,31 +1,195 @@
 <script>
-  import { ActionButton } from "@budibase/bbui"
-  import { permissions } from "stores/builder"
-  import ManageAccessModal from "../modals/ManageAccessModal.svelte"
+  import {
+    ActionButton,
+    Input,
+    Select,
+    Label,
+    List,
+    ListItem,
+    notifications,
+  } from "@budibase/bbui"
+  import { permissions as permissionsStore, roles } from "stores/builder"
   import DetailPopover from "components/common/DetailPopover.svelte"
-  import EditRolesButton from "./EditRolesButton.svelte"
+  import { PermissionSource } from "@budibase/types"
+  import { capitalise } from "helpers"
+  import InfoDisplay from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
+  import { Roles } from "constants/backend"
 
   export let resourceId
 
-  let resourcePermissions
+  const inheritedRoleId = "inherited"
+  const builtins = [Roles.ADMIN, Roles.POWER, Roles.BASIC, Roles.PUBLIC]
+
+  let permissions
   let showPopover = true
+  let dependantsInfoMessage
 
   $: fetchPermissions(resourceId)
+  $: loadDependantInfo(resourceId)
+  $: roleMismatch = checkRoleMismatch(permissions)
+  $: selectedRole = roleMismatch ? null : permissions?.[0]?.value
+  $: readableRole = selectedRole
+    ? $roles.find(x => x._id === selectedRole)?.uiMetadata.displayName
+    : null
+  $: buttonLabel = readableRole ? `Access: ${readableRole}` : "Access"
+  $: highlight = roleMismatch || selectedRole === Roles.PUBLIC
+
+  $: builtInRoles = builtins.map(roleId => $roles.find(x => x._id === roleId))
+  $: customRoles = $roles
+    .filter(x => !builtins.includes(x._id))
+    .slice()
+    .toSorted((a, b) => {
+      const aName = a.uiMetadata.displayName || a.name
+      const bName = b.uiMetadata.displayName || b.name
+      return aName < bName ? -1 : 1
+    })
 
   const fetchPermissions = async id => {
-    resourcePermissions = await permissions.forResourceDetailed(id)
+    const res = await permissionsStore.forResourceDetailed(id)
+    permissions = Object.entries(res?.permissions || {}).map(([perm, info]) => {
+      let enriched = {
+        permission: perm,
+        value:
+          info.permissionType === PermissionSource.INHERITED
+            ? inheritedRoleId
+            : info.role,
+        options: [...$roles],
+      }
+      if (info.inheritablePermission) {
+        enriched.options.unshift({
+          _id: inheritedRoleId,
+          name: `Inherit (${
+            $roles.find(x => x._id === info.inheritablePermission).name
+          })`,
+        })
+      }
+      return enriched
+    })
+  }
+
+  const checkRoleMismatch = permissions => {
+    if (!permissions || permissions.length < 2) {
+      return false
+    }
+    return (
+      permissions[0].value !== permissions[1].value ||
+      permissions[0].value === inheritedRoleId
+    )
+  }
+
+  const loadDependantInfo = async resourceId => {
+    const dependantsInfo = await permissionsStore.getDependantsInfo(resourceId)
+    const resourceByType = dependantsInfo?.resourceByType
+    if (resourceByType) {
+      const total = Object.values(resourceByType).reduce((p, c) => p + c, 0)
+      let resourceDisplay =
+        Object.keys(resourceByType).length === 1 && resourceByType.view
+          ? "view"
+          : "resource"
+
+      if (total === 1) {
+        dependantsInfoMessage = `1 ${resourceDisplay} is inheriting this access`
+      } else if (total > 1) {
+        dependantsInfoMessage = `${total} ${resourceDisplay}s are inheriting this access`
+      } else {
+        dependantsInfoMessage = null
+      }
+    } else {
+      dependantsInfoMessage = null
+    }
+  }
+
+  const changePermission = async role => {
+    try {
+      await permissionsStore.save({
+        level: "read",
+        role,
+        resource: resourceId,
+      })
+      await permissionsStore.save({
+        level: "write",
+        role,
+        resource: resourceId,
+      })
+      await fetchPermissions(resourceId)
+      notifications.success("Updated permissions")
+    } catch (error) {
+      console.error(error)
+      notifications.error("Error updating permissions")
+    }
   }
 </script>
 
-<DetailPopover title="Manage access" {showPopover}>
+<DetailPopover title="Select access role" {showPopover}>
   <svelte:fragment slot="anchor" let:open>
-    <ActionButton icon="LockClosed" selected={open} quiet>Access</ActionButton>
+    <ActionButton
+      icon="LockClosed"
+      selected={open || highlight}
+      quiet
+      accentColor={highlight ? "#ff0000" : null}
+    >
+      {buttonLabel}
+    </ActionButton>
   </svelte:fragment>
-  {#if resourcePermissions}
-    <ManageAccessModal {resourceId} permissions={resourcePermissions} />
+
+  {#if roleMismatch}
+    <div class="row">
+      <Label extraSmall grey>Level</Label>
+      <Label extraSmall grey>Role</Label>
+      {#each permissions as permission}
+        <Input value={capitalise(permission.permission)} disabled />
+        <Select
+          placeholder={false}
+          value={permission.value}
+          on:change={e => changePermission(e.detail)}
+          disabled
+          options={permission.options}
+          getOptionLabel={x => x.name}
+          getOptionValue={x => x._id}
+        />
+      {/each}
+    </div>
+    <InfoDisplay
+      error
+      icon="Alert"
+      body="Your previous configuration is shown above.<br/> Please choose a single role for read and write access."
+    />
+  {/if}
+
+  <List>
+    {#each builtInRoles as role}
+      <ListItem
+        title={role.uiMetadata.displayName}
+        subtitle={role.uiMetadata.description}
+        hoverable
+        selected={selectedRole === role._id}
+        icon="StatusLight"
+        iconColor={role.uiMetadata.color}
+        on:click={() => changePermission(role._id)}
+      />
+    {/each}
+    {#each customRoles as role}
+      <ListItem
+        title={role.uiMetadata.displayName}
+        subtitle={role.uiMetadata.description}
+        hoverable
+        selected={selectedRole === role._id}
+        icon="StatusLight"
+        iconColor={role.uiMetadata.color}
+        on:click={() => changePermission(role._id)}
+      />
+    {/each}
+  </List>
+
+  {#if dependantsInfoMessage}
+    <InfoDisplay info body={dependantsInfoMessage} />
   {/if}
-  <EditRolesButton
-    on:show={() => (showPopover = false)}
-    on:hide={() => (showPopover = true)}
-  />
 </DetailPopover>
+
+<style>
+  .row {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    grid-gap: var(--spacing-s);
+  }
+</style>
diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridAutomationsButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridAutomationsButton.svelte
index 9670bb2f35..b2448eeaf3 100644
--- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridAutomationsButton.svelte
+++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridAutomationsButton.svelte
@@ -34,7 +34,7 @@
 
   const generateAutomation = () => {
     popover?.hide()
-    dispatch("request-generate")
+    dispatch("generate")
   }
 </script>
 
diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridGenerateButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridGenerateButton.svelte
index 5cc3aca19e..5d1ec835d5 100644
--- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridGenerateButton.svelte
+++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridGenerateButton.svelte
@@ -91,7 +91,7 @@
 
 <DetailPopover title="Generate" bind:this={popover}>
   <svelte:fragment slot="anchor" let:open>
-    <ActionButton selected={open}>
+    <ActionButton quiet selected={open}>
       <div class="center">
         <img height={16} alt="magic wand" src={MagicWand} />
         Generate
diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridScreensButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridScreensButton.svelte
index 701a286112..db446b3c9e 100644
--- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridScreensButton.svelte
+++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridScreensButton.svelte
@@ -22,7 +22,7 @@
 
   const generateScreen = () => {
     popover?.hide()
-    dispatch("request-generate")
+    dispatch("generate")
   }
 </script>
 
diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditUser.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditUser.svelte
index a9990d0c2b..5ac2beab65 100644
--- a/packages/builder/src/components/backend/DataTable/modals/CreateEditUser.svelte
+++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditUser.svelte
@@ -125,7 +125,7 @@
     label="Role"
     bind:value={row.roleId}
     options={$roles}
-    getOptionLabel={role => role.name}
+    getOptionLabel={role => role.uiMetadata.displayName}
     getOptionValue={role => role._id}
     disabled={!creating}
   />
diff --git a/packages/builder/src/components/backend/DataTable/modals/EditRoles.svelte b/packages/builder/src/components/backend/DataTable/modals/EditRoles.svelte
deleted file mode 100644
index 6cec28de1d..0000000000
--- a/packages/builder/src/components/backend/DataTable/modals/EditRoles.svelte
+++ /dev/null
@@ -1,174 +0,0 @@
-<script>
-  import {
-    keepOpen,
-    ModalContent,
-    Select,
-    Input,
-    Button,
-    notifications,
-  } from "@budibase/bbui"
-  import { onMount } from "svelte"
-  import { API } from "api"
-  import ErrorsBox from "components/common/ErrorsBox.svelte"
-  import { roles } from "stores/builder"
-
-  const BASE_ROLE = { _id: "", inherits: "BASIC", permissionId: "write" }
-
-  let basePermissions = []
-  let selectedRole = BASE_ROLE
-  let errors = []
-  let builtInRoles = ["Admin", "Power", "Basic", "Public"]
-  let validRegex = /^[a-zA-Z0-9_]*$/
-  // Don't allow editing of public role
-  $: editableRoles = $roles.filter(role => role._id !== "PUBLIC")
-  $: selectedRoleId = selectedRole._id
-  $: otherRoles = editableRoles.filter(role => role._id !== selectedRoleId)
-  $: isCreating = selectedRoleId == null || selectedRoleId === ""
-
-  $: roleNameError = getRoleNameError(selectedRole.name)
-
-  $: valid =
-    selectedRole.name &&
-    selectedRole.inherits &&
-    selectedRole.permissionId &&
-    !builtInRoles.includes(selectedRole.name)
-
-  $: shouldDisableRoleInput =
-    builtInRoles.includes(selectedRole.name) &&
-    selectedRole.name?.toLowerCase() === selectedRoleId?.toLowerCase()
-
-  const fetchBasePermissions = async () => {
-    try {
-      basePermissions = await API.getBasePermissions()
-    } catch (error) {
-      notifications.error("Error fetching base permission options")
-      basePermissions = []
-    }
-  }
-
-  // Changes the selected role
-  const changeRole = event => {
-    const id = event?.detail
-    const role = $roles.find(role => role._id === id)
-    if (role) {
-      selectedRole = {
-        ...role,
-        inherits: role.inherits ?? "",
-        permissionId: role.permissionId ?? "",
-      }
-    } else {
-      selectedRole = BASE_ROLE
-    }
-    errors = []
-  }
-
-  // Saves or creates the selected role
-  const saveRole = async () => {
-    errors = []
-
-    // Clean up empty strings
-    const keys = ["_id", "inherits", "permissionId"]
-    keys.forEach(key => {
-      if (selectedRole[key] === "") {
-        delete selectedRole[key]
-      }
-    })
-
-    // Validation
-    if (!selectedRole.name || selectedRole.name.trim() === "") {
-      errors.push({ message: "Please enter a role name" })
-    }
-    if (!selectedRole.permissionId) {
-      errors.push({ message: "Please choose permissions" })
-    }
-    if (errors.length) {
-      return keepOpen
-    }
-
-    // Save/create the role
-    try {
-      await roles.save(selectedRole)
-      notifications.success("Role saved successfully")
-    } catch (error) {
-      notifications.error(`Error saving role - ${error.message}`)
-      return keepOpen
-    }
-  }
-
-  // Deletes the selected role
-  const deleteRole = async () => {
-    try {
-      await roles.delete(selectedRole)
-      changeRole()
-      notifications.success("Role deleted successfully")
-    } catch (error) {
-      notifications.error(`Error deleting role - ${error.message}`)
-      return false
-    }
-  }
-
-  const getRoleNameError = name => {
-    const hasUniqueRoleName = !otherRoles
-      ?.map(role => role.name)
-      ?.includes(name)
-    const invalidRoleName = !validRegex.test(name)
-    if (!hasUniqueRoleName) {
-      return "Select a unique role name."
-    } else if (invalidRoleName) {
-      return "Please enter a role name consisting of only alphanumeric symbols and underscores"
-    }
-  }
-
-  onMount(fetchBasePermissions)
-</script>
-
-<ModalContent
-  title="Edit Roles"
-  confirmText={isCreating ? "Create" : "Save"}
-  onConfirm={saveRole}
-  disabled={!valid || roleNameError}
->
-  {#if errors.length}
-    <ErrorsBox {errors} />
-  {/if}
-  <Select
-    thin
-    secondary
-    label="Role"
-    value={selectedRoleId}
-    on:change={changeRole}
-    options={editableRoles}
-    placeholder="Create new role"
-    getOptionValue={role => role._id}
-    getOptionLabel={role => role.name}
-  />
-  {#if selectedRole}
-    <Input
-      label="Name"
-      bind:value={selectedRole.name}
-      disabled={!!selectedRoleId}
-      error={roleNameError}
-    />
-    <Select
-      label="Inherits Role"
-      bind:value={selectedRole.inherits}
-      options={selectedRole._id === "BASIC" ? $roles : otherRoles}
-      getOptionValue={role => role._id}
-      getOptionLabel={role => role.name}
-      disabled={shouldDisableRoleInput}
-    />
-    <Select
-      label="Base Permissions"
-      bind:value={selectedRole.permissionId}
-      options={basePermissions}
-      getOptionValue={x => x._id}
-      getOptionLabel={x => x.name}
-      disabled={shouldDisableRoleInput}
-    />
-  {/if}
-  <div slot="footer">
-    {#if !isCreating && !builtInRoles.includes(selectedRole.name)}
-      <Button warning on:click={deleteRole}>Delete</Button>
-    {/if}
-  </div>
-</ModalContent>
diff --git a/packages/builder/src/components/backend/DataTable/modals/ManageAccessModal.svelte b/packages/builder/src/components/backend/DataTable/modals/ManageAccessModal.svelte
deleted file mode 100644
index bc5c437c57..0000000000
--- a/packages/builder/src/components/backend/DataTable/modals/ManageAccessModal.svelte
+++ /dev/null
@@ -1,127 +0,0 @@
-<script>
-  import { PermissionSource } from "@budibase/types"
-  import { roles, permissions as permissionsStore } from "stores/builder"
-  import {
-    Label,
-    Input,
-    Select,
-    notifications,
-    Body,
-    Icon,
-  } from "@budibase/bbui"
-  import { capitalise } from "helpers"
-  import { get } from "svelte/store"
-
-  export let resourceId
-  export let permissions
-
-  const inheritedRoleId = "inherited"
-
-  let dependantsInfoMessage
-
-  $: loadDependantInfo(resourceId)
-  $: computedPermissions = Object.entries(permissions.permissions).reduce(
-    (p, [level, roleInfo]) => {
-      p[level] = {
-        selectedValue:
-          roleInfo.permissionType === PermissionSource.INHERITED
-            ? inheritedRoleId
-            : roleInfo.role,
-        options: [...$roles],
-      }
-      if (roleInfo.inheritablePermission) {
-        p[level].inheritOption = roleInfo.inheritablePermission
-        p[level].options.unshift({
-          _id: inheritedRoleId,
-          name: `Inherit (${
-            get(roles).find(x => x._id === roleInfo.inheritablePermission).name
-          })`,
-        })
-      }
-      return p
-    },
-    {}
-  )
-
-  async function changePermission(level, role) {
-    try {
-      if (role === inheritedRoleId) {
-        await permissionsStore.remove({
-          level,
-          role,
-          resource: resourceId,
-        })
-      } else {
-        await permissionsStore.save({
-          level,
-          role,
-          resource: resourceId,
-        })
-      }
-
-      // Show updated permissions in UI: REMOVE
-      permissions = await permissionsStore.forResourceDetailed(resourceId)
-      notifications.success("Updated permissions")
-    } catch (error) {
-      notifications.error("Error updating permissions")
-    }
-  }
-
-  async function loadDependantInfo(resourceId) {
-    const dependantsInfo = await permissionsStore.getDependantsInfo(resourceId)
-    const resourceByType = dependantsInfo?.resourceByType
-    if (resourceByType) {
-      const total = Object.values(resourceByType).reduce((p, c) => p + c, 0)
-      let resourceDisplay =
-        Object.keys(resourceByType).length === 1 && resourceByType.view
-          ? "view"
-          : "resource"
-
-      if (total === 1) {
-        dependantsInfoMessage = `1 ${resourceDisplay} is inheriting this access.`
-      } else if (total > 1) {
-        dependantsInfoMessage = `${total} ${resourceDisplay}s are inheriting this access.`
-      }
-    }
-  }
-</script>
-
-<Body size="S">Specify the minimum access level role for this data.</Body>
-<div class="row">
-  <Label extraSmall grey>Level</Label>
-  <Label extraSmall grey>Role</Label>
-  {#each Object.keys(computedPermissions) as level}
-    <Input value={capitalise(level)} disabled />
-    <Select
-      placeholder={false}
-      value={computedPermissions[level].selectedValue}
-      on:change={e => changePermission(level, e.detail)}
-      options={computedPermissions[level].options}
-      getOptionLabel={x => x.name}
-      getOptionValue={x => x._id}
-    />
-  {/each}
-</div>
-
-{#if dependantsInfoMessage}
-  <div class="inheriting-resources">
-    <Icon name="Alert" />
-    <Body size="S">
-      <i>
-        {dependantsInfoMessage}
-      </i>
-    </Body>
-  </div>
-{/if}
-
-<style>
-  .row {
-    display: grid;
-    grid-template-columns: 1fr 1fr;
-    grid-gap: var(--spacing-s);
-  }
-  .inheriting-resources {
-    display: flex;
-    gap: var(--spacing-s);
-  }
-</style>
diff --git a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte
index a6078e38fb..d7a98c564a 100644
--- a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte
+++ b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte
@@ -76,6 +76,13 @@
       selectedBy={$userSelectedResourceMap[TableNames.USERS]}
     />
   {/if}
+  <NavItem
+    icon="UserAdmin"
+    text="Manage roles"
+    selected={$isActive("./roles")}
+    on:click={() => $goto("./roles")}
+    selectedBy={$userSelectedResourceMap.roles}
+  />
   {#each enrichedDataSources.filter(ds => ds.show) as datasource}
     <DatasourceNavItem
       {datasource}
diff --git a/packages/builder/src/components/backend/RoleEditor/BracketEdge.svelte b/packages/builder/src/components/backend/RoleEditor/BracketEdge.svelte
new file mode 100644
index 0000000000..182b56f122
--- /dev/null
+++ b/packages/builder/src/components/backend/RoleEditor/BracketEdge.svelte
@@ -0,0 +1,63 @@
+<script>
+  import { BaseEdge } from "@xyflow/svelte"
+  import { NodeWidth, GridResolution } from "./constants"
+  import { getContext } from "svelte"
+
+  export let sourceX
+  export let sourceY
+
+  const { bounds } = getContext("flow")
+
+  $: bracketWidth = GridResolution * 3
+  $: bracketHeight = $bounds.height / 2 + GridResolution * 2
+  $: path = getCurlyBracePath(
+    sourceX + bracketWidth,
+    sourceY - bracketHeight,
+    sourceX + bracketWidth,
+    sourceY + bracketHeight
+  )
+
+  const getCurlyBracePath = (x1, y1, x2, y2) => {
+    const w = 2 // Thickness
+    const q = 1 // Intensity
+    const i = 28 // Inner radius strenth (lower is stronger)
+    const j = 32 // Outer radius strength (higher is stronger)
+
+    // Calculate unit vector
+    var dx = x1 - x2
+    var dy = y1 - y2
+    var len = Math.sqrt(dx * dx + dy * dy)
+    dx = dx / len
+    dy = dy / len
+
+    // Path control points
+    const qx1 = x1 + q * w * dy - j
+    const qy1 = y1 - q * w * dx
+    const qx2 = x1 - 0.25 * len * dx + (1 - q) * w * dy - i
+    const qy2 = y1 - 0.25 * len * dy - (1 - q) * w * dx
+    const tx1 = x1 - 0.5 * len * dx + w * dy - bracketWidth
+    const ty1 = y1 - 0.5 * len * dy - w * dx
+    const qx3 = x2 + q * w * dy - j
+    const qy3 = y2 - q * w * dx
+    const qx4 = x1 - 0.75 * len * dx + (1 - q) * w * dy - i
+    const qy4 = y1 - 0.75 * len * dy - (1 - q) * w * dx
+
+    return `M ${x1} ${y1} Q ${qx1} ${qy1} ${qx2} ${qy2} T ${tx1} ${ty1} M ${x2} ${y2} Q ${qx3} ${qy3} ${qx4} ${qy4} T ${tx1} ${ty1}`
+  }
+</script>
+
+<BaseEdge
+  {...$$props}
+  {path}
+  style="--width:{NodeWidth}px; --x:{sourceX}px; --y:{sourceY}px;"
+/>
+
+<style>
+  :global(#basic-bracket) {
+    animation-timing-function: linear(1, 0);
+  }
+  :global(#admin-bracket) {
+    transform: scale(-1, 1) translateX(calc(var(--width) + 8px));
+    transform-origin: var(--x) var(--y);
+  }
+</style>
diff --git a/packages/builder/src/components/backend/RoleEditor/Controls.svelte b/packages/builder/src/components/backend/RoleEditor/Controls.svelte
new file mode 100644
index 0000000000..b695a71a43
--- /dev/null
+++ b/packages/builder/src/components/backend/RoleEditor/Controls.svelte
@@ -0,0 +1,74 @@
+<script>
+  import { Button, ActionButton } from "@budibase/bbui"
+  import { useSvelteFlow } from "@xyflow/svelte"
+  import { getContext } from "svelte"
+  import { ZoomDuration } from "./constants"
+
+  const { createRole, layoutAndFit } = getContext("flow")
+  const flow = useSvelteFlow()
+</script>
+
+<div class="control top-right">
+  <div class="group">
+    <ActionButton
+      icon="Add"
+      quiet
+      on:click={() => flow.zoomIn({ duration: ZoomDuration })}
+    />
+    <ActionButton
+      icon="Remove"
+      quiet
+      on:click={() => flow.zoomOut({ duration: ZoomDuration })}
+    />
+  </div>
+  <Button secondary on:click={layoutAndFit}>Auto layout</Button>
+</div>
+<div class="control bottom-right">
+  <Button icon="Add" cta on:click={createRole}>Add role</Button>
+</div>
+
+<style>
+  .control {
+    position: absolute;
+    z-index: 10;
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    gap: 12px;
+  }
+  .top-right {
+    top: 20px;
+    right: 20px;
+  }
+  .bottom-right {
+    bottom: 20px;
+    right: 20px;
+  }
+  .top-right :global(.spectrum-Button),
+  .top-right :global(.spectrum-ActionButton),
+  .top-right :global(.spectrum-Icon) {
+    color: var(--spectrum-global-color-gray-900) !important;
+  }
+  .top-right :global(.spectrum-Button),
+  .top-right :global(.spectrum-ActionButton) {
+    background: var(--spectrum-global-color-gray-200) !important;
+  }
+  .top-right :global(.spectrum-Button:hover),
+  .top-right :global(.spectrum-ActionButton:hover) {
+    background: var(--spectrum-global-color-gray-300) !important;
+  }
+  .group {
+    border-radius: 4px;
+    display: flex;
+    flex-direction: row;
+  }
+  .group :global(> *:not(:first-child)) {
+    border-top-left-radius: 0;
+    border-bottom-left-radius: 0;
+    border-left: 2px solid var(--spectrum-global-color-gray-300);
+  }
+  .group :global(> *:not(:last-child)) {
+    border-top-right-radius: 0;
+    border-bottom-right-radius: 0;
+  }
+</style>
diff --git a/packages/builder/src/components/backend/RoleEditor/EmptyStateNode.svelte b/packages/builder/src/components/backend/RoleEditor/EmptyStateNode.svelte
new file mode 100644
index 0000000000..2c949ed0f9
--- /dev/null
+++ b/packages/builder/src/components/backend/RoleEditor/EmptyStateNode.svelte
@@ -0,0 +1,24 @@
+<script>
+  import { NodeWidth, NodeHeight } from "./constants"
+</script>
+
+<div class="node" style={`--width:${NodeWidth}px; --height:${NodeHeight}px;`}>
+  Add custom roles for more granular control over permissions
+</div>
+
+<style>
+  .node {
+    border-radius: 4px;
+    width: var(--width);
+    height: var(--height);
+    display: grid;
+    place-items: center;
+    font-size: 16px;
+    text-align: center;
+    color: var(--spectrum-global-color-gray-800);
+    text-shadow: 4px 4px 10px var(--background-color),
+      4px -4px 10px var(--background-color),
+      -4px 4px 10px var(--background-color),
+      -4px -4px 10px var(--background-color);
+  }
+</style>
diff --git a/packages/builder/src/components/backend/RoleEditor/RoleEdge.svelte b/packages/builder/src/components/backend/RoleEditor/RoleEdge.svelte
new file mode 100644
index 0000000000..84badeb6b2
--- /dev/null
+++ b/packages/builder/src/components/backend/RoleEditor/RoleEdge.svelte
@@ -0,0 +1,123 @@
+<script>
+  import { getBezierPath, BaseEdge, EdgeLabelRenderer } from "@xyflow/svelte"
+  import { Icon, TooltipPosition } from "@budibase/bbui"
+  import { getContext, onMount } from "svelte"
+  import { roles } from "stores/builder"
+
+  export let sourceX
+  export let sourceY
+  export let sourcePosition
+  export let targetX
+  export let targetY
+  export let targetPosition
+  export let id
+  export let source
+  export let target
+
+  const { deleteEdge, selectedNodes } = getContext("flow")
+
+  let iconHovered = false
+  let edgeHovered = false
+
+  $: hovered = iconHovered || edgeHovered
+  $: active =
+    hovered ||
+    $selectedNodes.includes(source) ||
+    $selectedNodes.includes(target)
+  $: edgeClasses = getEdgeClasses(active, iconHovered)
+  $: [edgePath, labelX, labelY] = getBezierPath({
+    sourceX,
+    sourceY,
+    sourcePosition,
+    targetX,
+    targetY,
+    targetPosition,
+  })
+  $: sourceRole = $roles.find(x => x._id === source)
+  $: targetRole = $roles.find(x => x._id === target)
+  $: tooltip =
+    sourceRole && targetRole
+      ? `Stop ${targetRole.uiMetadata.displayName} from inheriting ${sourceRole.uiMetadata.displayName}`
+      : null
+
+  const getEdgeClasses = (active, iconHovered) => {
+    let classes = ""
+    if (active) classes += `active `
+    if (iconHovered) classes += `delete `
+    return classes
+  }
+
+  const onEdgeMouseOver = () => {
+    edgeHovered = true
+  }
+
+  const onEdgeMouseOut = () => {
+    edgeHovered = false
+  }
+
+  onMount(() => {
+    const edge = document.querySelector(`.svelte-flow__edge[data-id="${id}"]`)
+    if (edge) {
+      edge.addEventListener("mouseover", onEdgeMouseOver)
+      edge.addEventListener("mouseout", onEdgeMouseOut)
+    }
+    return () => {
+      if (edge) {
+        edge.removeEventListener("mouseover", onEdgeMouseOver)
+        edge.removeEventListener("mouseout", onEdgeMouseOut)
+      }
+    }
+  })
+</script>
+
+<BaseEdge path={edgePath} class={edgeClasses} />
+<EdgeLabelRenderer>
+  <!-- svelte-ignore a11y-click-events-have-key-events -->
+  <!-- svelte-ignore a11y-no-static-element-interactions -->
+  <!-- svelte-ignore a11y-mouse-events-have-key-events -->
+  <div
+    style:transform="translate(-50%, -50%) translate({labelX}px,{labelY}px)"
+    class="edge-label nodrag nopan"
+    class:active
+    on:click={() => deleteEdge(id)}
+    on:mouseover={() => (iconHovered = true)}
+    on:mouseout={() => (iconHovered = false)}
+  >
+    <Icon
+      name="Delete"
+      size="S"
+      {tooltip}
+      tooltipPosition={TooltipPosition.Top}
+    />
+  </div>
+</EdgeLabelRenderer>
+
+<style>
+  .edge-label {
+    position: absolute;
+    padding: 8px;
+    opacity: 0;
+    pointer-events: none;
+  }
+  .edge-label.active {
+    opacity: 1;
+    pointer-events: all;
+    cursor: pointer;
+  }
+  .edge-label:hover :global(.spectrum-Icon) {
+    color: var(--spectrum-global-color-red-400);
+  }
+  .edge-label :global(.spectrum-Icon) {
+    background: var(--background-color);
+    color: var(--spectrum-global-color-gray-600);
+  }
+  .edge-label :global(svg) {
+    padding: 4px;
+  }
+  :global(.svelte-flow__edge-path.active) {
+    stroke: var(--spectrum-global-color-blue-400);
+  }
+  :global(.svelte-flow__edge-path.active.delete) {
+    stroke: var(--spectrum-global-color-red-400);
+  }
+</style>
diff --git a/packages/builder/src/components/backend/RoleEditor/RoleEditor.svelte b/packages/builder/src/components/backend/RoleEditor/RoleEditor.svelte
new file mode 100644
index 0000000000..6169013d12
--- /dev/null
+++ b/packages/builder/src/components/backend/RoleEditor/RoleEditor.svelte
@@ -0,0 +1,8 @@
+<script>
+  import { SvelteFlowProvider } from "@xyflow/svelte"
+  import RoleFlow from "./RoleFlow.svelte"
+</script>
+
+<SvelteFlowProvider>
+  <RoleFlow />
+</SvelteFlowProvider>
diff --git a/packages/builder/src/components/backend/RoleEditor/RoleFlow.svelte b/packages/builder/src/components/backend/RoleEditor/RoleFlow.svelte
new file mode 100644
index 0000000000..90c0a9fca8
--- /dev/null
+++ b/packages/builder/src/components/backend/RoleEditor/RoleFlow.svelte
@@ -0,0 +1,234 @@
+<script>
+  import { Heading, Helpers, notifications } from "@budibase/bbui"
+  import { writable, derived } from "svelte/store"
+  import {
+    SvelteFlow,
+    Background,
+    BackgroundVariant,
+    useSvelteFlow,
+  } from "@xyflow/svelte"
+  import "@xyflow/svelte/dist/style.css"
+  import RoleNode from "./RoleNode.svelte"
+  import EmptyStateNode from "./EmptyStateNode.svelte"
+  import RoleEdge from "./RoleEdge.svelte"
+  import BracketEdge from "./BracketEdge.svelte"
+  import {
+    autoLayout,
+    getAdminPosition,
+    getBasicPosition,
+    rolesToLayout,
+    nodeToRole,
+    getBounds,
+  } from "./utils"
+  import { setContext, tick } from "svelte"
+  import Controls from "./Controls.svelte"
+  import { GridResolution, MaxAutoZoom, ZoomDuration } from "./constants"
+  import { roles } from "stores/builder"
+  import { Roles } from "constants/backend"
+  import { getSequentialName } from "helpers/duplicate"
+  import { derivedMemo } from "@budibase/frontend-core"
+
+  const flow = useSvelteFlow()
+  const edges = writable([])
+  const nodes = writable([])
+  const dragging = writable(false)
+
+  // Derive the list of selected nodes
+  const selectedNodes = derived(nodes, $nodes => {
+    return $nodes.filter(node => node.selected).map(node => node.id)
+  })
+
+  // Derive the bounds of all custom role nodes
+  const bounds = derivedMemo(nodes, getBounds)
+
+  $: handleExternalRoleChanges($roles)
+  $: updateBuiltins($bounds)
+
+  // Updates nodes and edges based on external changes to roles
+  const handleExternalRoleChanges = roles => {
+    const currentNodes = $nodes
+    const newLayout = autoLayout(rolesToLayout(roles))
+    edges.set(newLayout.edges)
+
+    // For nodes we want to persist some metadata if possible
+    nodes.set(
+      newLayout.nodes.map(node => {
+        const currentNode = currentNodes.find(x => x.id === node.id)
+        if (!currentNode) {
+          return node
+        }
+        return {
+          ...node,
+          position: currentNode.position || node.position,
+          selected: currentNode.selected || node.selected,
+        }
+      })
+    )
+  }
+
+  // Positions the basic and admin role at either edge of the flow
+  const updateBuiltins = bounds => {
+    flow.updateNode(Roles.BASIC, {
+      position: getBasicPosition(bounds),
+    })
+    flow.updateNode(Roles.ADMIN, {
+      position: getAdminPosition(bounds),
+    })
+  }
+
+  // Automatically lays out all roles and edges and zooms to fit them
+  const layoutAndFit = () => {
+    const layout = autoLayout({ nodes: $nodes, edges: $edges })
+    nodes.set(layout.nodes)
+    edges.set(layout.edges)
+    flow.fitView({ maxZoom: MaxAutoZoom, duration: ZoomDuration })
+  }
+
+  const createRole = async () => {
+    const roleId = Helpers.uuid()
+    await roles.save({
+      name: roleId,
+      uiMetadata: {
+        displayName: getSequentialName($roles, "New role ", {
+          getName: role => role.uiMetadata.displayName,
+        }),
+        color: "var(--spectrum-global-color-gray-700)",
+        description: "Custom role",
+      },
+      inherits: [Roles.BASIC],
+    })
+    await tick()
+    layoutAndFit()
+
+    // Select the new node
+    nodes.update($nodes => {
+      return $nodes.map(node => ({
+        ...node,
+        selected: node.id === roleId,
+      }))
+    })
+  }
+
+  const updateRole = async (roleId, metadata) => {
+    const node = $nodes.find(node => node.id === roleId)
+    if (!node) {
+      return
+    }
+    // Update metadata immediately, before saving
+    if (metadata) {
+      flow.updateNodeData(roleId, metadata)
+    }
+    try {
+      await roles.save(nodeToRole({ node, edges: $edges }))
+      layoutAndFit()
+    } catch (error) {
+      notifications.error(error?.message || error || "Failed to update role")
+      handleExternalRoleChanges($roles)
+    }
+  }
+
+  const deleteRole = async roleId => {
+    nodes.set($nodes.filter(node => node.id !== roleId))
+    layoutAndFit()
+    const role = $roles.find(role => role._id === roleId)
+    if (role) {
+      roles.delete(role)
+    }
+  }
+
+  const deleteEdge = async edgeId => {
+    const edge = $edges.find(edge => edge.id === edgeId)
+    edges.set($edges.filter(edge => edge.id !== edgeId))
+    await updateRole(edge.target)
+  }
+
+  const onConnect = async connection => {
+    await updateRole(connection.target)
+  }
+
+  setContext("flow", {
+    nodes,
+    edges,
+    dragging,
+    selectedNodes,
+    bounds,
+    createRole,
+    updateRole,
+    deleteRole,
+    deleteEdge,
+    layoutAndFit,
+  })
+</script>
+
+<div class="title">
+  <div class="heading" />
+</div>
+<div class="flow">
+  <SvelteFlow
+    fitView
+    {nodes}
+    {edges}
+    snapGrid={[GridResolution, GridResolution]}
+    nodeTypes={{ role: RoleNode, empty: EmptyStateNode }}
+    edgeTypes={{ role: RoleEdge, bracket: BracketEdge }}
+    proOptions={{ hideAttribution: true }}
+    fitViewOptions={{ maxZoom: MaxAutoZoom }}
+    defaultEdgeOptions={{ type: "role", animated: true, selectable: false }}
+    onconnectstart={() => dragging.set(true)}
+    onconnectend={() => dragging.set(false)}
+    onconnect={onConnect}
+    deleteKey={null}
+  >
+    <Background variant={BackgroundVariant.Dots} />
+    <Controls />
+    <div class="title">
+      <Heading size="S">Manage roles</Heading>
+    </div>
+    <div class="footer">Roles inherit permissions from each other</div>
+  </SvelteFlow>
+</div>
+
+<style>
+  .flow {
+    margin: -28px -40px -40px -40px;
+    flex: 1 1 auto;
+    overflow: hidden;
+    position: relative;
+    --background-color: var(--spectrum-global-color-gray-50);
+    --border-color: var(--spectrum-global-color-gray-300);
+    --edge-color: var(--spectrum-global-color-gray-500);
+    --handle-color: var(--spectrum-global-color-gray-600);
+    --selected-color: var(--spectrum-global-color-blue-400);
+  }
+  .title {
+    position: absolute;
+    top: 20px;
+    left: 20px;
+    z-index: 10;
+  }
+  .footer {
+    position: absolute;
+    left: 20px;
+    bottom: 20px;
+    color: var(--spectrum-global-color-gray-600);
+    z-index: 10;
+  }
+
+  /* Customise svelte-flow theme */
+  .flow :global(.svelte-flow) {
+    /* Panel */
+    --xy-background-color: var(--background-color);
+
+    /* Controls */
+    --xy-controls-button-border-color: var(--border-color);
+
+    /* Handles */
+    --xy-handle-background-color: var(--handle-color);
+    --xy-handle-border-color: var(--handle-color);
+
+    /* Edges */
+    --xy-edge-stroke: var(--edge-color);
+    --xy-edge-stroke-selected: var(--edge-color);
+    --xy-edge-stroke-width: 2px;
+  }
+</style>
diff --git a/packages/builder/src/components/backend/RoleEditor/RoleNode.svelte b/packages/builder/src/components/backend/RoleEditor/RoleNode.svelte
new file mode 100644
index 0000000000..32a95f4278
--- /dev/null
+++ b/packages/builder/src/components/backend/RoleEditor/RoleNode.svelte
@@ -0,0 +1,231 @@
+<script>
+  import { Handle, Position } from "@xyflow/svelte"
+  import {
+    Icon,
+    Input,
+    ColorPicker,
+    Modal,
+    ModalContent,
+    FieldLabel,
+  } from "@budibase/bbui"
+  import { NodeWidth, NodeHeight } from "./constants"
+  import { getContext } from "svelte"
+  import { roles } from "stores/builder"
+  import ConfirmDialog from "components/common/ConfirmDialog.svelte"
+
+  export let data
+  export let id
+  export let selected
+  export let isConnectable
+
+  const { dragging, updateRole, deleteRole } = getContext("flow")
+
+  let anchor
+  let modal
+  let tempDisplayName
+  let tempDescription
+  let tempColor
+  let deleteModal
+
+  $: nameError = validateName(tempDisplayName, $roles)
+  $: descriptionError = validateDescription(tempDescription)
+  $: invalid = nameError || descriptionError
+
+  const validateName = (name, roles) => {
+    if (!name?.length) {
+      return "Please enter a name"
+    }
+    if (roles.some(x => x.uiMetadata.displayName === name && x._id !== id)) {
+      return "That name is already used by another role"
+    }
+    return null
+  }
+
+  const validateDescription = description => {
+    if (!description?.length) {
+      return "Please enter a name"
+    }
+    return null
+  }
+
+  const openPopover = e => {
+    e.stopPropagation()
+    tempDisplayName = data.displayName
+    tempDescription = data.description
+    tempColor = data.color
+    modal.show()
+  }
+
+  const saveChanges = () => {
+    updateRole(id, {
+      displayName: tempDisplayName,
+      description: tempDescription,
+      color: tempColor,
+    })
+  }
+</script>
+
+<div
+  class="node"
+  class:dragging={$dragging}
+  class:selected
+  class:interactive={data.interactive}
+  class:custom={data.custom}
+  class:selectable={isConnectable}
+  style={`--color:${data.color}; --width:${NodeWidth}px; --height:${NodeHeight}px;`}
+  bind:this={anchor}
+>
+  <div class="color" />
+  <div class="content">
+    <div class="text">
+      <div class="name">
+        {data.displayName}
+      </div>
+      {#if data.description}
+        <div class="description" title={data.description}>
+          {data.description}
+        </div>
+      {/if}
+    </div>
+    {#if data.custom}
+      <div class="buttons">
+        <Icon size="S" name="Edit" hoverable on:click={openPopover} />
+        <Icon size="S" name="Delete" hoverable on:click={deleteModal?.show} />
+      </div>
+    {/if}
+  </div>
+  <Handle
+    type="target"
+    position={Position.Left}
+    isConnectable={isConnectable && $dragging && data.custom}
+  />
+  <Handle type="source" position={Position.Right} {isConnectable} />
+</div>
+
+<ConfirmDialog
+  bind:this={deleteModal}
+  title={`Delete ${data.displayName}`}
+  body="Are you sure you want to delete this role? This can't be undone."
+  okText="Delete"
+  onOk={async () => await deleteRole(id)}
+/>
+
+<Modal bind:this={modal}>
+  <ModalContent
+    title={`Edit ${data.displayName}`}
+    confirmText="Save"
+    onConfirm={saveChanges}
+    disabled={invalid}
+  >
+    <Input
+      label="Name"
+      value={tempDisplayName}
+      error={nameError}
+      on:change={e => (tempDisplayName = e.detail)}
+    />
+    <Input
+      label="Description"
+      value={tempDescription}
+      error={descriptionError}
+      on:change={e => (tempDescription = e.detail)}
+    />
+    <div>
+      <FieldLabel label="Color" />
+      <ColorPicker value={tempColor} on:change={e => (tempColor = e.detail)} />
+    </div>
+  </ModalContent>
+</Modal>
+
+<style>
+  /* Node styles */
+  .node {
+    position: relative;
+    background: var(--spectrum-global-color-gray-100);
+    border-radius: 4px;
+    width: var(--width);
+    height: var(--height);
+    display: flex;
+    flex-direction: row;
+    box-sizing: border-box;
+    transition: background 130ms ease-out;
+  }
+  .node.selectable:hover {
+    cursor: pointer;
+    background: var(--spectrum-global-color-gray-200);
+  }
+  .node.selectable.selected {
+    background: var(--spectrum-global-color-blue-100);
+    cursor: grab;
+  }
+  .color {
+    border-top-left-radius: 4px;
+    border-bottom-left-radius: 4px;
+    height: 100%;
+    width: 10px;
+    flex: 0 0 10px;
+    background: var(--color);
+  }
+
+  /* Main container */
+  .content {
+    flex: 1 1 auto;
+    display: flex;
+    flex-direction: row;
+    border: 1px solid var(--border-color);
+    border-left-width: 0;
+    border-top-right-radius: 4px;
+    border-bottom-right-radius: 4px;
+    padding: 12px;
+    gap: 6px;
+  }
+  .node.selected .content {
+    border-color: var(--spectrum-global-color-blue-100);
+  }
+
+  /* Text */
+  .text {
+    width: 0;
+    flex: 1 1 auto;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: stretch;
+  }
+  .name,
+  .description {
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    overflow: hidden;
+  }
+  .description {
+    color: var(--spectrum-global-color-gray-600);
+    font-size: 12px;
+  }
+
+  /* Icons */
+  .buttons {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    gap: 6px;
+  }
+  .buttons :global(.spectrum-Icon) {
+    color: var(--spectrum-global-color-gray-600);
+  }
+
+  /* Handles */
+  .node :global(.svelte-flow__handle) {
+    width: 6px;
+    height: 6px;
+    border-width: 2px;
+  }
+  .node :global(.svelte-flow__handle.target) {
+    background: var(--background-color);
+  }
+  .node:not(.dragging) :global(.svelte-flow__handle.target),
+  .node:not(.interactive) :global(.svelte-flow__handle),
+  .node:not(.custom) :global(.svelte-flow__handle.target) {
+    visibility: hidden;
+    pointer-events: none;
+  }
+</style>
diff --git a/packages/builder/src/components/backend/RoleEditor/constants.js b/packages/builder/src/components/backend/RoleEditor/constants.js
new file mode 100644
index 0000000000..6f188e2141
--- /dev/null
+++ b/packages/builder/src/components/backend/RoleEditor/constants.js
@@ -0,0 +1,9 @@
+export const ZoomDuration = 300
+export const MaxAutoZoom = 1.2
+export const GridResolution = 20
+export const NodeHeight = GridResolution * 3
+export const NodeWidth = GridResolution * 12
+export const NodeHSpacing = GridResolution * 6
+export const NodeVSpacing = GridResolution * 2
+export const MinHeight = GridResolution * 10
+export const EmptyStateID = "empty"
diff --git a/packages/builder/src/components/backend/RoleEditor/utils.js b/packages/builder/src/components/backend/RoleEditor/utils.js
new file mode 100644
index 0000000000..a958fc6401
--- /dev/null
+++ b/packages/builder/src/components/backend/RoleEditor/utils.js
@@ -0,0 +1,245 @@
+import dagre from "@dagrejs/dagre"
+import {
+  NodeWidth,
+  NodeHeight,
+  GridResolution,
+  NodeHSpacing,
+  NodeVSpacing,
+  MinHeight,
+  EmptyStateID,
+} from "./constants"
+import { getNodesBounds, Position } from "@xyflow/svelte"
+import { Roles } from "constants/backend"
+import { roles } from "stores/builder"
+import { get } from "svelte/store"
+
+// Calculates the bounds of all custom nodes
+export const getBounds = nodes => {
+  const interactiveNodes = nodes.filter(node => node.data.interactive)
+
+  // Empty state bounds which line up with bounds after adding first node
+  if (!interactiveNodes.length) {
+    return {
+      x: 0,
+      y: -3.5 * GridResolution,
+      width: 12 * GridResolution,
+      height: 10 * GridResolution,
+    }
+  }
+  let bounds = getNodesBounds(interactiveNodes)
+
+  // Enforce a min size
+  if (bounds.height < MinHeight) {
+    const diff = MinHeight - bounds.height
+    bounds.height = MinHeight
+    bounds.y -= diff / 2
+  }
+  return bounds
+}
+
+// Gets the position of the basic role
+export const getBasicPosition = bounds => ({
+  x: bounds.x - NodeHSpacing - NodeWidth,
+  y: bounds.y + bounds.height / 2 - NodeHeight / 2,
+})
+
+// Gets the position of the admin role
+export const getAdminPosition = bounds => ({
+  x: bounds.x + bounds.width + NodeHSpacing,
+  y: bounds.y + bounds.height / 2 - NodeHeight / 2,
+})
+
+// Filters out invalid nodes and edges
+const preProcessLayout = ({ nodes, edges }) => {
+  const ignoredIds = [Roles.PUBLIC, Roles.BASIC, Roles.ADMIN, EmptyStateID]
+  const targetlessIds = [Roles.POWER]
+  return {
+    nodes: nodes.filter(node => {
+      // Filter out ignored IDs
+      if (ignoredIds.includes(node.id)) {
+        return false
+      }
+      return true
+    }),
+    edges: edges.filter(edge => {
+      // Filter out edges from ignored IDs
+      if (
+        ignoredIds.includes(edge.source) ||
+        ignoredIds.includes(edge.target)
+      ) {
+        return false
+      }
+      // Filter out edges which have the same source and target
+      if (edge.source === edge.target) {
+        return false
+      }
+      // Filter out edges which target targetless roles
+      if (targetlessIds.includes(edge.target)) {
+        return false
+      }
+      return true
+    }),
+  }
+}
+
+// Updates positions of nodes and edges into a nice graph structure
+export const dagreLayout = ({ nodes, edges }) => {
+  const dagreGraph = new dagre.graphlib.Graph()
+  dagreGraph.setDefaultEdgeLabel(() => ({}))
+  dagreGraph.setGraph({
+    rankdir: "LR",
+    ranksep: NodeHSpacing,
+    nodesep: NodeVSpacing,
+  })
+  nodes.forEach(node => {
+    dagreGraph.setNode(node.id, { width: NodeWidth, height: NodeHeight })
+  })
+  edges.forEach(edge => {
+    dagreGraph.setEdge(edge.source, edge.target)
+  })
+  dagre.layout(dagreGraph)
+  nodes.forEach(node => {
+    const pos = dagreGraph.node(node.id)
+    node.targetPosition = Position.Left
+    node.sourcePosition = Position.Right
+    node.position = {
+      x: Math.round((pos.x - NodeWidth / 2) / GridResolution) * GridResolution,
+      y: Math.round((pos.y - NodeHeight / 2) / GridResolution) * GridResolution,
+    }
+  })
+  return { nodes, edges }
+}
+
+const postProcessLayout = ({ nodes, edges }) => {
+  // Add basic and admin nodes at each edge
+  const bounds = getBounds(nodes)
+  const $roles = get(roles)
+  nodes.push({
+    ...roleToNode($roles.find(role => role._id === Roles.BASIC)),
+    position: getBasicPosition(bounds),
+  })
+  nodes.push({
+    ...roleToNode($roles.find(role => role._id === Roles.ADMIN)),
+    position: getAdminPosition(bounds),
+  })
+
+  // Add custom edges for basic and admin brackets
+  edges.push({
+    id: "basic-bracket",
+    source: Roles.BASIC,
+    target: Roles.ADMIN,
+    type: "bracket",
+  })
+  edges.push({
+    id: "admin-bracket",
+    source: Roles.ADMIN,
+    target: Roles.BASIC,
+    type: "bracket",
+  })
+
+  // Add empty state node if required
+  if (!nodes.some(node => node.data.interactive)) {
+    nodes.push({
+      id: EmptyStateID,
+      type: "empty",
+      position: {
+        x: bounds.x + bounds.width / 2 - NodeWidth / 2,
+        y: bounds.y + bounds.height / 2 - NodeHeight / 2,
+      },
+      data: {},
+      measured: {
+        width: NodeWidth,
+        height: NodeHeight,
+      },
+      deletable: false,
+      draggable: false,
+      connectable: false,
+      selectable: false,
+    })
+  }
+
+  return { nodes, edges }
+}
+
+// Automatically lays out the graph, sanitising and enriching the structure
+export const autoLayout = ({ nodes, edges }) => {
+  return postProcessLayout(dagreLayout(preProcessLayout({ nodes, edges })))
+}
+
+// Converts a role doc into a node structure
+export const roleToNode = role => {
+  const custom = ![
+    Roles.PUBLIC,
+    Roles.BASIC,
+    Roles.POWER,
+    Roles.ADMIN,
+    Roles.BUILDER,
+  ].includes(role._id)
+  const interactive = custom || role._id === Roles.POWER
+  return {
+    id: role._id,
+    sourcePosition: Position.Right,
+    targetPosition: Position.Left,
+    type: "role",
+    position: { x: 0, y: 0 },
+    data: {
+      ...role.uiMetadata,
+      custom,
+      interactive,
+    },
+    measured: {
+      width: NodeWidth,
+      height: NodeHeight,
+    },
+    deletable: custom,
+    draggable: interactive,
+    connectable: interactive,
+    selectable: interactive,
+  }
+}
+
+// Converts a node structure back into a role doc
+export const nodeToRole = ({ node, edges }) => ({
+  ...get(roles).find(role => role._id === node.id),
+  inherits: edges
+    .filter(x => x.target === node.id)
+    .map(x => x.source)
+    .concat(Roles.BASIC),
+  uiMetadata: {
+    displayName: node.data.displayName,
+    color: node.data.color,
+    description: node.data.description,
+  },
+})
+
+// Builds a default layout from an array of roles
+export const rolesToLayout = roles => {
+  let nodes = []
+  let edges = []
+
+  // Add all nodes and edges
+  for (let role of roles) {
+    // Add node for this role
+    nodes.push(roleToNode(role))
+
+    // Add edges for this role
+    let inherits = []
+    if (role.inherits) {
+      inherits = Array.isArray(role.inherits) ? role.inherits : [role.inherits]
+    }
+    for (let sourceRole of inherits) {
+      if (!roles.some(x => x._id === sourceRole)) {
+        continue
+      }
+      edges.push({
+        id: `${sourceRole}-${role._id}`,
+        source: sourceRole,
+        target: role._id,
+      })
+    }
+  }
+  return {
+    nodes,
+    edges,
+  }
+}
diff --git a/packages/builder/src/components/common/RoleIcon.svelte b/packages/builder/src/components/common/RoleIcon.svelte
index 1bd6ba49bc..3b48935e0c 100644
--- a/packages/builder/src/components/common/RoleIcon.svelte
+++ b/packages/builder/src/components/common/RoleIcon.svelte
@@ -1,12 +1,14 @@
 <script>
-  import { RoleUtils } from "@budibase/frontend-core"
   import { StatusLight } from "@budibase/bbui"
+  import { roles } from "stores/builder"
 
   export let id
   export let size = "M"
   export let disabled = false
 
-  $: color = RoleUtils.getRoleColour(id)
+  $: color =
+    $roles.find(x => x._id === id)?.color ||
+    "var(--spectrum-global-color-static-magenta-400)"
 </script>
 
 <StatusLight square {disabled} {size} {color} />
diff --git a/packages/builder/src/components/common/RoleSelect.svelte b/packages/builder/src/components/common/RoleSelect.svelte
index 6006b8ab8d..38b84e964d 100644
--- a/packages/builder/src/components/common/RoleSelect.svelte
+++ b/packages/builder/src/components/common/RoleSelect.svelte
@@ -3,7 +3,7 @@
   import { roles } from "stores/builder"
   import { licensing } from "stores/portal"
 
-  import { Constants, RoleUtils } from "@budibase/frontend-core"
+  import { Constants } from "@budibase/frontend-core"
   import { createEventDispatcher } from "svelte"
   import { capitalise } from "helpers"
 
@@ -49,7 +49,8 @@
       let options = roles
         .filter(role => allowedRoles.includes(role._id))
         .map(role => ({
-          name: enrichLabel(role.name),
+          color: role.uiMetadata.color,
+          name: enrichLabel(role.uiMetadata.displayName),
           _id: role._id,
         }))
       if (allowedRoles.includes(Constants.Roles.CREATOR)) {
@@ -64,7 +65,8 @@
 
     // Allow all core roles
     let options = roles.map(role => ({
-      name: enrichLabel(role.name),
+      color: role.uiMetadata.color,
+      name: enrichLabel(role.uiMetadata.displayName),
       _id: role._id,
     }))
 
@@ -100,7 +102,7 @@
     if (role._id === Constants.Roles.CREATOR || role._id === RemoveID) {
       return null
     }
-    return RoleUtils.getRoleColour(role._id)
+    return role.color || "var(--spectrum-global-color-static-magenta-400)"
   }
 
   const getIcon = role => {
diff --git a/packages/builder/src/components/design/settings/controls/RoleSelect.svelte b/packages/builder/src/components/design/settings/controls/RoleSelect.svelte
index 5b5daac183..b99e58f205 100644
--- a/packages/builder/src/components/design/settings/controls/RoleSelect.svelte
+++ b/packages/builder/src/components/design/settings/controls/RoleSelect.svelte
@@ -1,20 +1,21 @@
 <script>
   import { Select } from "@budibase/bbui"
   import { roles } from "stores/builder"
-  import { RoleUtils } from "@budibase/frontend-core"
 
   export let value
   export let error
   export let placeholder = null
+  export let autoWidth = false
 </script>
 
 <Select
   bind:value
   on:change
   options={$roles}
-  getOptionLabel={role => role.name}
+  getOptionLabel={role => role.uiMetadata.displayName}
   getOptionValue={role => role._id}
-  getOptionColour={role => RoleUtils.getRoleColour(role._id)}
+  getOptionColour={role => role.uiMetadata.color}
   {placeholder}
   {error}
+  {autoWidth}
 />
diff --git a/packages/builder/src/components/integration/AccessLevelSelect.svelte b/packages/builder/src/components/integration/AccessLevelSelect.svelte
index 05b336c3b3..02ea50e780 100644
--- a/packages/builder/src/components/integration/AccessLevelSelect.svelte
+++ b/packages/builder/src/components/integration/AccessLevelSelect.svelte
@@ -1,7 +1,8 @@
 <script>
-  import { Label, notifications, Select } from "@budibase/bbui"
-  import { permissions, roles } from "stores/builder"
+  import { Label, notifications } from "@budibase/bbui"
+  import { permissions } from "stores/builder"
   import { Constants } from "@budibase/frontend-core"
+  import RoleSelect from "components/design/settings/controls/RoleSelect.svelte"
 
   export let query
   export let label
@@ -52,12 +53,5 @@
   {#if label}
     <Label>{label}</Label>
   {/if}
-  <Select
-    value={roleId}
-    on:change={e => updateRole(e.detail)}
-    options={$roles}
-    getOptionLabel={x => x.name}
-    getOptionValue={x => x._id}
-    autoWidth
-  />
+  <RoleSelect value={roleId} on:change={e => updateRole(e.detail)} autoWidth />
 {/if}
diff --git a/packages/builder/src/components/integration/RestQueryViewer.svelte b/packages/builder/src/components/integration/RestQueryViewer.svelte
index 0489167823..3b1356efe2 100644
--- a/packages/builder/src/components/integration/RestQueryViewer.svelte
+++ b/packages/builder/src/components/integration/RestQueryViewer.svelte
@@ -503,7 +503,7 @@
             on:save={saveQuery}
           />
           <div class="access">
-            <Label>Access level</Label>
+            <Label>Access</Label>
             <AccessLevelSelect {query} {saveId} />
           </div>
         </div>
diff --git a/packages/builder/src/components/start/CreateAppModal.svelte b/packages/builder/src/components/start/CreateAppModal.svelte
index b5fed9cdc1..83ed089006 100644
--- a/packages/builder/src/components/start/CreateAppModal.svelte
+++ b/packages/builder/src/components/start/CreateAppModal.svelte
@@ -118,14 +118,17 @@
       if ($values.url) {
         data.append("url", $values.url.trim())
       }
-      data.append("useTemplate", template != null)
-      if (template) {
-        data.append("templateName", template.name)
-        data.append("templateKey", template.key)
-        data.append("templateFile", $values.file)
+
+      if (template?.fromFile) {
+        data.append("useTemplate", true)
+        data.append("fileToImport", $values.file)
         if ($values.encryptionPassword?.trim()) {
           data.append("encryptionPassword", $values.encryptionPassword.trim())
         }
+      } else if (template) {
+        data.append("useTemplate", true)
+        data.append("templateName", template.name)
+        data.append("templateKey", template.key)
       }
 
       // Create App
diff --git a/packages/builder/src/dataBinding.js b/packages/builder/src/dataBinding.js
index 3eefb373ca..a59ce16e5a 100644
--- a/packages/builder/src/dataBinding.js
+++ b/packages/builder/src/dataBinding.js
@@ -725,10 +725,10 @@ const getRoleBindings = () => {
     return {
       type: "context",
       runtimeBinding: `'${role._id}'`,
-      readableBinding: `Role.${role.name}`,
+      readableBinding: `Role.${role.uiMetadata.displayName}`,
       category: "Role",
       icon: "UserGroup",
-      display: { type: "string", name: role.name },
+      display: { type: "string", name: role.uiMetadata.displayName },
     }
   })
 }
diff --git a/packages/builder/src/pages/builder/app/[application]/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/_layout.svelte
index be8337b376..8e109d3145 100644
--- a/packages/builder/src/pages/builder/app/[application]/_layout.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/_layout.svelte
@@ -228,7 +228,7 @@
   .top-nav {
     flex: 0 0 60px;
     background: var(--background);
-    padding-left: var(--spacing-xl);
+    padding: 0 var(--spacing-xl);
     display: grid;
     grid-template-columns: 1fr auto 1fr;
     flex-direction: row;
@@ -269,6 +269,7 @@
     flex-direction: row;
     justify-content: flex-end;
     align-items: center;
+    margin-right: calc(-1 * var(--spacing-xl));
   }
 
   .toprightnav :global(.avatars) {
diff --git a/packages/builder/src/pages/builder/app/[application]/data/roles.svelte b/packages/builder/src/pages/builder/app/[application]/data/roles.svelte
new file mode 100644
index 0000000000..0177577730
--- /dev/null
+++ b/packages/builder/src/pages/builder/app/[application]/data/roles.svelte
@@ -0,0 +1,8 @@
+<script>
+  import RoleEditor from "components/backend/RoleEditor/RoleEditor.svelte"
+  import { builderStore } from "stores/builder"
+
+  builderStore.selectResource("roles")
+</script>
+
+<RoleEditor />
diff --git a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/[viewId]/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/[viewId]/index.svelte
index 52bed9c72c..44fb70b9d0 100644
--- a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/[viewId]/index.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/[viewId]/index.svelte
@@ -63,17 +63,15 @@
     {#if calculation}
       <GridViewCalculationButton />
     {/if}
+    <GridManageAccessButton />
     <GridFilterButton />
     <GridSortButton />
     <GridSizeButton />
-    <GridManageAccessButton />
     {#if !calculation}
       <GridColumnsSettingButton />
       <GridRowActionsButton />
-      <GridScreensButton on:request-generate={() => generateButton?.show()} />
+      <GridScreensButton on:generate={() => generateButton?.show()} />
     {/if}
-  </svelte:fragment>
-  <svelte:fragment slot="controls-right">
     <GridGenerateButton bind:this={generateButton} />
   </svelte:fragment>
   <GridCreateEditRowModal />
diff --git a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte
index 69a23f3a0e..e300768bf1 100644
--- a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte
@@ -6,6 +6,7 @@
     integrations,
     appStore,
     rowActions,
+    roles,
   } from "stores/builder"
   import { themeStore, admin, licensing } from "stores/portal"
   import { TableNames } from "constants"
@@ -26,16 +27,20 @@
   import GridRowActionsButton from "components/backend/DataTable/buttons/grid/GridRowActionsButton.svelte"
   import { DB_TYPE_EXTERNAL } from "constants/backend"
 
-  const userSchemaOverrides = {
+  let generateButton
+
+  $: userSchemaOverrides = {
     firstName: { displayName: "First name", disabled: true },
     lastName: { displayName: "Last name", disabled: true },
     email: { displayName: "Email", disabled: true },
-    roleId: { displayName: "Role", disabled: true },
     status: { displayName: "Status", disabled: true },
+    roleId: {
+      displayName: "Role",
+      type: "role",
+      disabled: true,
+      roles: $roles,
+    },
   }
-
-  let generateButton
-
   $: autoColumnStatus = verifyAutocolumns($tables?.selected)
   $: duplicates = Object.values(autoColumnStatus).reduce((acc, status) => {
     if (status.length > 1) {
@@ -141,17 +146,11 @@
         <GridRelationshipButton />
       {/if}
       {#if !isUsersTable}
-        <GridRowActionsButton />
-        <GridScreensButton on:request-generate={() => generateButton?.show()} />
-        <GridAutomationsButton
-          on:request-generate={() => generateButton?.show()}
-        />
         <GridImportButton />
-      {/if}
-      <GridExportButton />
-    </svelte:fragment>
-    <svelte:fragment slot="controls-right">
-      {#if !isUsersTable}
+        <GridExportButton />
+        <GridRowActionsButton />
+        <GridScreensButton on:generate={() => generateButton?.show()} />
+        <GridAutomationsButton on:generate={() => generateButton?.show()} />
         <GridGenerateButton bind:this={generateButton} />
       {/if}
     </svelte:fragment>
diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte
index 60c908c7c5..88ddf6f9a5 100644
--- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte
@@ -5,9 +5,11 @@
   export let body
   export let icon = "HelpOutline"
   export let quiet = false
+  export let warning = false
+  export let error = false
 </script>
 
-<div class="info" class:noTitle={!title} class:quiet>
+<div class="info" class:noTitle={!title} class:warning class:error class:quiet>
   {#if title}
     <div class="title">
       <Icon name={icon} />
@@ -17,7 +19,7 @@
     {@html body}
   {:else}
     <span class="icon">
-      <Icon name={icon} />
+      <Icon size="S" name={icon} />
     </span>
     <!-- eslint-disable-next-line svelte/no-at-html-tags -->
     {@html body}
@@ -25,6 +27,23 @@
 </div>
 
 <style>
+  .info {
+    padding: var(--spacing-m) var(--spacing-l);
+    background-color: var(--spectrum-global-color-gray-200);
+    border-radius: var(--border-radius-s);
+    font-size: 13px;
+  }
+  .warning {
+    background: rgba(255, 200, 0, 0.2);
+  }
+  .error {
+    background: rgba(255, 0, 0, 0.2);
+  }
+  .noTitle {
+    display: flex;
+    align-items: center;
+    gap: var(--spacing-m);
+  }
   .title {
     font-size: 12px;
     font-weight: 600;
diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json
index ff58a66221..84e1bd75b8 100644
--- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json
+++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json
@@ -14,7 +14,7 @@
   {
     "name": "Layout",
     "icon": "ClassicGridView",
-    "children": ["container", "section", "sidepanel", "modal"]
+    "children": ["container", "sidepanel", "modal"]
   },
   {
     "name": "Data",
diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ScreenList/RoleIndicator.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ScreenList/RoleIndicator.svelte
index 4b7f26709c..09e29d806a 100644
--- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ScreenList/RoleIndicator.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ScreenList/RoleIndicator.svelte
@@ -1,5 +1,4 @@
 <script>
-  import { RoleUtils } from "@budibase/frontend-core"
   import { Tooltip, StatusLight } from "@budibase/bbui"
   import { roles } from "stores/builder"
   import { Roles } from "constants/backend"
@@ -8,12 +7,13 @@
 
   let showTooltip = false
 
-  $: color = RoleUtils.getRoleColour(roleId)
   $: role = $roles.find(role => role._id === roleId)
+  $: color =
+    role?.uiMetadata.color || "var(--spectrum-global-color-static-magenta-400)"
   $: tooltip =
     roleId === Roles.PUBLIC
       ? "Open to the public"
-      : `Requires ${role?.name} access`
+      : `Requires ${role?.uiMetadata.displayName || "Unknown role"} access`
 </script>
 
 <!-- svelte-ignore a11y-no-static-element-interactions -->
diff --git a/packages/builder/src/pages/builder/portal/settings/ai/index.svelte b/packages/builder/src/pages/builder/portal/settings/ai/index.svelte
index 42de9f19ae..bbdf46a24e 100644
--- a/packages/builder/src/pages/builder/portal/settings/ai/index.svelte
+++ b/packages/builder/src/pages/builder/portal/settings/ai/index.svelte
@@ -31,10 +31,7 @@
 
   async function fetchAIConfig() {
     try {
-      const aiDoc = await API.getConfig(ConfigTypes.AI)
-      if (aiDoc._id) {
-        fullAIConfig = aiDoc
-      }
+      fullAIConfig = await API.getConfig(ConfigTypes.AI)
     } catch (error) {
       notifications.error("Error fetching AI config")
     }
@@ -66,6 +63,7 @@
       }
       // Add new or update existing custom AI Config
       fullAIConfig.config[id] = editingAIConfig
+      fullAIConfig.type = ConfigTypes.AI
     }
 
     try {
diff --git a/packages/builder/src/pages/builder/portal/users/groups/_components/GroupAppsTableRenderer.svelte b/packages/builder/src/pages/builder/portal/users/groups/_components/GroupAppsTableRenderer.svelte
index 48b1bfbce6..37e44a277e 100644
--- a/packages/builder/src/pages/builder/portal/users/groups/_components/GroupAppsTableRenderer.svelte
+++ b/packages/builder/src/pages/builder/portal/users/groups/_components/GroupAppsTableRenderer.svelte
@@ -4,15 +4,16 @@
 
   export let value
   export let row
-  $: count = getCount(Object.keys(value || {}).length)
 
-  const getCount = () => {
+  $: count = getCount(row, value)
+
+  const getCount = (row, value) => {
     return sdk.users.hasAppBuilderPermissions(row)
       ? row.builder.apps.length +
           Object.keys(row.roles || {}).filter(appId =>
             row.builder.apps.includes(appId)
           ).length
-      : value?.length || 0
+      : Object.keys(value || {}).length
   }
 </script>
 
diff --git a/packages/builder/src/pages/builder/portal/users/users/_components/AppRoleTableRenderer.svelte b/packages/builder/src/pages/builder/portal/users/users/_components/AppRoleTableRenderer.svelte
index 0f19bb3e1f..03a3e626a4 100644
--- a/packages/builder/src/pages/builder/portal/users/users/_components/AppRoleTableRenderer.svelte
+++ b/packages/builder/src/pages/builder/portal/users/users/_components/AppRoleTableRenderer.svelte
@@ -1,23 +1,28 @@
 <script>
   import { StatusLight } from "@budibase/bbui"
-  import { RoleUtils, Constants } from "@budibase/frontend-core"
+  import { Constants } from "@budibase/frontend-core"
   import { roles } from "stores/builder"
   import { capitalise } from "helpers"
 
   export let value
 
+  $: role = $roles.find(x => x._id === value)
+
   const getRoleLabel = roleId => {
-    const role = $roles.find(x => x._id === roleId)
     return roleId === Constants.Roles.CREATOR
       ? capitalise(Constants.Roles.CREATOR.toLowerCase())
-      : role?.name || "Custom role"
+      : role?.uiMetadata.displayName || role?.name || "Custom role"
   }
 </script>
 
 {#if value === Constants.Roles.CREATOR}
   Can edit
 {:else}
-  <StatusLight square color={RoleUtils.getRoleColour(value)}>
+  <StatusLight
+    square
+    color={role?.uiMetadata.color ||
+      "var(--spectrum-global-color-static-magenta-400)"}
+  >
     Can use as {getRoleLabel(value)}
   </StatusLight>
 {/if}
diff --git a/packages/builder/src/pages/builder/portal/users/users/_components/RolesTagsTableRenderer.svelte b/packages/builder/src/pages/builder/portal/users/users/_components/RolesTagsTableRenderer.svelte
deleted file mode 100644
index 402344dedd..0000000000
--- a/packages/builder/src/pages/builder/portal/users/users/_components/RolesTagsTableRenderer.svelte
+++ /dev/null
@@ -1,9 +0,0 @@
-<script>
-  import TagsTableRenderer from "./TagsTableRenderer.svelte"
-
-  export let value
-
-  $: roles = value?.filter(role => role != null).map(role => role.name) ?? []
-</script>
-
-<TagsTableRenderer value={roles} />
diff --git a/packages/builder/src/pages/builder/portal/users/users/_components/TagsTableRenderer.svelte b/packages/builder/src/pages/builder/portal/users/users/_components/TagsTableRenderer.svelte
deleted file mode 100644
index 8b2fd3c522..0000000000
--- a/packages/builder/src/pages/builder/portal/users/users/_components/TagsTableRenderer.svelte
+++ /dev/null
@@ -1,35 +0,0 @@
-<script>
-  import { Tag, Tags } from "@budibase/bbui"
-
-  export let value
-
-  const displayLimit = 5
-
-  $: values = value?.filter(value => value != null) ?? []
-  $: tags = values.slice(0, displayLimit)
-  $: leftover = values.length - tags.length
-</script>
-
-<div class="tag-renderer">
-  <Tags>
-    {#each tags as tag}
-      <Tag>
-        {tag}
-      </Tag>
-    {/each}
-    {#if leftover}
-      <Tag>+{leftover} more</Tag>
-    {/if}
-  </Tags>
-</div>
-
-<style>
-  .tag-renderer :global(.spectrum-Tags-item:hover) {
-    color: var(--spectrum-alias-label-text-color);
-    border-color: var(--spectrum-alias-border-color-darker-default);
-    cursor: pointer;
-  }
-  .tag-renderer :global(.spectrum-Tags-itemLabel) {
-    cursor: pointer;
-  }
-</style>
diff --git a/packages/builder/src/pages/builder/portal/users/users/_components/UpdateRolesModal.svelte b/packages/builder/src/pages/builder/portal/users/users/_components/UpdateRolesModal.svelte
deleted file mode 100644
index 9ad41ad652..0000000000
--- a/packages/builder/src/pages/builder/portal/users/users/_components/UpdateRolesModal.svelte
+++ /dev/null
@@ -1,74 +0,0 @@
-<script>
-  import { createEventDispatcher } from "svelte"
-  import { Body, Select, ModalContent, notifications } from "@budibase/bbui"
-  import { users } from "stores/portal"
-  import { sdk } from "@budibase/shared-core"
-
-  export let app
-  export let user
-
-  const NO_ACCESS = "NO_ACCESS"
-
-  const dispatch = createEventDispatcher()
-
-  const roles = app.roles
-  let options = roles
-    .filter(role => role._id !== "PUBLIC")
-    .map(role => ({ value: role._id, label: role.name }))
-
-  if (!sdk.users.isBuilder(user, app?.appId)) {
-    options.push({ value: NO_ACCESS, label: "No Access" })
-  }
-  let selectedRole = user?.roles?.[app?._id]
-
-  async function updateUserRoles() {
-    try {
-      if (selectedRole === NO_ACCESS) {
-        // Remove the user role
-        const filteredRoles = { ...user.roles }
-        delete filteredRoles[app?._id]
-        await users.save({
-          ...user,
-          roles: {
-            ...filteredRoles,
-          },
-        })
-      } else {
-        // Add the user role
-        await users.save({
-          ...user,
-          roles: {
-            ...user.roles,
-            [app._id]: selectedRole,
-          },
-        })
-      }
-      notifications.success("Role updated")
-      dispatch("update")
-    } catch (error) {
-      notifications.error("Failed to update role")
-    }
-  }
-</script>
-
-<ModalContent
-  onConfirm={updateUserRoles}
-  title="Update App Role"
-  confirmText="Update role"
-  cancelText="Cancel"
-  size="M"
-  showCloseIcon={false}
->
-  <Body>
-    Update {user.email}'s role for <strong>{app.name}</strong>.
-  </Body>
-  <Select
-    placeholder={null}
-    bind:value={selectedRole}
-    on:change
-    {options}
-    label="Role"
-    getOptionLabel={role => role.label}
-    getOptionValue={role => role.value}
-  />
-</ModalContent>
diff --git a/packages/builder/src/stores/builder/roles.js b/packages/builder/src/stores/builder/roles.js
index ac395aa232..fd3581f1d4 100644
--- a/packages/builder/src/stores/builder/roles.js
+++ b/packages/builder/src/stores/builder/roles.js
@@ -1,16 +1,34 @@
-import { writable } from "svelte/store"
+import { derived, writable, get } from "svelte/store"
 import { API } from "api"
 import { RoleUtils } from "@budibase/frontend-core"
 
 export function createRolesStore() {
-  const { subscribe, update, set } = writable([])
+  const store = writable([])
+  const enriched = derived(store, $store => {
+    return $store.map(role => ({
+      ...role,
+
+      // Ensure we have new metadata for all roles
+      uiMetadata: {
+        displayName: role.uiMetadata?.displayName || role.name,
+        color:
+          role.uiMetadata?.color || "var(--spectrum-global-color-magenta-400)",
+        description: role.uiMetadata?.description || "Custom role",
+      },
+    }))
+  })
 
   function setRoles(roles) {
-    set(
+    store.set(
       roles.sort((a, b) => {
         const priorityA = RoleUtils.getRolePriority(a._id)
         const priorityB = RoleUtils.getRolePriority(b._id)
-        return priorityA > priorityB ? -1 : 1
+        if (priorityA !== priorityB) {
+          return priorityA > priorityB ? -1 : 1
+        }
+        const nameA = a.uiMetadata?.displayName || a.name
+        const nameB = b.uiMetadata?.displayName || b.name
+        return nameA < nameB ? -1 : 1
       })
     )
   }
@@ -29,17 +47,43 @@ export function createRolesStore() {
         roleId: role?._id,
         roleRev: role?._rev,
       })
-      update(state => state.filter(existing => existing._id !== role._id))
+      await actions.fetch()
     },
     save: async role => {
       const savedRole = await API.saveRole(role)
       await actions.fetch()
       return savedRole
     },
+    replace: (roleId, role) => {
+      // Handles external updates of roles
+      if (!roleId) {
+        return
+      }
+
+      // Handle deletion
+      if (!role) {
+        store.update(state => state.filter(x => x._id !== roleId))
+        return
+      }
+
+      // Add new role
+      const index = get(store).findIndex(x => x._id === role._id)
+      if (index === -1) {
+        store.update(state => [...state, role])
+      }
+
+      // Update existing role
+      else if (role) {
+        store.update(state => {
+          state[index] = role
+          return [...state]
+        })
+      }
+    },
   }
 
   return {
-    subscribe,
+    subscribe: enriched.subscribe,
     ...actions,
   }
 }
diff --git a/packages/builder/src/stores/builder/websocket.js b/packages/builder/src/stores/builder/websocket.js
index 7df5ab9adb..8a0d83abc1 100644
--- a/packages/builder/src/stores/builder/websocket.js
+++ b/packages/builder/src/stores/builder/websocket.js
@@ -9,6 +9,7 @@ import {
   snippets,
   datasources,
   tables,
+  roles,
 } from "stores/builder"
 import { get } from "svelte/store"
 import { auth, appsStore } from "stores/portal"
@@ -56,12 +57,18 @@ export const createBuilderWebsocket = appId => {
     datasources.replaceDatasource(id, datasource)
   })
 
+  // Role events
+  socket.onOther(BuilderSocketEvent.RoleChange, ({ id, role }) => {
+    roles.replace(id, role)
+  })
+
   // Design section events
   socket.onOther(BuilderSocketEvent.ScreenChange, ({ id, screen }) => {
     screenStore.replace(id, screen)
   })
+
+  // App events
   socket.onOther(BuilderSocketEvent.AppMetadataChange, ({ metadata }) => {
-    //Sync app metadata across the stores
     appStore.syncMetadata(metadata)
     themeStore.syncMetadata(metadata)
     navigationStore.syncMetadata(metadata)
@@ -79,7 +86,7 @@ export const createBuilderWebsocket = appId => {
     }
   )
 
-  // Automations
+  // Automation events
   socket.onOther(BuilderSocketEvent.AutomationChange, ({ id, automation }) => {
     automationStore.actions.replace(id, automation)
   })
diff --git a/packages/client/src/components/app/Section.svelte b/packages/client/src/components/app/deprecated/Section.svelte
similarity index 97%
rename from packages/client/src/components/app/Section.svelte
rename to packages/client/src/components/app/deprecated/Section.svelte
index b86c5cc352..245d280dc0 100644
--- a/packages/client/src/components/app/Section.svelte
+++ b/packages/client/src/components/app/deprecated/Section.svelte
@@ -1,6 +1,6 @@
 <script>
   import { getContext } from "svelte"
-  import Placeholder from "./Placeholder.svelte"
+  import Placeholder from "../Placeholder.svelte"
 
   const { styleable, builderStore } = getContext("sdk")
   const component = getContext("component")
diff --git a/packages/client/src/components/app/forms/FieldGroup.svelte b/packages/client/src/components/app/forms/FieldGroup.svelte
index 9b495338e1..814ee3f76e 100644
--- a/packages/client/src/components/app/forms/FieldGroup.svelte
+++ b/packages/client/src/components/app/forms/FieldGroup.svelte
@@ -1,6 +1,6 @@
 <script>
   import { getContext, setContext } from "svelte"
-  import Section from "../Section.svelte"
+  import Section from "../deprecated/Section.svelte"
 
   export let labelPosition = "above"
   export let type = "oneColumn"
diff --git a/packages/client/src/components/app/index.js b/packages/client/src/components/app/index.js
index 63cdb95ac1..92e1327f96 100644
--- a/packages/client/src/components/app/index.js
+++ b/packages/client/src/components/app/index.js
@@ -14,7 +14,6 @@ export { default as Placeholder } from "./Placeholder.svelte"
 
 // User facing components
 export { default as container } from "./container/Container.svelte"
-export { default as section } from "./Section.svelte"
 export { default as dataprovider } from "./DataProvider.svelte"
 export { default as divider } from "./Divider.svelte"
 export { default as screenslot } from "./ScreenSlot.svelte"
@@ -50,3 +49,4 @@ export { default as navigation } from "./deprecated/Navigation.svelte"
 export { default as cardhorizontal } from "./deprecated/CardHorizontal.svelte"
 export { default as stackedlist } from "./deprecated/StackedList.svelte"
 export { default as card } from "./deprecated/Card.svelte"
+export { default as section } from "./deprecated/Section.svelte"
diff --git a/packages/client/src/components/devtools/DevToolsHeader.svelte b/packages/client/src/components/devtools/DevToolsHeader.svelte
index 55b705e717..eca085a88a 100644
--- a/packages/client/src/components/devtools/DevToolsHeader.svelte
+++ b/packages/client/src/components/devtools/DevToolsHeader.svelte
@@ -1,28 +1,29 @@
 <script>
   import { Heading, Select, ActionButton } from "@budibase/bbui"
-  import { devToolsStore, appStore, roleStore } from "../../stores"
+  import { devToolsStore, appStore } from "../../stores"
   import { getContext, onMount } from "svelte"
+  import { API } from "api"
 
   const context = getContext("context")
   const SELF_ROLE = "self"
 
-  let staticRoleList
+  let roles
 
-  $: previewOptions = buildRoleList(staticRoleList)
+  $: previewOptions = buildRoleList(roles)
 
-  function buildRoleList(roleIds) {
+  function buildRoleList(roles) {
     const list = []
     list.push({
       label: "View as yourself",
       value: SELF_ROLE,
     })
-    if (!roleIds) {
+    if (!roles) {
       return list
     }
-    for (let roleId of roleIds) {
+    for (let role of roles) {
       list.push({
-        label: `View as ${roleId.toLowerCase()} user`,
-        value: roleId,
+        label: `View as ${role.uiMetadata?.displayName || role.name}`,
+        value: role._id,
       })
     }
     return list
@@ -31,7 +32,7 @@
   onMount(async () => {
     // make sure correct before starting
     await devToolsStore.actions.changeRole(SELF_ROLE)
-    staticRoleList = await roleStore.actions.fetchAccessibleRoles()
+    roles = await API.getRoles()
   })
 </script>
 
diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte
index 5d5f06872d..cd6b61af80 100644
--- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte
+++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte
@@ -66,7 +66,7 @@
     focus: () => api?.focus?.(),
     blur: () => api?.blur?.(),
     isActive: () => api?.isActive?.() ?? false,
-    onKeyDown: (...params) => api?.onKeyDown(...params),
+    onKeyDown: (...params) => api?.onKeyDown?.(...params),
     isReadonly: () => readonly,
     getType: () => column.schema.type,
     getValue: () => row[column.name],
diff --git a/packages/frontend-core/src/components/grid/cells/RoleCell.svelte b/packages/frontend-core/src/components/grid/cells/RoleCell.svelte
new file mode 100644
index 0000000000..82d1e26aa7
--- /dev/null
+++ b/packages/frontend-core/src/components/grid/cells/RoleCell.svelte
@@ -0,0 +1,45 @@
+<script>
+  import { StatusLight } from "@budibase/bbui"
+
+  export let value
+  export let schema
+
+  $: role = schema.roles?.find(x => x._id === value)
+</script>
+
+<div class="role-cell">
+  <div class="light">
+    <StatusLight
+      square
+      size="L"
+      color={role?.uiMetadata?.color ||
+        "var(--spectrum-global-color-static-magenta-400)"}
+    />
+  </div>
+  <div class="value">
+    {role?.uiMetadata?.displayName || role?.name || "Unknown role"}
+  </div>
+</div>
+
+<style>
+  .role-cell {
+    flex: 1 1 auto;
+    padding: var(--cell-padding);
+    align-self: stretch;
+    display: flex;
+    align-items: flex-start;
+    overflow: hidden;
+    gap: var(--cell-padding);
+  }
+  .light {
+    height: 20px;
+    display: grid;
+    place-items: center;
+  }
+  .value {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    line-height: 20px;
+  }
+</style>
diff --git a/packages/frontend-core/src/components/grid/lib/renderers.js b/packages/frontend-core/src/components/grid/lib/renderers.js
index 75211f3231..a860d01b53 100644
--- a/packages/frontend-core/src/components/grid/lib/renderers.js
+++ b/packages/frontend-core/src/components/grid/lib/renderers.js
@@ -16,6 +16,7 @@ import AttachmentSingleCell from "../cells/AttachmentSingleCell.svelte"
 import BBReferenceCell from "../cells/BBReferenceCell.svelte"
 import SignatureCell from "../cells/SignatureCell.svelte"
 import BBReferenceSingleCell from "../cells/BBReferenceSingleCell.svelte"
+import RoleCell from "../cells/RoleCell.svelte"
 
 const TypeComponentMap = {
   [FieldType.STRING]: TextCell,
@@ -35,6 +36,9 @@ const TypeComponentMap = {
   [FieldType.JSON]: JSONCell,
   [FieldType.BB_REFERENCE]: BBReferenceCell,
   [FieldType.BB_REFERENCE_SINGLE]: BBReferenceSingleCell,
+
+  // Custom types for UI only
+  role: RoleCell,
 }
 export const getCellRenderer = column => {
   if (column.calculationType) {
diff --git a/packages/frontend-core/src/utils/roles.js b/packages/frontend-core/src/utils/roles.js
index 1ae9d3ac14..913d452e7c 100644
--- a/packages/frontend-core/src/utils/roles.js
+++ b/packages/frontend-core/src/utils/roles.js
@@ -7,20 +7,7 @@ const RolePriorities = {
   [Roles.BASIC]: 2,
   [Roles.PUBLIC]: 1,
 }
-const RoleColours = {
-  [Roles.ADMIN]: "var(--spectrum-global-color-static-red-400)",
-  [Roles.CREATOR]: "var(--spectrum-global-color-static-magenta-600)",
-  [Roles.POWER]: "var(--spectrum-global-color-static-orange-400)",
-  [Roles.BASIC]: "var(--spectrum-global-color-static-green-400)",
-  [Roles.PUBLIC]: "var(--spectrum-global-color-static-blue-400)",
-}
 
 export const getRolePriority = role => {
   return RolePriorities[role] ?? 0
 }
-
-export const getRoleColour = roleId => {
-  return (
-    RoleColours[roleId] ?? "var(--spectrum-global-color-static-magenta-400)"
-  )
-}
diff --git a/packages/server/scripts/load/utils.js b/packages/server/scripts/load/utils.js
index 1dabdcec9a..57e03c471c 100644
--- a/packages/server/scripts/load/utils.js
+++ b/packages/server/scripts/load/utils.js
@@ -29,7 +29,7 @@ exports.createApp = async apiKey => {
   const body = {
     name,
     url: `/${name}`,
-    useTemplate: "true",
+    useTemplate: true,
     templateKey: "app/school-admin-panel",
     templateName: "School Admin Panel",
   }
diff --git a/packages/server/src/api/controllers/application.ts b/packages/server/src/api/controllers/application.ts
index 59f67540fe..d8d9d75336 100644
--- a/packages/server/src/api/controllers/application.ts
+++ b/packages/server/src/api/controllers/application.ts
@@ -23,6 +23,7 @@ import {
   cache,
   context,
   db as dbCore,
+  docIds,
   env as envCore,
   ErrorCode,
   events,
@@ -35,7 +36,6 @@ import {
 import { USERS_TABLE_SCHEMA, DEFAULT_BB_DATASOURCE_ID } from "../../constants"
 import { buildDefaultDocs } from "../../db/defaultData/datasource_bb_default"
 import { removeAppFromUserRoles } from "../../utilities/workerRequests"
-import { stringToReadStream } from "../../utilities"
 import { doesUserHaveLock } from "../../utilities/redis"
 import { cleanupAutomations } from "../../automations/utils"
 import { getUniqueRows } from "../../utilities/usageQuota/rows"
@@ -54,6 +54,11 @@ import {
   DuplicateAppResponse,
   UpdateAppRequest,
   UpdateAppResponse,
+  Database,
+  FieldType,
+  BBReferenceFieldSubType,
+  Row,
+  BBRequest,
 } from "@budibase/types"
 import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
 import sdk from "../../sdk"
@@ -123,8 +128,7 @@ function checkAppName(
 }
 
 interface AppTemplate {
-  templateString?: string
-  useTemplate?: string
+  useTemplate?: boolean
   file?: {
     type?: string
     path: string
@@ -148,14 +152,7 @@ async function createInstance(appId: string, template: AppTemplate) {
   await createRoutingView()
   await createAllSearchIndex()
 
-  // replicate the template data to the instance DB
-  // this is currently very hard to test, downloading and importing template files
-  if (template && template.templateString) {
-    const { ok } = await db.load(stringToReadStream(template.templateString))
-    if (!ok) {
-      throw "Error loading database dump from memory."
-    }
-  } else if (template && template.useTemplate === "true") {
+  if (template && template.useTemplate) {
     await sdk.backups.importApp(appId, db, template)
   } else {
     // create the users table
@@ -243,14 +240,15 @@ export async function fetchAppPackage(
 
 async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
   const apps = (await dbCore.getAllApps({ dev: true })) as App[]
-  const {
-    name,
-    url,
-    encryptionPassword,
-    useTemplate,
-    templateKey,
-    templateString,
-  } = ctx.request.body
+  const { body } = ctx.request
+  const { name, url, encryptionPassword, templateKey } = body
+
+  let useTemplate
+  if (typeof body.useTemplate === "string") {
+    useTemplate = body.useTemplate === "true"
+  } else if (typeof body.useTemplate === "boolean") {
+    useTemplate = body.useTemplate
+  }
 
   checkAppName(ctx, apps, name)
   const appUrl = sdk.applications.getAppUrl({ name, url })
@@ -259,16 +257,15 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
   const instanceConfig: AppTemplate = {
     useTemplate,
     key: templateKey,
-    templateString,
   }
-  if (ctx.request.files && ctx.request.files.templateFile) {
+  if (ctx.request.files && ctx.request.files.fileToImport) {
     instanceConfig.file = {
-      ...(ctx.request.files.templateFile as any),
+      ...(ctx.request.files.fileToImport as any),
       password: encryptionPassword,
     }
-  } else if (typeof ctx.request.body.file?.path === "string") {
+  } else if (typeof body.file?.path === "string") {
     instanceConfig.file = {
-      path: ctx.request.body.file?.path,
+      path: body.file?.path,
     }
   }
 
@@ -279,6 +276,10 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
     const instance = await createInstance(appId, instanceConfig)
     const db = context.getAppDB()
 
+    if (instanceConfig.useTemplate && !instanceConfig.file) {
+      await updateUserColumns(appId, db, ctx.user._id!)
+    }
+
     const newApplication: App = {
       _id: DocumentType.APP_METADATA,
       _rev: undefined,
@@ -375,21 +376,81 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
   })
 }
 
-async function creationEvents(request: any, app: App) {
+async function updateUserColumns(
+  appId: string,
+  db: Database,
+  toUserId: string
+) {
+  await context.doInAppContext(appId, async () => {
+    const allTables = await sdk.tables.getAllTables()
+    const tablesWithUserColumns = []
+    for (const table of allTables) {
+      const userColumns = Object.values(table.schema).filter(
+        f =>
+          (f.type === FieldType.BB_REFERENCE ||
+            f.type === FieldType.BB_REFERENCE_SINGLE) &&
+          f.subtype === BBReferenceFieldSubType.USER
+      )
+      if (!userColumns.length) {
+        continue
+      }
+
+      tablesWithUserColumns.push({
+        tableId: table._id!,
+        columns: userColumns.map(c => c.name),
+      })
+    }
+
+    const docsToUpdate = []
+
+    for (const { tableId, columns } of tablesWithUserColumns) {
+      const docs = await db.allDocs<Row>(
+        docIds.getRowParams(tableId, null, { include_docs: true })
+      )
+      const rows = docs.rows.map(d => d.doc!)
+
+      for (const row of rows) {
+        let shouldUpdate = false
+        const updatedColumns = columns.reduce<Row>((newColumns, column) => {
+          if (row[column]) {
+            shouldUpdate = true
+            if (Array.isArray(row[column])) {
+              newColumns[column] = row[column]?.map(() => toUserId)
+            } else if (row[column]) {
+              newColumns[column] = toUserId
+            }
+          }
+          return newColumns
+        }, {})
+
+        if (shouldUpdate) {
+          docsToUpdate.push({
+            ...row,
+            ...updatedColumns,
+          })
+        }
+      }
+    }
+
+    await db.bulkDocs(docsToUpdate)
+  })
+}
+
+async function creationEvents(request: BBRequest<CreateAppRequest>, app: App) {
   let creationFns: ((app: App) => Promise<void>)[] = []
 
-  const body = request.body
-  if (body.useTemplate === "true") {
+  const { useTemplate, templateKey, file } = request.body
+  if (useTemplate === "true") {
     // from template
-    if (body.templateKey && body.templateKey !== "undefined") {
-      creationFns.push(a => events.app.templateImported(a, body.templateKey))
+    if (templateKey && templateKey !== "undefined") {
+      creationFns.push(a => events.app.templateImported(a, templateKey))
     }
     // from file
-    else if (request.files?.templateFile) {
+    else if (request.files?.fileToImport) {
       creationFns.push(a => events.app.fileImported(a))
     }
     // from server file path
-    else if (request.body.file) {
+    else if (file) {
       // explicitly pass in the newly created app id
       creationFns.push(a => events.app.duplicated(a, app.appId))
     }
@@ -399,16 +460,14 @@ async function creationEvents(request: any, app: App) {
     }
   }
 
-  if (!request.duplicate) {
-    creationFns.push(a => events.app.created(a))
-  }
+  creationFns.push(a => events.app.created(a))
 
   for (let fn of creationFns) {
     await fn(app)
   }
 }
 
-async function appPostCreate(ctx: UserCtx, app: App) {
+async function appPostCreate(ctx: UserCtx<CreateAppRequest, App>, app: App) {
   const tenantId = tenancy.getTenantId()
   await migrations.backPopulateMigrations({
     type: MigrationType.APP,
@@ -419,7 +478,7 @@ async function appPostCreate(ctx: UserCtx, app: App) {
   await creationEvents(ctx.request, app)
 
   // app import, template creation and duplication
-  if (ctx.request.body.useTemplate === "true") {
+  if (ctx.request.body.useTemplate) {
     const { rows } = await getUniqueRows([app.appId])
     const rowCount = rows ? rows.length : 0
     if (rowCount) {
diff --git a/packages/server/src/api/controllers/automation.ts b/packages/server/src/api/controllers/automation.ts
index 6177868303..b19218647b 100644
--- a/packages/server/src/api/controllers/automation.ts
+++ b/packages/server/src/api/controllers/automation.ts
@@ -159,6 +159,7 @@ export async function trigger(ctx: UserCtx) {
         automation,
         {
           fields: ctx.request.body.fields,
+          user: sdk.users.getUserContextBindings(ctx.user),
           timeout:
             ctx.request.body.timeout * 1000 || env.AUTOMATION_THREAD_TIMEOUT,
         },
@@ -183,6 +184,7 @@ export async function trigger(ctx: UserCtx) {
     await triggers.externalTrigger(automation, {
       ...ctx.request.body,
       appId: ctx.appId,
+      user: sdk.users.getUserContextBindings(ctx.user),
     })
     ctx.body = {
       message: `Automation ${automation._id} has been triggered.`,
@@ -212,6 +214,7 @@ export async function test(ctx: UserCtx) {
     {
       ...testInput,
       appId: ctx.appId,
+      user: sdk.users.getUserContextBindings(ctx.user),
     },
     { getResponses: true }
   )
diff --git a/packages/server/src/api/controllers/role.ts b/packages/server/src/api/controllers/role.ts
index f26b4bae69..1047711983 100644
--- a/packages/server/src/api/controllers/role.ts
+++ b/packages/server/src/api/controllers/role.ts
@@ -18,9 +18,11 @@ import {
   UserCtx,
   UserMetadata,
   DocumentType,
+  PermissionLevel,
 } from "@budibase/types"
 import { RoleColor, sdk as sharedSdk, helpers } from "@budibase/shared-core"
 import sdk from "../../sdk"
+import { builderSocket } from "../../websockets"
 
 const UpdateRolesOptions = {
   CREATED: "created",
@@ -34,11 +36,11 @@ async function removeRoleFromOthers(roleId: string) {
     let changed = false
     if (Array.isArray(role.inherits)) {
       const newInherits = role.inherits.filter(
-        id => !roles.compareRoleIds(id, roleId)
+        id => !roles.roleIDsAreEqual(id, roleId)
       )
       changed = role.inherits.length !== newInherits.length
       role.inherits = newInherits
-    } else if (role.inherits && roles.compareRoleIds(role.inherits, roleId)) {
+    } else if (role.inherits && roles.roleIDsAreEqual(role.inherits, roleId)) {
       role.inherits = roles.BUILTIN_ROLE_IDS.PUBLIC
       changed = true
     }
@@ -124,6 +126,17 @@ export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) {
     ctx.throw(400, "Cannot change custom role name")
   }
 
+  // custom roles should always inherit basic - if they don't inherit anything else
+  if (!inherits && roles.validInherits(allRoles, dbRole?.inherits)) {
+    inherits = dbRole?.inherits
+  } else if (!roles.validInherits(allRoles, inherits)) {
+    inherits = [roles.BUILTIN_ROLE_IDS.BASIC]
+  }
+  // assume write permission level for newly created roles
+  if (isCreate && !permissionId) {
+    permissionId = PermissionLevel.WRITE
+  }
+
   const role = new roles.Role(_id, name, permissionId, {
     displayName: uiMetadata?.displayName || name,
     description: uiMetadata?.description || "Custom role",
@@ -177,6 +190,7 @@ export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) {
       },
     })
   }
+  builderSocket?.emitRoleUpdate(ctx, role)
 }
 
 export async function destroy(ctx: UserCtx<void, DestroyRoleResponse>) {
@@ -216,6 +230,7 @@ export async function destroy(ctx: UserCtx<void, DestroyRoleResponse>) {
 
   ctx.message = `Role ${ctx.params.roleId} deleted successfully`
   ctx.status = 200
+  builderSocket?.emitRoleDeletion(ctx, role)
 }
 
 export async function accessible(ctx: UserCtx<void, AccessibleRolesResponse>) {
@@ -223,35 +238,23 @@ export async function accessible(ctx: UserCtx<void, AccessibleRolesResponse>) {
   if (!roleId) {
     roleId = roles.BUILTIN_ROLE_IDS.PUBLIC
   }
+  // If a custom role is provided in the header, filter out higher level roles
+  const roleHeader = ctx.header[Header.PREVIEW_ROLE]
+  if (Array.isArray(roleHeader)) {
+    ctx.throw(400, `Too many roles specified in ${Header.PREVIEW_ROLE} header`)
+  }
+  const isBuilder = ctx.user && sharedSdk.users.isAdminOrBuilder(ctx.user)
   let roleIds: string[] = []
-  if (ctx.user && sharedSdk.users.isAdminOrBuilder(ctx.user)) {
+  if (!roleHeader && isBuilder) {
     const appId = context.getAppId()
     if (appId) {
       roleIds = await roles.getAllRoleIds(appId)
     }
+  } else if (isBuilder && roleHeader) {
+    roleIds = await roles.getUserRoleIdHierarchy(roleHeader)
   } else {
     roleIds = await roles.getUserRoleIdHierarchy(roleId!)
   }
 
-  // If a custom role is provided in the header, filter out higher level roles
-  const roleHeader = ctx.header?.[Header.PREVIEW_ROLE] as string
-  if (roleHeader && !Object.keys(roles.BUILTIN_ROLE_IDS).includes(roleHeader)) {
-    const role = await roles.getRole(roleHeader)
-    const inherits = role?.inherits
-    const orderedRoles = roleIds.reverse()
-    let filteredRoles = [roleHeader]
-    for (let role of orderedRoles) {
-      filteredRoles = [role, ...filteredRoles]
-      if (
-        (Array.isArray(inherits) && inherits.includes(role)) ||
-        role === inherits
-      ) {
-        break
-      }
-    }
-    filteredRoles.pop()
-    roleIds = [roleHeader, ...filteredRoles]
-  }
-
   ctx.body = roleIds.map(roleId => roles.getExternalRoleID(roleId))
 }
diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts
index 60775ce628..4d40476b7c 100644
--- a/packages/server/src/api/controllers/row/index.ts
+++ b/packages/server/src/api/controllers/row/index.ts
@@ -65,7 +65,14 @@ export async function patch(
     }
     ctx.status = 200
     ctx.eventEmitter &&
-      ctx.eventEmitter.emitRow(`row:update`, appId, row, table, oldRow)
+      ctx.eventEmitter.emitRow({
+        eventName: `row:update`,
+        appId,
+        row,
+        table,
+        oldRow,
+        user: sdk.users.getUserContextBindings(ctx.user),
+      })
     ctx.message = `${table.name} updated successfully.`
     ctx.body = row
     gridSocket?.emitRowUpdate(ctx, row)
@@ -96,7 +103,14 @@ export const save = async (ctx: UserCtx<Row, Row>) => {
         sdk.rows.save(sourceId, ctx.request.body, ctx.user?._id)
       )
   ctx.status = 200
-  ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:save`, appId, row, table)
+  ctx.eventEmitter &&
+    ctx.eventEmitter.emitRow({
+      eventName: `row:save`,
+      appId,
+      row,
+      table,
+      user: sdk.users.getUserContextBindings(ctx.user),
+    })
   ctx.message = `${table.name} saved successfully`
   // prefer squashed for response
   ctx.body = row || squashed
@@ -168,10 +182,15 @@ async function deleteRows(ctx: UserCtx<DeleteRowRequest>) {
   }
 
   for (let row of rows) {
-    ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row)
+    ctx.eventEmitter &&
+      ctx.eventEmitter.emitRow({
+        eventName: `row:delete`,
+        appId,
+        row,
+        user: sdk.users.getUserContextBindings(ctx.user),
+      })
     gridSocket?.emitRowDeletion(ctx, row)
   }
-
   return rows
 }
 
@@ -184,7 +203,13 @@ async function deleteRow(ctx: UserCtx<DeleteRowRequest>) {
     await quotas.removeRow()
   }
 
-  ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, resp.row)
+  ctx.eventEmitter &&
+    ctx.eventEmitter.emitRow({
+      eventName: `row:delete`,
+      appId,
+      row: resp.row,
+      user: sdk.users.getUserContextBindings(ctx.user),
+    })
   gridSocket?.emitRowDeletion(ctx, resp.row)
 
   return resp
diff --git a/packages/server/src/api/controllers/rowAction/run.ts b/packages/server/src/api/controllers/rowAction/run.ts
index c4b6a276bf..05bdb7bace 100644
--- a/packages/server/src/api/controllers/rowAction/run.ts
+++ b/packages/server/src/api/controllers/rowAction/run.ts
@@ -5,6 +5,6 @@ export async function run(ctx: Ctx<RowActionTriggerRequest, void>) {
   const { tableId, actionId } = ctx.params
   const { rowId } = ctx.request.body
 
-  await sdk.rowActions.run(tableId, actionId, rowId)
+  await sdk.rowActions.run(tableId, actionId, rowId, ctx.user)
   ctx.status = 200
 }
diff --git a/packages/server/src/api/controllers/static/index.ts b/packages/server/src/api/controllers/static/index.ts
index b4b4fdea5d..1d04811019 100644
--- a/packages/server/src/api/controllers/static/index.ts
+++ b/packages/server/src/api/controllers/static/index.ts
@@ -2,11 +2,12 @@ import { InvalidFileExtensions } from "@budibase/shared-core"
 import AppComponent from "./templates/BudibaseApp.svelte"
 import { join } from "../../../utilities/centralPath"
 import * as uuid from "uuid"
-import { devClientVersion, ObjectStoreBuckets } from "../../../constants"
+import { ObjectStoreBuckets } from "../../../constants"
 import { processString } from "@budibase/string-templates"
 import {
   loadHandlebarsFile,
   NODE_MODULES_PATH,
+  shouldServeLocally,
   TOP_LEVEL_PATH,
 } from "../../../utilities/fileSystem"
 import env from "../../../environment"
@@ -257,25 +258,29 @@ export const serveBuilderPreview = async function (ctx: Ctx) {
 export const serveClientLibrary = async function (ctx: Ctx) {
   const version = ctx.request.query.version
 
+  if (Array.isArray(version)) {
+    ctx.throw(400)
+  }
+
   const appId = context.getAppId() || (ctx.request.query.appId as string)
   let rootPath = join(NODE_MODULES_PATH, "@budibase", "client", "dist")
   if (!appId) {
     ctx.throw(400, "No app ID provided - cannot fetch client library.")
   }
-  if (env.isProd() || (env.isDev() && version !== devClientVersion)) {
+
+  const serveLocally = shouldServeLocally(version || "")
+  if (!serveLocally) {
     ctx.body = await objectStore.getReadStream(
       ObjectStoreBuckets.APPS,
       objectStore.clientLibraryPath(appId!)
     )
     ctx.set("Content-Type", "application/javascript")
-  } else if (env.isDev() && version === devClientVersion) {
+  } else {
     // incase running from TS directly
     const tsPath = join(require.resolve("@budibase/client"), "..")
     return send(ctx, "budibase-client.js", {
       root: !fs.existsSync(rootPath) ? tsPath : rootPath,
     })
-  } else {
-    ctx.throw(500, "Unable to retrieve client library.")
   }
 }
 
diff --git a/packages/server/src/api/routes/tests/application.spec.ts b/packages/server/src/api/routes/tests/application.spec.ts
index fe8250bde5..47b6776610 100644
--- a/packages/server/src/api/routes/tests/application.spec.ts
+++ b/packages/server/src/api/routes/tests/application.spec.ts
@@ -21,6 +21,7 @@ import tk from "timekeeper"
 import * as uuid from "uuid"
 import { structures } from "@budibase/backend-core/tests"
 import nock from "nock"
+import path from "path"
 
 describe("/applications", () => {
   let config = setup.getConfig()
@@ -137,11 +138,17 @@ describe("/applications", () => {
     })
 
     it("creates app from template", async () => {
+      nock("https://prod-budi-templates.s3-eu-west-1.amazonaws.com")
+        .get(`/templates/app/agency-client-portal.tar.gz`)
+        .replyWithFile(
+          200,
+          path.resolve(__dirname, "data", "agency-client-portal.tar.gz")
+        )
+
       const app = await config.api.application.create({
         name: utils.newid(),
         useTemplate: "true",
-        templateKey: "test",
-        templateString: "{}",
+        templateKey: "app/agency-client-portal",
       })
       expect(app._id).toBeDefined()
       expect(events.app.created).toHaveBeenCalledTimes(1)
@@ -152,7 +159,7 @@ describe("/applications", () => {
       const app = await config.api.application.create({
         name: utils.newid(),
         useTemplate: "true",
-        templateFile: "src/api/routes/tests/data/export.txt",
+        fileToImport: "src/api/routes/tests/data/export.txt",
       })
       expect(app._id).toBeDefined()
       expect(events.app.created).toHaveBeenCalledTimes(1)
@@ -172,7 +179,7 @@ describe("/applications", () => {
       const app = await config.api.application.create({
         name: utils.newid(),
         useTemplate: "true",
-        templateFile: "src/api/routes/tests/data/old-app.txt",
+        fileToImport: "src/api/routes/tests/data/old-app.txt",
       })
       expect(app._id).toBeDefined()
       expect(app.navigation).toBeDefined()
diff --git a/packages/server/src/api/routes/tests/role.spec.ts b/packages/server/src/api/routes/tests/role.spec.ts
index 5668295342..adb83ca793 100644
--- a/packages/server/src/api/routes/tests/role.spec.ts
+++ b/packages/server/src/api/routes/tests/role.spec.ts
@@ -38,6 +38,26 @@ describe("/roles", () => {
         _id: dbCore.prefixRoleID(res._id!),
       })
     })
+
+    it("handle a role with invalid inherits", async () => {
+      const role = basicRole()
+      role.inherits = ["not_real", "some_other_not_real"]
+
+      const res = await config.api.roles.save(role, {
+        status: 200,
+      })
+      expect(res.inherits).toEqual([BUILTIN_ROLE_IDS.BASIC])
+    })
+
+    it("handle a role with no inherits", async () => {
+      const role = basicRole()
+      role.inherits = []
+
+      const res = await config.api.roles.save(role, {
+        status: 200,
+      })
+      expect(res.inherits).toEqual([BUILTIN_ROLE_IDS.BASIC])
+    })
   })
 
   describe("update", () => {
@@ -149,6 +169,17 @@ describe("/roles", () => {
         { status: 400, body: { message: LOOP_ERROR } }
       )
     })
+
+    it("handle updating a role, without its inherits", async () => {
+      const res = await config.api.roles.save({
+        ...basicRole(),
+        inherits: [BUILTIN_ROLE_IDS.ADMIN],
+      })
+      // remove the roles so that it will default back to DB roles, then save again
+      delete res.inherits
+      const updatedRes = await config.api.roles.save(res)
+      expect(updatedRes.inherits).toEqual([BUILTIN_ROLE_IDS.ADMIN])
+    })
   })
 
   describe("fetch", () => {
@@ -298,6 +329,23 @@ describe("/roles", () => {
         }
       )
     })
+
+    it("should fetch preview role correctly even without basic specified", async () => {
+      const role = await config.api.roles.save(basicRole())
+      // have to forcefully delete the inherits from DB - technically can't
+      // happen anymore - but good test case
+      await dbCore.getDB(config.appId!).put({
+        ...role,
+        _id: dbCore.prefixRoleID(role._id!),
+        inherits: [],
+      })
+      await config.withHeaders({ "x-budibase-role": role.name }, async () => {
+        const res = await config.api.roles.accessible({
+          status: 200,
+        })
+        expect(res).toEqual([role.name])
+      })
+    })
   })
 
   describe("accessible - multi-inheritance", () => {
diff --git a/packages/server/src/api/routes/tests/rowAction.spec.ts b/packages/server/src/api/routes/tests/rowAction.spec.ts
index a84e3402ca..c0b5c4210f 100644
--- a/packages/server/src/api/routes/tests/rowAction.spec.ts
+++ b/packages/server/src/api/routes/tests/rowAction.spec.ts
@@ -767,7 +767,6 @@ describe("/rowsActions", () => {
     it("can trigger an automation given valid data", async () => {
       expect(await getAutomationLogs()).toBeEmpty()
       await config.api.rowAction.trigger(viewId, rowAction.id, { rowId })
-
       const automationLogs = await getAutomationLogs()
       expect(automationLogs).toEqual([
         expect.objectContaining({
@@ -785,6 +784,10 @@ describe("/rowsActions", () => {
                 ...(await config.api.table.get(tableId)),
                 views: expect.anything(),
               },
+              user: expect.objectContaining({
+                _id: "ro_ta_users_" + config.getUser()._id,
+              }),
+
               automation: expect.objectContaining({
                 _id: rowAction.automationId,
               }),
diff --git a/packages/server/src/api/routes/tests/screen.spec.ts b/packages/server/src/api/routes/tests/screen.spec.ts
index 5dfe3d2a44..894710ca27 100644
--- a/packages/server/src/api/routes/tests/screen.spec.ts
+++ b/packages/server/src/api/routes/tests/screen.spec.ts
@@ -86,7 +86,6 @@ describe("/screens", () => {
             status: 200,
           }
         )
-        // basic and role1 screen
         expect(res.screens.length).toEqual(screenIds.length)
         expect(res.screens.map(s => s._id).sort()).toEqual(screenIds.sort())
       })
@@ -107,6 +106,25 @@ describe("/screens", () => {
         screen2._id!,
       ])
     })
+
+    it("should be able to fetch basic and screen 1 with role1 in role header", async () => {
+      await config.withHeaders(
+        {
+          "x-budibase-role": role1._id!,
+        },
+        async () => {
+          const res = await config.api.application.getDefinition(
+            config.prodAppId!,
+            {
+              status: 200,
+            }
+          )
+          const screenIds = [screen._id!, screen1._id!]
+          expect(res.screens.length).toEqual(screenIds.length)
+          expect(res.screens.map(s => s._id).sort()).toEqual(screenIds.sort())
+        }
+      )
+    })
   })
 
   describe("save", () => {
diff --git a/packages/server/src/api/routes/utils/validators.ts b/packages/server/src/api/routes/utils/validators.ts
index d9dbd96b16..02a1a2d060 100644
--- a/packages/server/src/api/routes/utils/validators.ts
+++ b/packages/server/src/api/routes/utils/validators.ts
@@ -355,9 +355,7 @@ export function applicationValidator(opts = { isCreate: true }) {
     _id: OPTIONAL_STRING,
     _rev: OPTIONAL_STRING,
     url: OPTIONAL_STRING,
-    template: Joi.object({
-      templateString: OPTIONAL_STRING,
-    }),
+    template: Joi.object({}),
   }
 
   const appNameValidator = Joi.string()
@@ -390,9 +388,7 @@ export function applicationValidator(opts = { isCreate: true }) {
       _rev: OPTIONAL_STRING,
       name: appNameValidator,
       url: OPTIONAL_STRING,
-      template: Joi.object({
-        templateString: OPTIONAL_STRING,
-      }).unknown(true),
+      template: Joi.object({}).unknown(true),
       snippets: snippetValidator,
     }).unknown(true)
   )
diff --git a/packages/server/src/automations/actions.ts b/packages/server/src/automations/actions.ts
index e921e569c9..a8b69fa7d7 100644
--- a/packages/server/src/automations/actions.ts
+++ b/packages/server/src/automations/actions.ts
@@ -18,6 +18,7 @@ import * as loop from "./steps/loop"
 import * as collect from "./steps/collect"
 import * as branch from "./steps/branch"
 import * as triggerAutomationRun from "./steps/triggerAutomationRun"
+import * as openai from "./steps/openai"
 import env from "../environment"
 import {
   PluginType,
@@ -50,6 +51,7 @@ const ACTION_IMPLS: ActionImplType = {
   QUERY_ROWS: queryRow.run,
   COLLECT: collect.run,
   TRIGGER_AUTOMATION_RUN: triggerAutomationRun.run,
+  OPENAI: openai.run,
   // these used to be lowercase step IDs, maintain for backwards compat
   discord: discord.run,
   slack: slack.run,
@@ -89,21 +91,25 @@ export const BUILTIN_ACTION_DEFINITIONS: Record<
 // ran at all
 if (env.SELF_HOSTED) {
   const bash = require("./steps/bash")
-  const openai = require("./steps/openai")
 
   // @ts-ignore
   ACTION_IMPLS["EXECUTE_BASH"] = bash.run
   // @ts-ignore
   BUILTIN_ACTION_DEFINITIONS["EXECUTE_BASH"] = bash.definition
-  // @ts-ignore
-  ACTION_IMPLS.OPENAI = openai.run
-  BUILTIN_ACTION_DEFINITIONS.OPENAI = openai.definition
 }
 
 export async function getActionDefinitions() {
   if (await features.flags.isEnabled(FeatureFlag.AUTOMATION_BRANCHING)) {
     BUILTIN_ACTION_DEFINITIONS["BRANCH"] = branch.definition
   }
+  if (
+    env.SELF_HOSTED ||
+    (await features.flags.isEnabled(FeatureFlag.BUDIBASE_AI)) ||
+    (await features.flags.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS))
+  ) {
+    BUILTIN_ACTION_DEFINITIONS["OPENAI"] = openai.definition
+  }
+
   const actionDefinitions = BUILTIN_ACTION_DEFINITIONS
   if (env.SELF_HOSTED) {
     const plugins = await sdk.plugins.fetch(PluginType.AUTOMATION)
diff --git a/packages/server/src/automations/tests/scenarios/scenarios.spec.ts b/packages/server/src/automations/tests/scenarios/scenarios.spec.ts
index 850a04a95a..358aba35c8 100644
--- a/packages/server/src/automations/tests/scenarios/scenarios.spec.ts
+++ b/packages/server/src/automations/tests/scenarios/scenarios.spec.ts
@@ -482,4 +482,38 @@ describe("Automation Scenarios", () => {
       }
     )
   })
+
+  it("Check user is passed through from row trigger", async () => {
+    const table = await config.createTable()
+
+    const builder = createAutomationBuilder({
+      name: "Test a user is successfully passed from the trigger",
+    })
+
+    const results = await builder
+      .rowUpdated(
+        { tableId: table._id! },
+        {
+          row: { name: "Test", description: "TEST" },
+          id: "1234",
+        }
+      )
+      .serverLog({ text: "{{ [user].[email] }}" })
+      .run()
+
+    expect(results.steps[0].outputs.message).toContain("example.com")
+  })
+
+  it("Check user is passed through from app trigger", async () => {
+    const builder = createAutomationBuilder({
+      name: "Test a user is successfully passed from the trigger",
+    })
+
+    const results = await builder
+      .appAction({ fields: {} })
+      .serverLog({ text: "{{ [user].[email] }}" })
+      .run()
+
+    expect(results.steps[0].outputs.message).toContain("example.com")
+  })
 })
diff --git a/packages/server/src/automations/triggers.ts b/packages/server/src/automations/triggers.ts
index 110ccfa37a..70fda1f237 100644
--- a/packages/server/src/automations/triggers.ts
+++ b/packages/server/src/automations/triggers.ts
@@ -19,6 +19,7 @@ import {
   AutomationStoppedReason,
   AutomationStatus,
   AutomationRowEvent,
+  UserBindings,
 } from "@budibase/types"
 import { executeInThread } from "../threads/automation"
 import { dataFilters, sdk } from "@budibase/shared-core"
@@ -140,7 +141,12 @@ function rowPassesFilters(row: Row, filters: SearchFilters) {
 
 export async function externalTrigger(
   automation: Automation,
-  params: { fields: Record<string, any>; timeout?: number; appId?: string },
+  params: {
+    fields: Record<string, any>
+    timeout?: number
+    appId?: string
+    user?: UserBindings
+  },
   { getResponses }: { getResponses?: boolean } = {}
 ): Promise<any> {
   if (automation.disabled) {
diff --git a/packages/server/src/constants/index.ts b/packages/server/src/constants/index.ts
index 316d27b3fc..bac838b53e 100644
--- a/packages/server/src/constants/index.ts
+++ b/packages/server/src/constants/index.ts
@@ -152,8 +152,6 @@ export enum AutomationErrors {
   FAILURE_CONDITION = "FAILURE_CONDITION_MET",
 }
 
-export const devClientVersion = "0.0.0"
-
 // pass through the list from the auth/core lib
 export const ObjectStoreBuckets = objectStore.ObjectStoreBuckets
 export const MAX_AUTOMATION_RECURRING_ERRORS = 5
diff --git a/packages/server/src/definitions/automations.ts b/packages/server/src/definitions/automations.ts
index 9433075da7..d551584fd1 100644
--- a/packages/server/src/definitions/automations.ts
+++ b/packages/server/src/definitions/automations.ts
@@ -1,4 +1,4 @@
-import { AutomationResults, LoopStepType } from "@budibase/types"
+import { AutomationResults, LoopStepType, UserBindings } from "@budibase/types"
 
 export interface LoopInput {
   option: LoopStepType
@@ -18,5 +18,6 @@ export interface AutomationContext extends AutomationResults {
   stepsById: Record<string, any>
   stepsByName: Record<string, any>
   env?: Record<string, string>
+  user?: UserBindings
   trigger: any
 }
diff --git a/packages/server/src/events/AutomationEmitter.ts b/packages/server/src/events/AutomationEmitter.ts
index 32fd130929..a63273bdc0 100644
--- a/packages/server/src/events/AutomationEmitter.ts
+++ b/packages/server/src/events/AutomationEmitter.ts
@@ -31,7 +31,17 @@ class AutomationEmitter {
     }
   }
 
-  async emitRow(eventName: string, appId: string, row: Row, table?: Table) {
+  async emitRow({
+    eventName,
+    appId,
+    row,
+    table,
+  }: {
+    eventName: string
+    appId: string
+    row: Row
+    table?: Table
+  }) {
     let MAX_AUTOMATION_CHAIN = await this.getMaxAutomationChain()
 
     // don't emit even if we've reached max automation chain
diff --git a/packages/server/src/events/BudibaseEmitter.ts b/packages/server/src/events/BudibaseEmitter.ts
index 8feb36bbf5..c8983096d0 100644
--- a/packages/server/src/events/BudibaseEmitter.ts
+++ b/packages/server/src/events/BudibaseEmitter.ts
@@ -1,6 +1,6 @@
 import { EventEmitter } from "events"
 import { rowEmission, tableEmission } from "./utils"
-import { Table, Row } from "@budibase/types"
+import { Table, Row, User } from "@budibase/types"
 
 /**
  * keeping event emitter in one central location as it might be used for things other than
@@ -13,14 +13,22 @@ import { Table, Row } from "@budibase/types"
  * This is specifically quite important for template strings used in automations.
  */
 class BudibaseEmitter extends EventEmitter {
-  emitRow(
-    eventName: string,
-    appId: string,
-    row: Row,
-    table?: Table,
+  emitRow({
+    eventName,
+    appId,
+    row,
+    table,
+    oldRow,
+    user,
+  }: {
+    eventName: string
+    appId: string
+    row: Row
+    table?: Table
     oldRow?: Row
-  ) {
-    rowEmission({ emitter: this, eventName, appId, row, table, oldRow })
+    user: User
+  }) {
+    rowEmission({ emitter: this, eventName, appId, row, table, oldRow, user })
   }
 
   emitTable(eventName: string, appId: string, table?: Table) {
diff --git a/packages/server/src/events/utils.ts b/packages/server/src/events/utils.ts
index b972c8e473..5e4a1bebbf 100644
--- a/packages/server/src/events/utils.ts
+++ b/packages/server/src/events/utils.ts
@@ -1,4 +1,4 @@
-import { Table, Row } from "@budibase/types"
+import { Table, Row, User } from "@budibase/types"
 import BudibaseEmitter from "./BudibaseEmitter"
 
 type BBEventOpts = {
@@ -9,6 +9,7 @@ type BBEventOpts = {
   row?: Row
   oldRow?: Row
   metadata?: any
+  user?: User
 }
 
 interface BBEventTable extends Table {
@@ -24,6 +25,7 @@ type BBEvent = {
   id?: string
   revision?: string
   metadata?: any
+  user?: User
 }
 
 export function rowEmission({
@@ -34,12 +36,14 @@ export function rowEmission({
   table,
   metadata,
   oldRow,
+  user,
 }: BBEventOpts) {
   let event: BBEvent = {
     row,
     oldRow,
     appId,
     tableId: row?.tableId,
+    user,
   }
   if (table) {
     event.table = table
diff --git a/packages/server/src/middleware/currentapp.ts b/packages/server/src/middleware/currentapp.ts
index d616377298..a8ef8bb251 100644
--- a/packages/server/src/middleware/currentapp.ts
+++ b/packages/server/src/middleware/currentapp.ts
@@ -56,22 +56,9 @@ export default async (ctx: UserCtx, next: any) => {
       ctx.request &&
       (ctx.request.headers[constants.Header.PREVIEW_ROLE] as string)
     if (isBuilder && isDevApp && roleHeader) {
-      // Ensure the role is valid by ensuring a definition exists
-      try {
-        if (roleHeader) {
-          const role = await roles.getRole(roleHeader)
-          if (role) {
-            roleId = roleHeader
-
-            // Delete admin and builder flags so that the specified role is honoured
-            ctx.user = users.removePortalUserPermissions(
-              ctx.user
-            ) as ContextUser
-          }
-        }
-      } catch (error) {
-        // Swallow error and do nothing
-      }
+      roleId = roleHeader
+      // Delete admin and builder flags so that the specified role is honoured
+      ctx.user = users.removePortalUserPermissions(ctx.user) as ContextUser
     }
   }
 
diff --git a/packages/server/src/sdk/app/rowActions.ts b/packages/server/src/sdk/app/rowActions.ts
index 4b128ae003..08a173ee13 100644
--- a/packages/server/src/sdk/app/rowActions.ts
+++ b/packages/server/src/sdk/app/rowActions.ts
@@ -4,6 +4,7 @@ import {
   AutomationTriggerStepId,
   SEPARATOR,
   TableRowActions,
+  User,
   VirtualDocumentType,
 } from "@budibase/types"
 import { generateRowActionsID } from "../../db/utils"
@@ -236,7 +237,12 @@ export async function remove(tableId: string, rowActionId: string) {
   })
 }
 
-export async function run(tableId: any, rowActionId: any, rowId: string) {
+export async function run(
+  tableId: any,
+  rowActionId: any,
+  rowId: string,
+  user: User
+) {
   const table = await sdk.tables.getTable(tableId)
   if (!table) {
     throw new HTTPError("Table not found", 404)
@@ -260,6 +266,7 @@ export async function run(tableId: any, rowActionId: any, rowId: string) {
         row,
         table,
       },
+      user,
       appId: context.getAppId(),
     },
     { getResponses: true }
diff --git a/packages/server/src/sdk/users/utils.ts b/packages/server/src/sdk/users/utils.ts
index 74389a1444..8266d3e28a 100644
--- a/packages/server/src/sdk/users/utils.ts
+++ b/packages/server/src/sdk/users/utils.ts
@@ -12,6 +12,7 @@ import {
   UserMetadata,
   Database,
   ContextUserMetadata,
+  UserBindings,
 } from "@budibase/types"
 
 export function combineMetadataAndUser(
@@ -125,7 +126,7 @@ export async function syncGlobalUsers() {
   }
 }
 
-export function getUserContextBindings(user: ContextUser) {
+export function getUserContextBindings(user: ContextUser): UserBindings {
   if (!user) {
     return {}
   }
diff --git a/packages/server/src/tests/utilities/api/application.ts b/packages/server/src/tests/utilities/api/application.ts
index 516af5c973..9dabc8cfe8 100644
--- a/packages/server/src/tests/utilities/api/application.ts
+++ b/packages/server/src/tests/utilities/api/application.ts
@@ -17,8 +17,8 @@ export class ApplicationAPI extends TestAPI {
     app: CreateAppRequest,
     expectations?: Expectations
   ): Promise<App> => {
-    const files = app.templateFile ? { templateFile: app.templateFile } : {}
-    delete app.templateFile
+    const files = app.fileToImport ? { fileToImport: app.fileToImport } : {}
+    delete app.fileToImport
     return await this._post<App>("/api/applications", {
       fields: app,
       files,
diff --git a/packages/server/src/tests/utilities/structures.ts b/packages/server/src/tests/utilities/structures.ts
index b38cc6484f..b63d6d1a78 100644
--- a/packages/server/src/tests/utilities/structures.ts
+++ b/packages/server/src/tests/utilities/structures.ts
@@ -8,31 +8,31 @@ import {
 } from "../../automations"
 import {
   AIOperationEnum,
+  AutoFieldSubType,
   Automation,
   AutomationActionStepId,
+  AutomationEventType,
   AutomationResults,
   AutomationStatus,
   AutomationStep,
   AutomationStepType,
   AutomationTrigger,
   AutomationTriggerStepId,
+  BBReferenceFieldSubType,
+  CreateViewRequest,
   Datasource,
+  FieldSchema,
   FieldType,
+  INTERNAL_TABLE_SOURCE_ID,
+  JsonFieldSubType,
+  LoopStepType,
+  Query,
+  Role,
   SourceName,
   Table,
-  INTERNAL_TABLE_SOURCE_ID,
   TableSourceType,
-  Query,
   Webhook,
   WebhookActionType,
-  AutomationEventType,
-  LoopStepType,
-  FieldSchema,
-  BBReferenceFieldSubType,
-  JsonFieldSubType,
-  AutoFieldSubType,
-  Role,
-  CreateViewRequest,
 } from "@budibase/types"
 import { LoopInput } from "../../definitions/automations"
 import { merge } from "lodash"
@@ -439,7 +439,7 @@ export function updateRowAutomationWithFilters(
   appId: string,
   tableId: string
 ): Automation {
-  const automation: Automation = {
+  return {
     name: "updateRowWithFilters",
     type: "automation",
     appId,
@@ -472,7 +472,6 @@ export function updateRowAutomationWithFilters(
       },
     },
   }
-  return automation
 }
 
 export function basicAutomationResults(
diff --git a/packages/server/src/threads/automation.ts b/packages/server/src/threads/automation.ts
index 3b47634663..0f17b44424 100644
--- a/packages/server/src/threads/automation.ts
+++ b/packages/server/src/threads/automation.ts
@@ -26,6 +26,7 @@ import {
   BranchStep,
   LoopStep,
   SearchFilters,
+  UserBindings,
 } from "@budibase/types"
 import { AutomationContext, TriggerOutput } from "../definitions/automations"
 import { WorkerCallback } from "./definitions"
@@ -75,6 +76,7 @@ class Orchestrator {
   private loopStepOutputs: LoopStep[]
   private stopped: boolean
   private executionOutput: Omit<AutomationContext, "stepsByName" | "stepsById">
+  private currentUser: UserBindings | undefined
 
   constructor(job: AutomationJob) {
     let automation = job.data.automation
@@ -106,6 +108,7 @@ class Orchestrator {
     this.updateExecutionOutput(triggerId, triggerStepId, null, triggerOutput)
     this.loopStepOutputs = []
     this.stopped = false
+    this.currentUser = triggerOutput.user
   }
 
   cleanupTriggerOutputs(stepId: string, triggerOutput: TriggerOutput) {
@@ -258,6 +261,7 @@ class Orchestrator {
           automationId: this.automation._id,
         })
         this.context.env = await sdkUtils.getEnvironmentVariables()
+        this.context.user = this.currentUser
 
         let metadata
 
@@ -572,7 +576,6 @@ class Orchestrator {
           originalStepInput,
           this.processContext(this.context)
         )
-
         inputs = automationUtils.cleanInputValues(inputs, step.schema.inputs)
 
         const outputs = await stepFn({
diff --git a/packages/server/src/utilities/fileSystem/app.ts b/packages/server/src/utilities/fileSystem/app.ts
index c708a9422b..9bd88ba0b1 100644
--- a/packages/server/src/utilities/fileSystem/app.ts
+++ b/packages/server/src/utilities/fileSystem/app.ts
@@ -1,8 +1,8 @@
 import { budibaseTempDir } from "../budibaseDir"
 import fs from "fs"
 import { join } from "path"
-import { ObjectStoreBuckets, devClientVersion } from "../../constants"
-import { updateClientLibrary } from "./clientLibrary"
+import { ObjectStoreBuckets } from "../../constants"
+import { shouldServeLocally, updateClientLibrary } from "./clientLibrary"
 import env from "../../environment"
 import { objectStore, context } from "@budibase/backend-core"
 import { TOP_LEVEL_PATH } from "./filesystem"
@@ -40,7 +40,7 @@ export const getComponentLibraryManifest = async (library: string) => {
     const db = context.getAppDB()
     const app = await db.get<App>(DocumentType.APP_METADATA)
 
-    if (app.version === devClientVersion || env.isTest()) {
+    if (shouldServeLocally(app.version) || env.isTest()) {
       const paths = [
         join(TOP_LEVEL_PATH, "packages/client", filename),
         join(process.cwd(), "client", filename),
diff --git a/packages/server/src/utilities/fileSystem/clientLibrary.ts b/packages/server/src/utilities/fileSystem/clientLibrary.ts
index c994502995..faa328fdc5 100644
--- a/packages/server/src/utilities/fileSystem/clientLibrary.ts
+++ b/packages/server/src/utilities/fileSystem/clientLibrary.ts
@@ -1,3 +1,4 @@
+import semver from "semver"
 import path, { join } from "path"
 import { ObjectStoreBuckets } from "../../constants"
 import fs from "fs"
@@ -183,3 +184,19 @@ export async function revertClientLibrary(appId: string) {
 
   return JSON.parse(await manifestSrc)
 }
+
+export function shouldServeLocally(version: string) {
+  if (env.isProd() || !env.isDev()) {
+    return false
+  }
+
+  if (version === "0.0.0") {
+    return true
+  }
+
+  const parsedSemver = semver.parse(version)
+  if (parsedSemver?.build?.[0] === "local") {
+    return true
+  }
+  return false
+}
diff --git a/packages/server/src/utilities/index.ts b/packages/server/src/utilities/index.ts
index ce6f2345ca..129137a72e 100644
--- a/packages/server/src/utilities/index.ts
+++ b/packages/server/src/utilities/index.ts
@@ -2,9 +2,6 @@ import env from "../environment"
 import { context } from "@budibase/backend-core"
 import { generateMetadataID } from "../db/utils"
 import { Document } from "@budibase/types"
-import stream from "stream"
-
-const Readable = stream.Readable
 
 export function wait(ms: number) {
   return new Promise(resolve => setTimeout(resolve, ms))
@@ -98,15 +95,6 @@ export function escapeDangerousCharacters(string: string) {
     .replace(/[\t]/g, "\\t")
 }
 
-export function stringToReadStream(string: string) {
-  return new Readable({
-    read() {
-      this.push(string)
-      this.push(null)
-    },
-  })
-}
-
 export function formatBytes(bytes: string) {
   const units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
   const byteIncrements = 1024
diff --git a/packages/server/src/websockets/builder.ts b/packages/server/src/websockets/builder.ts
index 702a941095..cf92d68ef3 100644
--- a/packages/server/src/websockets/builder.ts
+++ b/packages/server/src/websockets/builder.ts
@@ -11,6 +11,7 @@ import {
   Screen,
   App,
   Automation,
+  Role,
 } from "@budibase/types"
 import { gridSocket } from "./index"
 import { clearLock, updateLock } from "../utilities/redis"
@@ -100,6 +101,20 @@ export default class BuilderSocket extends BaseSocket {
     })
   }
 
+  emitRoleUpdate(ctx: any, role: Role) {
+    this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.RoleChange, {
+      id: role._id,
+      role,
+    })
+  }
+
+  emitRoleDeletion(ctx: any, role: Role) {
+    this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.RoleChange, {
+      id: role._id,
+      role: null,
+    })
+  }
+
   emitTableUpdate(ctx: any, table: Table, options?: EmitOptions) {
     if (table.sourceId == null || table.sourceId === "") {
       throw new Error("Table sourceId is not set")
diff --git a/packages/shared-core/src/constants/index.ts b/packages/shared-core/src/constants/index.ts
index 6c49625937..11bb79c8d3 100644
--- a/packages/shared-core/src/constants/index.ts
+++ b/packages/shared-core/src/constants/index.ts
@@ -97,6 +97,7 @@ export enum BuilderSocketEvent {
   SelectResource = "SelectResource",
   AppPublishChange = "AppPublishChange",
   AutomationChange = "AutomationChange",
+  RoleChange = "RoleChange",
 }
 
 export const SocketSessionTTL = 60
diff --git a/packages/types/src/api/web/application.ts b/packages/types/src/api/web/application.ts
index bb4d8c7f72..57422ceabc 100644
--- a/packages/types/src/api/web/application.ts
+++ b/packages/types/src/api/web/application.ts
@@ -7,10 +7,8 @@ export interface CreateAppRequest {
   useTemplate?: string
   templateName?: string
   templateKey?: string
-  templateFile?: string
-  includeSampleData?: boolean
+  fileToImport?: string
   encryptionPassword?: string
-  templateString?: string
   file?: { path: string }
 }
 
diff --git a/packages/types/src/documents/app/automation/automation.ts b/packages/types/src/documents/app/automation/automation.ts
index effe99a328..1af892d8d1 100644
--- a/packages/types/src/documents/app/automation/automation.ts
+++ b/packages/types/src/documents/app/automation/automation.ts
@@ -261,6 +261,7 @@ export type UpdatedRowEventEmitter = {
   oldRow: Row
   table: Table
   appId: string
+  user: User
 }
 
 export enum LoopStepType {
diff --git a/packages/types/src/documents/app/automation/schema.ts b/packages/types/src/documents/app/automation/schema.ts
index 599256694d..b8a19b7b45 100644
--- a/packages/types/src/documents/app/automation/schema.ts
+++ b/packages/types/src/documents/app/automation/schema.ts
@@ -126,16 +126,16 @@ export type ActionImplementations<T extends Hosting> = {
     n8nStepInputs,
     ExternalAppStepOutputs
   >
+  [AutomationActionStepId.OPENAI]: ActionImplementation<
+    OpenAIStepInputs,
+    OpenAIStepOutputs
+  >
 } & (T extends "self"
   ? {
       [AutomationActionStepId.EXECUTE_BASH]: ActionImplementation<
         BashStepInputs,
         BashStepOutputs
       >
-      [AutomationActionStepId.OPENAI]: ActionImplementation<
-        OpenAIStepInputs,
-        OpenAIStepOutputs
-      >
     }
   : {})
 
diff --git a/packages/types/src/documents/global/user.ts b/packages/types/src/documents/global/user.ts
index a1c5b2506f..829a171843 100644
--- a/packages/types/src/documents/global/user.ts
+++ b/packages/types/src/documents/global/user.ts
@@ -68,6 +68,16 @@ export interface User extends Document {
   appSort?: string
 }
 
+export interface UserBindings extends Document {
+  firstName?: string
+  lastName?: string
+  email?: string
+  status?: string
+  roleId?: string | null
+  globalId?: string
+  userId?: string
+}
+
 export enum UserStatus {
   ACTIVE = "active",
   INACTIVE = "inactive",
diff --git a/packages/types/src/sdk/automations/index.ts b/packages/types/src/sdk/automations/index.ts
index d04f126c32..9ceded03ee 100644
--- a/packages/types/src/sdk/automations/index.ts
+++ b/packages/types/src/sdk/automations/index.ts
@@ -1,4 +1,9 @@
-import { Automation, AutomationMetadata, Row } from "../../documents"
+import {
+  Automation,
+  AutomationMetadata,
+  Row,
+  UserBindings,
+} from "../../documents"
 import { Job } from "bull"
 
 export interface AutomationDataEvent {
@@ -8,6 +13,7 @@ export interface AutomationDataEvent {
   timeout?: number
   row?: Row
   oldRow?: Row
+  user?: UserBindings
 }
 
 export interface AutomationData {
diff --git a/packages/worker/src/api/controllers/global/configs.ts b/packages/worker/src/api/controllers/global/configs.ts
index e6e80ff3a5..750b02088c 100644
--- a/packages/worker/src/api/controllers/global/configs.ts
+++ b/packages/worker/src/api/controllers/global/configs.ts
@@ -44,9 +44,7 @@ const getEventFns = async (config: Config, existing?: Config) => {
       fns.push(events.email.SMTPCreated)
     } else if (isAIConfig(config)) {
       fns.push(() => events.ai.AIConfigCreated)
-      fns.push(() =>
-        pro.quotas.updateCustomAIConfigCount(Object.keys(config.config).length)
-      )
+      fns.push(() => pro.quotas.addCustomAIConfig())
     } else if (isGoogleConfig(config)) {
       fns.push(() => events.auth.SSOCreated(ConfigType.GOOGLE))
       if (config.config.activated) {
@@ -85,9 +83,6 @@ const getEventFns = async (config: Config, existing?: Config) => {
       fns.push(events.email.SMTPUpdated)
     } else if (isAIConfig(config)) {
       fns.push(() => events.ai.AIConfigUpdated)
-      fns.push(() =>
-        pro.quotas.updateCustomAIConfigCount(Object.keys(config.config).length)
-      )
     } else if (isGoogleConfig(config)) {
       fns.push(() => events.auth.SSOUpdated(ConfigType.GOOGLE))
       if (!existing.config.activated && config.config.activated) {
@@ -253,7 +248,7 @@ export async function save(ctx: UserCtx<Config>) {
         if (existingConfig) {
           await verifyAIConfig(config, existingConfig)
         }
-        await pro.quotas.updateCustomAIConfigCount(Object.keys(config).length)
+        await pro.quotas.addCustomAIConfig()
         break
     }
   } catch (err: any) {
@@ -342,29 +337,43 @@ export async function find(ctx: UserCtx) {
     let scopedConfig = await configs.getConfig(type)
 
     if (scopedConfig) {
-      if (type === ConfigType.OIDC_LOGOS) {
-        enrichOIDCLogos(scopedConfig)
-      }
-
-      if (type === ConfigType.AI) {
-        await pro.sdk.ai.enrichAIConfig(scopedConfig)
-        // Strip out the API Keys from the response so they don't show in the UI
-        for (const key in scopedConfig.config) {
-          if (scopedConfig.config[key].apiKey) {
-            scopedConfig.config[key].apiKey = PASSWORD_REPLACEMENT
-          }
-        }
-      }
-      ctx.body = scopedConfig
+      await handleConfigType(type, scopedConfig)
+    } else if (type === ConfigType.AI) {
+      scopedConfig = { config: {} } as AIConfig
+      await handleAIConfig(scopedConfig)
     } else {
-      // don't throw an error, there simply is nothing to return
+      // If no config found and not AI type, just return an empty body
       ctx.body = {}
+      return
     }
+
+    ctx.body = scopedConfig
   } catch (err: any) {
     ctx.throw(err?.status || 400, err)
   }
 }
 
+async function handleConfigType(type: ConfigType, config: Config) {
+  if (type === ConfigType.OIDC_LOGOS) {
+    enrichOIDCLogos(config)
+  } else if (type === ConfigType.AI) {
+    await handleAIConfig(config)
+  }
+}
+
+async function handleAIConfig(config: AIConfig) {
+  await pro.sdk.ai.enrichAIConfig(config)
+  stripApiKeys(config)
+}
+
+function stripApiKeys(config: AIConfig) {
+  for (const key in config?.config) {
+    if (config.config[key].apiKey) {
+      config.config[key].apiKey = PASSWORD_REPLACEMENT
+    }
+  }
+}
+
 export async function publicOidc(ctx: Ctx<void, GetPublicOIDCConfigResponse>) {
   try {
     // Find the config with the most granular scope based on context
@@ -508,6 +517,9 @@ export async function destroy(ctx: UserCtx) {
   try {
     await db.remove(id, rev)
     await cache.destroy(cache.CacheKey.CHECKLIST)
+    if (id === configs.generateConfigID(ConfigType.AI)) {
+      await pro.quotas.removeCustomAIConfig()
+    }
     ctx.body = { message: "Config deleted successfully" }
   } catch (err: any) {
     ctx.throw(err.status, err)
diff --git a/packages/worker/src/api/controllers/global/tests/configs.spec.ts b/packages/worker/src/api/controllers/global/tests/configs.spec.ts
index 9091f29247..857f499dcc 100644
--- a/packages/worker/src/api/controllers/global/tests/configs.spec.ts
+++ b/packages/worker/src/api/controllers/global/tests/configs.spec.ts
@@ -13,10 +13,6 @@ describe("Global configs controller", () => {
     await config.afterAll()
   })
 
-  afterEach(() => {
-    jest.resetAllMocks()
-  })
-
   it("Should strip secrets when pulling AI config", async () => {
     const data = structures.configs.ai()
     await config.api.configs.saveConfig(data)
diff --git a/yarn.lock b/yarn.lock
index 1198e98ad6..5dfef16e0e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2343,6 +2343,18 @@
     enabled "2.0.x"
     kuler "^2.0.0"
 
+"@dagrejs/dagre@1.1.4":
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/@dagrejs/dagre/-/dagre-1.1.4.tgz#66f9c0e2b558308f2c268f60e2c28f22ee17e339"
+  integrity sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==
+  dependencies:
+    "@dagrejs/graphlib" "2.2.4"
+
+"@dagrejs/graphlib@2.2.4":
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/@dagrejs/graphlib/-/graphlib-2.2.4.tgz#d77bfa9ff49e2307c0c6e6b8b26b5dd3c05816c4"
+  integrity sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==
+
 "@datadog/native-appsec@7.0.0":
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/@datadog/native-appsec/-/native-appsec-7.0.0.tgz#a380174dd49aef2d9bb613a0ec8ead6dc7822095"
@@ -5093,6 +5105,11 @@
   resolved "https://registry.yarnpkg.com/@spectrum-css/vars/-/vars-4.3.1.tgz#d333fa41909f691c8750b5c15ad9ba029df2248e"
   integrity sha512-rX6Iasu9BsFMVgEN0vGRPm9dmSxva+IK/uqQAa9HM0lliwqUiFrJxrFXHHpiAgNuux/U4srEJwbSpGzfF+CegQ==
 
+"@svelte-put/shortcut@^3.1.0":
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/@svelte-put/shortcut/-/shortcut-3.1.1.tgz#aba4d7407024d5cff38727e12925c8f81e877079"
+  integrity sha512-2L5EYTZXiaKvbEelVkg5znxqvfZGZai3m97+cAiUBhLZwXnGtviTDpHxOoZBsqz41szlfRMcamW/8o0+fbW3ZQ==
+
 "@sveltejs/vite-plugin-svelte@1.4.0":
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-1.4.0.tgz#412a735de489ca731d0c780c2b410f45dd95b392"
@@ -5451,6 +5468,45 @@
   dependencies:
     "@types/node" "*"
 
+"@types/d3-color@*":
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2"
+  integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==
+
+"@types/d3-drag@^3.0.7":
+  version "3.0.7"
+  resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02"
+  integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==
+  dependencies:
+    "@types/d3-selection" "*"
+
+"@types/d3-interpolate@*":
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c"
+  integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==
+  dependencies:
+    "@types/d3-color" "*"
+
+"@types/d3-selection@*", "@types/d3-selection@^3.0.10":
+  version "3.0.10"
+  resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.10.tgz#98cdcf986d0986de6912b5892e7c015a95ca27fe"
+  integrity sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==
+
+"@types/d3-transition@^3.0.8":
+  version "3.0.8"
+  resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.8.tgz#677707f5eed5b24c66a1918cde05963021351a8f"
+  integrity sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ==
+  dependencies:
+    "@types/d3-selection" "*"
+
+"@types/d3-zoom@^3.0.8":
+  version "3.0.8"
+  resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b"
+  integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==
+  dependencies:
+    "@types/d3-interpolate" "*"
+    "@types/d3-selection" "*"
+
 "@types/debug@*":
   version "4.1.7"
   resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82"
@@ -6578,6 +6634,28 @@
     loupe "^3.1.1"
     tinyrainbow "^1.2.0"
 
+"@xyflow/svelte@^0.1.18":
+  version "0.1.18"
+  resolved "https://registry.yarnpkg.com/@xyflow/svelte/-/svelte-0.1.18.tgz#ba2f9f72adc64ff6f71a5ad03cf759af8d7c9748"
+  integrity sha512-P2td3XcvMk36pnhyRUAXtmwfd7sv1KAHVF29YZUNndYlgxG98vwj1UoyyuXwCHIiyu82GgowaTppHCNPXsvNSg==
+  dependencies:
+    "@svelte-put/shortcut" "^3.1.0"
+    "@xyflow/system" "0.0.41"
+    classcat "^5.0.4"
+
+"@xyflow/system@0.0.41":
+  version "0.0.41"
+  resolved "https://registry.yarnpkg.com/@xyflow/system/-/system-0.0.41.tgz#6c314b2bbca594aec4d7cdb56efb003be6727d21"
+  integrity sha512-XAjs8AUA0YMfYD91cT6pLGALwbsPS64s2WBHyULqL1m0gTqXqaUSLK1P7qA/Q8HecN0RFbqlM2tPO8bmZXP0YQ==
+  dependencies:
+    "@types/d3-drag" "^3.0.7"
+    "@types/d3-selection" "^3.0.10"
+    "@types/d3-transition" "^3.0.8"
+    "@types/d3-zoom" "^3.0.8"
+    d3-drag "^3.0.0"
+    d3-selection "^3.0.0"
+    d3-zoom "^3.0.0"
+
 "@yarnpkg/lockfile@^1.1.0":
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31"
@@ -8244,6 +8322,11 @@ cjs-module-lexer@^1.0.0, cjs-module-lexer@^1.2.2:
   resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz#6c370ab19f8a3394e318fe682686ec0ac684d107"
   integrity sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==
 
+classcat@^5.0.4:
+  version "5.0.5"
+  resolved "https://registry.yarnpkg.com/classcat/-/classcat-5.0.5.tgz#8c209f359a93ac302404a10161b501eba9c09c77"
+  integrity sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==
+
 clean-stack@^2.0.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b"
@@ -9159,6 +9242,68 @@ curlconverter@3.21.0:
     string.prototype.startswith "^1.0.0"
     yamljs "^0.3.0"
 
+"d3-color@1 - 3":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2"
+  integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==
+
+"d3-dispatch@1 - 3":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e"
+  integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==
+
+"d3-drag@2 - 3", d3-drag@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba"
+  integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==
+  dependencies:
+    d3-dispatch "1 - 3"
+    d3-selection "3"
+
+"d3-ease@1 - 3":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
+  integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
+
+"d3-interpolate@1 - 3":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
+  integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
+  dependencies:
+    d3-color "1 - 3"
+
+"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
+  integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==
+
+"d3-timer@1 - 3":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
+  integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
+
+"d3-transition@2 - 3":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f"
+  integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==
+  dependencies:
+    d3-color "1 - 3"
+    d3-dispatch "1 - 3"
+    d3-ease "1 - 3"
+    d3-interpolate "1 - 3"
+    d3-timer "1 - 3"
+
+d3-zoom@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3"
+  integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==
+  dependencies:
+    d3-dispatch "1 - 3"
+    d3-drag "2 - 3"
+    d3-interpolate "1 - 3"
+    d3-selection "2 - 3"
+    d3-transition "2 - 3"
+
 dargs@^7.0.0:
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/dargs/-/dargs-7.0.0.tgz#04015c41de0bcb69ec84050f3d9be0caf8d6d5cc"