Add new access selection UI for tables and views
This commit is contained in:
parent
714d05a9d2
commit
4fd74c3a19
|
@ -8,6 +8,7 @@
|
||||||
export let onConfirm = undefined
|
export let onConfirm = undefined
|
||||||
export let buttonText = ""
|
export let buttonText = ""
|
||||||
export let cta = false
|
export let cta = false
|
||||||
|
|
||||||
$: icon = selectIcon(type)
|
$: icon = selectIcon(type)
|
||||||
// if newlines used, convert them to different elements
|
// if newlines used, convert them to different elements
|
||||||
$: split = message.split("\n")
|
$: split = message.split("\n")
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
export let url = null
|
export let url = null
|
||||||
export let hoverable = false
|
export let hoverable = false
|
||||||
export let showArrow = false
|
export let showArrow = false
|
||||||
|
export let selected = false
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
|
@ -15,6 +16,7 @@
|
||||||
class="list-item"
|
class="list-item"
|
||||||
class:hoverable={hoverable || url != null}
|
class:hoverable={hoverable || url != null}
|
||||||
on:click
|
on:click
|
||||||
|
class:selected
|
||||||
>
|
>
|
||||||
<div class="left">
|
<div class="left">
|
||||||
{#if icon}
|
{#if icon}
|
||||||
|
@ -43,7 +45,7 @@
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.list-item {
|
.list-item {
|
||||||
padding: var(--spacing-m);
|
padding: var(--spacing-m) var(--spacing-l);
|
||||||
background: var(--spectrum-global-color-gray-75);
|
background: var(--spectrum-global-color-gray-75);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
@ -66,8 +68,13 @@
|
||||||
}
|
}
|
||||||
.hoverable:hover {
|
.hoverable:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.hoverable:not(.selected):hover {
|
||||||
background: var(--spectrum-global-color-gray-200);
|
background: var(--spectrum-global-color-gray-200);
|
||||||
}
|
}
|
||||||
|
.selected {
|
||||||
|
background: var(--spectrum-global-color-blue-100);
|
||||||
|
}
|
||||||
|
|
||||||
.left,
|
.left,
|
||||||
.right {
|
.right {
|
||||||
|
|
|
@ -1,31 +1,208 @@
|
||||||
<script>
|
<script>
|
||||||
import { ActionButton } from "@budibase/bbui"
|
import {
|
||||||
import { permissions } from "stores/builder"
|
ActionButton,
|
||||||
import ManageAccessModal from "../modals/ManageAccessModal.svelte"
|
Icon,
|
||||||
|
Body,
|
||||||
|
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 DetailPopover from "components/common/DetailPopover.svelte"
|
||||||
import EditRolesButton from "./EditRolesButton.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
|
export let resourceId
|
||||||
|
|
||||||
let resourcePermissions
|
const inheritedRoleId = "inherited"
|
||||||
|
const builtins = [Roles.ADMIN, Roles.POWER, Roles.BASIC, Roles.PUBLIC]
|
||||||
|
|
||||||
|
let permissions
|
||||||
let showPopover = true
|
let showPopover = true
|
||||||
|
let dependantsInfoMessage
|
||||||
|
|
||||||
$: fetchPermissions(resourceId)
|
$: fetchPermissions(resourceId)
|
||||||
|
$: loadDependantInfo(resourceId)
|
||||||
|
$: roleMismatch = checkRoleMismatch(permissions)
|
||||||
|
$: selectedRole = roleMismatch ? null : permissions?.[0]?.value
|
||||||
|
$: readableRole = selectedRole
|
||||||
|
? $roles.find(x => x._id === selectedRole)?.name
|
||||||
|
: null
|
||||||
|
$: buttonLabel = readableRole ? `Access: ${readableRole}` : "Access"
|
||||||
|
$: customRoles = $roles.filter(x => !builtins.includes(x._id))
|
||||||
|
$: highlight = roleMismatch || selectedRole === Roles.PUBLIC
|
||||||
|
|
||||||
const fetchPermissions = async id => {
|
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>
|
</script>
|
||||||
|
|
||||||
<DetailPopover title="Manage access" {showPopover}>
|
<DetailPopover title="Select access role" {showPopover}>
|
||||||
<svelte:fragment slot="anchor" let:open>
|
<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>
|
</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 new role for both read and write access."
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<List>
|
||||||
|
<ListItem
|
||||||
|
title="App admin"
|
||||||
|
subtitle="Only app admins can access this data"
|
||||||
|
hoverable
|
||||||
|
selected={selectedRole === Roles.ADMIN}
|
||||||
|
on:click={() => changePermission(Roles.ADMIN)}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="App power user"
|
||||||
|
subtitle="Only app power users can access this data"
|
||||||
|
hoverable
|
||||||
|
selected={selectedRole === Roles.POWER}
|
||||||
|
on:click={() => changePermission(Roles.POWER)}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="App user"
|
||||||
|
subtitle="Only logged in users can access this data"
|
||||||
|
hoverable
|
||||||
|
selected={selectedRole === Roles.BASIC}
|
||||||
|
on:click={() => changePermission(Roles.BASIC)}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Public user"
|
||||||
|
subtitle="Users are not required to log in to access this data"
|
||||||
|
hoverable
|
||||||
|
selected={selectedRole === Roles.PUBLIC}
|
||||||
|
on:click={() => changePermission(Roles.PUBLIC)}
|
||||||
|
/>
|
||||||
|
{#each customRoles as role}
|
||||||
|
<ListItem
|
||||||
|
title={role.name}
|
||||||
|
hoverable
|
||||||
|
selected={selectedRole === role._id}
|
||||||
|
on:click={() => changePermission(role._id)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
{#if dependantsInfoMessage}
|
||||||
|
<InfoDisplay body={dependantsInfoMessage} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<EditRolesButton
|
<EditRolesButton
|
||||||
on:show={() => (showPopover = false)}
|
on:show={() => (showPopover = false)}
|
||||||
on:hide={() => (showPopover = true)}
|
on:hide={() => (showPopover = true)}
|
||||||
/>
|
/>
|
||||||
</DetailPopover>
|
</DetailPopover>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-gap: var(--spacing-s);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
let basePermissions = []
|
let basePermissions = []
|
||||||
let selectedRole = BASE_ROLE
|
let selectedRole = BASE_ROLE
|
||||||
let errors = []
|
let errors = []
|
||||||
let builtInRoles = ["Admin", "Power", "Basic", "Public"]
|
let builtInRoles = ["App admin", "App power user", "App user", "Public user"]
|
||||||
let validRegex = /^[a-zA-Z0-9_]*$/
|
let validRegex = /^[a-zA-Z0-9_]*$/
|
||||||
// Don't allow editing of public role
|
// Don't allow editing of public role
|
||||||
$: editableRoles = $roles.filter(role => role._id !== "PUBLIC")
|
$: editableRoles = $roles.filter(role => role._id !== "PUBLIC")
|
||||||
|
@ -108,6 +108,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRoleNameError = name => {
|
const getRoleNameError = name => {
|
||||||
|
if (builtInRoles.includes(name)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
const hasUniqueRoleName = !otherRoles
|
const hasUniqueRoleName = !otherRoles
|
||||||
?.map(role => role.name)
|
?.map(role => role.name)
|
||||||
?.includes(name)
|
?.includes(name)
|
||||||
|
|
|
@ -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>
|
|
|
@ -4,9 +4,11 @@
|
||||||
export let title
|
export let title
|
||||||
export let body
|
export let body
|
||||||
export let icon = "HelpOutline"
|
export let icon = "HelpOutline"
|
||||||
|
export let warning = false
|
||||||
|
export let error = false
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="info" class:noTitle={!title}>
|
<div class="info" class:noTitle={!title} class:warning class:error>
|
||||||
{#if title}
|
{#if title}
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<Icon name={icon} />
|
<Icon name={icon} />
|
||||||
|
@ -16,7 +18,7 @@
|
||||||
{@html body}
|
{@html body}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<Icon name={icon} />
|
<Icon size="S" name={icon} />
|
||||||
</span>
|
</span>
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
{@html body}
|
{@html body}
|
||||||
|
@ -24,6 +26,23 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.title {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
@ -39,17 +58,7 @@
|
||||||
.icon {
|
.icon {
|
||||||
color: var(--spectrum-global-color-gray-600);
|
color: var(--spectrum-global-color-gray-600);
|
||||||
}
|
}
|
||||||
.info {
|
|
||||||
padding: var(--spacing-m) var(--spacing-l) var(--spacing-l) var(--spacing-l);
|
|
||||||
background-color: var(--background-alt);
|
|
||||||
border-radius: var(--border-radius-s);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.noTitle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-m);
|
|
||||||
}
|
|
||||||
.info :global(a) {
|
.info :global(a) {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
transition: color 130ms ease-out;
|
transition: color 130ms ease-out;
|
||||||
|
|
|
@ -1,6 +1,14 @@
|
||||||
import { writable } from "svelte/store"
|
import { writable } from "svelte/store"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
import { RoleUtils } from "@budibase/frontend-core"
|
import { RoleUtils } from "@budibase/frontend-core"
|
||||||
|
import { Roles } from "constants/backend"
|
||||||
|
|
||||||
|
const ROLE_NAMES = {
|
||||||
|
[Roles.ADMIN]: "App admin",
|
||||||
|
[Roles.POWER]: "App power user",
|
||||||
|
[Roles.BASIC]: "App user",
|
||||||
|
[Roles.PUBLIC]: "Public user",
|
||||||
|
}
|
||||||
|
|
||||||
export function createRolesStore() {
|
export function createRolesStore() {
|
||||||
const { subscribe, update, set } = writable([])
|
const { subscribe, update, set } = writable([])
|
||||||
|
@ -17,7 +25,16 @@ export function createRolesStore() {
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
fetch: async () => {
|
fetch: async () => {
|
||||||
const roles = await API.getRoles()
|
let roles = await API.getRoles()
|
||||||
|
|
||||||
|
// Update labels
|
||||||
|
for (let [roleId, name] of Object.entries(ROLE_NAMES)) {
|
||||||
|
const idx = roles.findIndex(x => x._id === roleId)
|
||||||
|
if (idx !== -1) {
|
||||||
|
roles[idx].name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setRoles(roles)
|
setRoles(roles)
|
||||||
},
|
},
|
||||||
fetchByAppId: async appId => {
|
fetchByAppId: async appId => {
|
||||||
|
|
Loading…
Reference in New Issue