Merge branch 'develop' into BUDI-7393/display_inheritance_permission
This commit is contained in:
commit
a1da8e495c
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "2.9.33-alpha.6",
|
||||
"version": "2.9.33-alpha.11",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*"
|
||||
|
|
|
@ -2,8 +2,9 @@
|
|||
import { createEventDispatcher } from "svelte"
|
||||
import FancyField from "./FancyField.svelte"
|
||||
import Icon from "../Icon/Icon.svelte"
|
||||
import Popover from "../Popover/Popover.svelte"
|
||||
import FancyFieldLabel from "./FancyFieldLabel.svelte"
|
||||
import StatusLight from "../StatusLight/StatusLight.svelte"
|
||||
import Picker from "../Form/Core/Picker.svelte"
|
||||
|
||||
export let label
|
||||
export let value
|
||||
|
@ -11,18 +12,30 @@
|
|||
export let error = null
|
||||
export let validate = null
|
||||
export let options = []
|
||||
export let isOptionEnabled = () => true
|
||||
export let getOptionLabel = option => extractProperty(option, "label")
|
||||
export let getOptionValue = option => extractProperty(option, "value")
|
||||
|
||||
export let getOptionSubtitle = option => extractProperty(option, "subtitle")
|
||||
export let getOptionColour = () => null
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let open = false
|
||||
let popover
|
||||
let wrapper
|
||||
|
||||
$: placeholder = !value
|
||||
$: selectedLabel = getSelectedLabel(value)
|
||||
$: fieldColour = getFieldAttribute(getOptionColour, value, options)
|
||||
|
||||
const getFieldAttribute = (getAttribute, value, options) => {
|
||||
// Wait for options to load if there is a value but no options
|
||||
if (!options?.length) {
|
||||
return ""
|
||||
}
|
||||
const index = options.findIndex(
|
||||
(option, idx) => getOptionValue(option, idx) === value
|
||||
)
|
||||
return index !== -1 ? getAttribute(options[index], index) : null
|
||||
}
|
||||
const extractProperty = (value, property) => {
|
||||
if (value && typeof value === "object") {
|
||||
return value[property]
|
||||
|
@ -64,46 +77,45 @@
|
|||
<FancyFieldLabel {placeholder}>{label}</FancyFieldLabel>
|
||||
{/if}
|
||||
|
||||
{#if fieldColour}
|
||||
<span class="align">
|
||||
<StatusLight square color={fieldColour} />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<div class="value" class:placeholder>
|
||||
{selectedLabel || ""}
|
||||
</div>
|
||||
|
||||
<div class="arrow">
|
||||
<div class="align arrow-alignment">
|
||||
<Icon name="ChevronDown" />
|
||||
</div>
|
||||
</FancyField>
|
||||
|
||||
<Popover
|
||||
anchor={wrapper}
|
||||
align="left"
|
||||
portalTarget={document.documentElement}
|
||||
bind:this={popover}
|
||||
{open}
|
||||
on:close={() => (open = false)}
|
||||
useAnchorWidth={true}
|
||||
maxWidth={null}
|
||||
>
|
||||
<div class="popover-content">
|
||||
{#if options.length}
|
||||
{#each options as option, idx}
|
||||
<div
|
||||
class="popover-option"
|
||||
tabindex="0"
|
||||
on:click={() => onChange(getOptionValue(option, idx))}
|
||||
>
|
||||
<span class="option-text">
|
||||
{getOptionLabel(option, idx)}
|
||||
</span>
|
||||
{#if value === getOptionValue(option, idx)}
|
||||
<Icon name="Checkmark" />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Popover>
|
||||
<div id="picker-wrapper">
|
||||
<Picker
|
||||
customAnchor={wrapper}
|
||||
onlyPopover={true}
|
||||
bind:open
|
||||
{error}
|
||||
{disabled}
|
||||
{options}
|
||||
{getOptionLabel}
|
||||
{getOptionValue}
|
||||
{getOptionSubtitle}
|
||||
{getOptionColour}
|
||||
{isOptionEnabled}
|
||||
isPlaceholder={value == null || value === ""}
|
||||
placeholderOption={placeholder === false ? null : placeholder}
|
||||
onSelectOption={onChange}
|
||||
isOptionSelected={option => option === value}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#picker-wrapper :global(.spectrum-Picker) {
|
||||
display: none;
|
||||
}
|
||||
.value {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
|
@ -118,30 +130,23 @@
|
|||
width: 0;
|
||||
transform: translateY(9px);
|
||||
}
|
||||
|
||||
.align {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
line-height: 17px;
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
transition: transform 130ms ease-out, opacity 130ms ease-out;
|
||||
transform: translateY(9px);
|
||||
}
|
||||
|
||||
.arrow-alignment {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.value.placeholder {
|
||||
transform: translateY(0);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
margin-top: 0;
|
||||
}
|
||||
.popover-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
padding: 7px 0;
|
||||
}
|
||||
.popover-option {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 7px 16px;
|
||||
transition: background 130ms ease-out;
|
||||
font-size: 15px;
|
||||
}
|
||||
.popover-option:hover {
|
||||
background: var(--spectrum-global-color-gray-200);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -8,6 +8,8 @@
|
|||
import Icon from "../../Icon/Icon.svelte"
|
||||
import StatusLight from "../../StatusLight/StatusLight.svelte"
|
||||
import Popover from "../../Popover/Popover.svelte"
|
||||
import Tags from "../../Tags/Tags.svelte"
|
||||
import Tag from "../../Tags/Tag.svelte"
|
||||
|
||||
export let id = null
|
||||
export let disabled = false
|
||||
|
@ -26,6 +28,7 @@
|
|||
export let getOptionIcon = () => null
|
||||
export let useOptionIconImage = false
|
||||
export let getOptionColour = () => null
|
||||
export let getOptionSubtitle = () => null
|
||||
export let open = false
|
||||
export let readonly = false
|
||||
export let quiet = false
|
||||
|
@ -37,7 +40,7 @@
|
|||
export let customPopoverHeight
|
||||
export let align = "left"
|
||||
export let footer = null
|
||||
|
||||
export let customAnchor = null
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let searchTerm = null
|
||||
|
@ -99,7 +102,7 @@
|
|||
bind:this={button}
|
||||
>
|
||||
{#if fieldIcon}
|
||||
{#if !useOptionIconImage}
|
||||
{#if !useOptionIconImage}x
|
||||
<span class="option-extra icon">
|
||||
<Icon size="S" name={fieldIcon} />
|
||||
</span>
|
||||
|
@ -139,9 +142,8 @@
|
|||
<use xlink:href="#spectrum-css-icon-Chevron100" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Popover
|
||||
anchor={button}
|
||||
anchor={customAnchor ? customAnchor : button}
|
||||
align={align || "left"}
|
||||
bind:this={popover}
|
||||
{open}
|
||||
|
@ -215,8 +217,21 @@
|
|||
</span>
|
||||
{/if}
|
||||
<span class="spectrum-Menu-itemLabel">
|
||||
{#if getOptionSubtitle(option, idx)}
|
||||
<span class="subtitle-text"
|
||||
>{getOptionSubtitle(option, idx)}</span
|
||||
>
|
||||
{/if}
|
||||
|
||||
{getOptionLabel(option, idx)}
|
||||
</span>
|
||||
{#if option.tag}
|
||||
<span class="option-tag">
|
||||
<Tags>
|
||||
<Tag icon="LockClosed">{option.tag}</Tag>
|
||||
</Tags>
|
||||
</span>
|
||||
{/if}
|
||||
<svg
|
||||
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
|
||||
focusable="false"
|
||||
|
@ -242,6 +257,17 @@
|
|||
width: 100%;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.subtitle-text {
|
||||
font-size: 12px;
|
||||
line-height: 15px;
|
||||
font-weight: 500;
|
||||
top: 10px;
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.spectrum-Picker-label.auto-width {
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
@ -321,4 +347,12 @@
|
|||
.option-extra.icon.field-icon {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.option-tag {
|
||||
margin: 0 var(--spacing-m) 0 var(--spacing-m);
|
||||
}
|
||||
|
||||
.option-tag :global(.spectrum-Tags-item > .spectrum-Icon) {
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
export let sort = false
|
||||
export let align
|
||||
export let footer = null
|
||||
|
||||
export let tag = null
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let open = false
|
||||
|
@ -83,6 +83,7 @@
|
|||
{isOptionEnabled}
|
||||
{autocomplete}
|
||||
{sort}
|
||||
{tag}
|
||||
isPlaceholder={value == null || value === ""}
|
||||
placeholderOption={placeholder === false ? null : placeholder}
|
||||
isOptionSelected={option => option === value}
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
export let customPopoverHeight
|
||||
export let align
|
||||
export let footer = null
|
||||
|
||||
export let tag = null
|
||||
const dispatch = createEventDispatcher()
|
||||
const onChange = e => {
|
||||
value = e.detail
|
||||
|
@ -61,6 +61,7 @@
|
|||
{isOptionEnabled}
|
||||
{autocomplete}
|
||||
{customPopoverHeight}
|
||||
{tag}
|
||||
on:change={onChange}
|
||||
on:click
|
||||
/>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
export let text = null
|
||||
export let condition = true
|
||||
export let duration = 3000
|
||||
export let duration = 5000
|
||||
export let position
|
||||
export let type
|
||||
|
||||
|
|
|
@ -3,6 +3,9 @@ import { Screen } from "./utils/Screen"
|
|||
import { Component } from "./utils/Component"
|
||||
|
||||
export default function (datasources) {
|
||||
if (!Array.isArray(datasources)) {
|
||||
return []
|
||||
}
|
||||
return datasources.map(datasource => {
|
||||
return {
|
||||
name: `${datasource.name} - List`,
|
||||
|
|
|
@ -1,18 +1,30 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import { Modal, ActionButton } from "@budibase/bbui"
|
||||
import { Modal, ActionButton, TooltipType, TempTooltip } from "@budibase/bbui"
|
||||
import GridCreateViewModal from "../../modals/grid/GridCreateViewModal.svelte"
|
||||
|
||||
const { rows, columns } = getContext("grid")
|
||||
const { rows, columns, filter } = getContext("grid")
|
||||
|
||||
let modal
|
||||
let firstFilterUsage = false
|
||||
|
||||
$: disabled = !$columns.length || !$rows.length
|
||||
$: {
|
||||
if ($filter?.length && !firstFilterUsage) {
|
||||
firstFilterUsage = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ActionButton {disabled} icon="CollectionAdd" quiet on:click={modal.show}>
|
||||
Add view
|
||||
</ActionButton>
|
||||
<TempTooltip
|
||||
text="Create a view to save your filters"
|
||||
type={TooltipType.Info}
|
||||
condition={firstFilterUsage}
|
||||
>
|
||||
<ActionButton {disabled} icon="CollectionAdd" quiet on:click={modal.show}>
|
||||
Create view
|
||||
</ActionButton>
|
||||
</TempTooltip>
|
||||
<Modal bind:this={modal}>
|
||||
<GridCreateViewModal />
|
||||
</Modal>
|
||||
|
|
|
@ -46,13 +46,13 @@
|
|||
</script>
|
||||
|
||||
<ModalContent
|
||||
title="Create View"
|
||||
confirmText="Create View"
|
||||
title="Create view"
|
||||
confirmText="Create view"
|
||||
onConfirm={saveView}
|
||||
disabled={nameExists}
|
||||
>
|
||||
<Input
|
||||
label="View Name"
|
||||
label="View name"
|
||||
thin
|
||||
bind:value={name}
|
||||
error={nameExists ? "A view already exists with that name" : null}
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
<script>
|
||||
import { Select } from "@budibase/bbui"
|
||||
import { Select, FancySelect } from "@budibase/bbui"
|
||||
import { roles } from "stores/backend"
|
||||
import { licensing } from "stores/portal"
|
||||
|
||||
import { Constants, RoleUtils } from "@budibase/frontend-core"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { capitalise } from "helpers"
|
||||
|
||||
export let value
|
||||
export let error
|
||||
|
@ -15,17 +18,43 @@
|
|||
export let align
|
||||
export let footer = null
|
||||
export let allowedRoles = null
|
||||
export let allowCreator = false
|
||||
export let fancySelect = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const RemoveID = "remove"
|
||||
|
||||
$: options = getOptions($roles, allowPublic, allowRemove, allowedRoles)
|
||||
|
||||
const getOptions = (roles, allowPublic, allowRemove, allowedRoles) => {
|
||||
$: options = getOptions(
|
||||
$roles,
|
||||
allowPublic,
|
||||
allowRemove,
|
||||
allowedRoles,
|
||||
allowCreator
|
||||
)
|
||||
const getOptions = (
|
||||
roles,
|
||||
allowPublic,
|
||||
allowRemove,
|
||||
allowedRoles,
|
||||
allowCreator
|
||||
) => {
|
||||
if (allowedRoles?.length) {
|
||||
return roles.filter(role => allowedRoles.includes(role._id))
|
||||
}
|
||||
let newRoles = [...roles]
|
||||
|
||||
if (allowCreator) {
|
||||
newRoles = [
|
||||
{
|
||||
_id: Constants.Roles.CREATOR,
|
||||
name: "Creator",
|
||||
tag:
|
||||
!$licensing.perAppBuildersEnabled &&
|
||||
capitalise(Constants.PlanType.BUSINESS),
|
||||
},
|
||||
...newRoles,
|
||||
]
|
||||
}
|
||||
if (allowRemove) {
|
||||
newRoles = [
|
||||
...newRoles,
|
||||
|
@ -64,19 +93,45 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<Select
|
||||
{autoWidth}
|
||||
{quiet}
|
||||
{disabled}
|
||||
{align}
|
||||
{footer}
|
||||
bind:value
|
||||
on:change={onChange}
|
||||
{options}
|
||||
getOptionLabel={role => role.name}
|
||||
getOptionValue={role => role._id}
|
||||
getOptionColour={getColor}
|
||||
getOptionIcon={getIcon}
|
||||
{placeholder}
|
||||
{error}
|
||||
/>
|
||||
{#if fancySelect}
|
||||
<FancySelect
|
||||
{autoWidth}
|
||||
{quiet}
|
||||
{disabled}
|
||||
{align}
|
||||
{footer}
|
||||
bind:value
|
||||
on:change={onChange}
|
||||
{options}
|
||||
label="Access on this app"
|
||||
getOptionLabel={role => role.name}
|
||||
getOptionValue={role => role._id}
|
||||
getOptionColour={getColor}
|
||||
getOptionIcon={getIcon}
|
||||
isOptionEnabled={option =>
|
||||
option._id !== Constants.Roles.CREATOR ||
|
||||
$licensing.perAppBuildersEnabled}
|
||||
{placeholder}
|
||||
{error}
|
||||
/>
|
||||
{:else}
|
||||
<Select
|
||||
{autoWidth}
|
||||
{quiet}
|
||||
{disabled}
|
||||
{align}
|
||||
{footer}
|
||||
bind:value
|
||||
on:change={onChange}
|
||||
{options}
|
||||
getOptionLabel={role => role.name}
|
||||
getOptionValue={role => role._id}
|
||||
getOptionColour={getColor}
|
||||
getOptionIcon={getIcon}
|
||||
isOptionEnabled={option =>
|
||||
option._id !== Constants.Roles.CREATOR ||
|
||||
$licensing.perAppBuildersEnabled}
|
||||
{placeholder}
|
||||
{error}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
@ -33,17 +33,19 @@
|
|||
let anchors = {}
|
||||
let draggableItems = []
|
||||
|
||||
const buildDragable = items => {
|
||||
return items.map(item => {
|
||||
return {
|
||||
id: listItemKey ? item[listItemKey] : generate(),
|
||||
item,
|
||||
}
|
||||
})
|
||||
const buildDraggable = items => {
|
||||
return items
|
||||
.map(item => {
|
||||
return {
|
||||
id: listItemKey ? item[listItemKey] : generate(),
|
||||
item,
|
||||
}
|
||||
})
|
||||
.filter(item => item.id)
|
||||
}
|
||||
|
||||
$: if (items) {
|
||||
draggableItems = buildDragable(items)
|
||||
draggableItems = buildDraggable(items)
|
||||
}
|
||||
|
||||
const updateRowOrder = e => {
|
||||
|
|
|
@ -99,6 +99,9 @@
|
|||
}
|
||||
|
||||
const type = getComponentForField(instance.field, schema)
|
||||
if (!type) {
|
||||
return null
|
||||
}
|
||||
instance._component = `@budibase/standard-components/${type}`
|
||||
|
||||
const pseudoComponentInstance = store.actions.components.createInstance(
|
||||
|
@ -116,7 +119,9 @@
|
|||
}
|
||||
|
||||
$: if (sanitisedFields) {
|
||||
fieldList = [...sanitisedFields, ...unconfigured].map(buildSudoInstance)
|
||||
fieldList = [...sanitisedFields, ...unconfigured]
|
||||
.map(buildSudoInstance)
|
||||
.filter(x => x != null)
|
||||
}
|
||||
|
||||
const processItemUpdate = e => {
|
||||
|
|
|
@ -26,6 +26,9 @@ export const capitalise = s => {
|
|||
|
||||
export const lowercase = s => s.substring(0, 1).toLowerCase() + s.substring(1)
|
||||
|
||||
export const lowercaseExceptFirst = s =>
|
||||
s.charAt(0) + s.substring(1).toLowerCase()
|
||||
|
||||
export const get_name = s => (!s ? "" : last(s.split("/")))
|
||||
|
||||
export const get_capitalised_name = name => pipe(name, [get_name, capitalise])
|
||||
|
|
|
@ -1,18 +1,27 @@
|
|||
<script>
|
||||
import {
|
||||
Icon,
|
||||
Divider,
|
||||
Heading,
|
||||
Layout,
|
||||
Input,
|
||||
clickOutside,
|
||||
notifications,
|
||||
ActionButton,
|
||||
CopyInput,
|
||||
Modal,
|
||||
FancyForm,
|
||||
FancyInput,
|
||||
Button,
|
||||
FancySelect,
|
||||
} from "@budibase/bbui"
|
||||
import { store } from "builderStore"
|
||||
import { groups, licensing, apps, users, auth, admin } from "stores/portal"
|
||||
import { fetchData, Constants, Utils } from "@budibase/frontend-core"
|
||||
import {
|
||||
fetchData,
|
||||
Constants,
|
||||
Utils,
|
||||
RoleUtils,
|
||||
} from "@budibase/frontend-core"
|
||||
import { sdk } from "@budibase/shared-core"
|
||||
import { API } from "api"
|
||||
import GroupIcon from "../../../portal/users/groups/_components/GroupIcon.svelte"
|
||||
|
@ -26,10 +35,15 @@
|
|||
let loaded = false
|
||||
let inviting = false
|
||||
let searchFocus = false
|
||||
|
||||
let invitingFlow = false
|
||||
// Initially filter entities without app access
|
||||
// Show all when false
|
||||
let filterByAppAccess = true
|
||||
let filterByAppAccess = false
|
||||
let email
|
||||
let error
|
||||
let form
|
||||
let creationRoleType = Constants.BudibaseRoles.AppUser
|
||||
let creationAccessType = Constants.Roles.BASIC
|
||||
|
||||
let appInvites = []
|
||||
let filteredInvites = []
|
||||
|
@ -40,8 +54,7 @@
|
|||
let userLimitReachedModal
|
||||
|
||||
let inviteFailureResponse = ""
|
||||
|
||||
$: queryIsEmail = emailValidator(query) === true
|
||||
$: validEmail = emailValidator(email) === true
|
||||
$: prodAppId = apps.getProdAppID($store.appId)
|
||||
$: promptInvite = showInvite(
|
||||
filteredInvites,
|
||||
|
@ -50,7 +63,6 @@
|
|||
query
|
||||
)
|
||||
$: isOwner = $auth.accountPortalAccess && $admin.cloud
|
||||
|
||||
const showInvite = (invites, users, groups, query) => {
|
||||
return !invites?.length && !users?.length && !groups?.length && query
|
||||
}
|
||||
|
@ -66,9 +78,9 @@
|
|||
if (!filterByAppAccess && !query) {
|
||||
filteredInvites =
|
||||
appInvites.length > 100 ? appInvites.slice(0, 100) : [...appInvites]
|
||||
filteredInvites.sort(sortInviteRoles)
|
||||
return
|
||||
}
|
||||
|
||||
filteredInvites = appInvites.filter(invite => {
|
||||
const inviteInfo = invite.info?.apps
|
||||
if (!query && inviteInfo && prodAppId) {
|
||||
|
@ -76,8 +88,8 @@
|
|||
}
|
||||
return invite.email.includes(query)
|
||||
})
|
||||
filteredInvites.sort(sortInviteRoles)
|
||||
}
|
||||
|
||||
$: filterByAppAccess, prodAppId, filterInvites(query)
|
||||
$: if (searchFocus === true) {
|
||||
filterByAppAccess = false
|
||||
|
@ -107,24 +119,66 @@
|
|||
})
|
||||
await usersFetch.refresh()
|
||||
|
||||
filteredUsers = $usersFetch.rows.map(user => {
|
||||
const isAdminOrBuilder = sdk.users.isAdminOrBuilder(user, prodAppId)
|
||||
let role = undefined
|
||||
if (isAdminOrBuilder) {
|
||||
role = Constants.Roles.ADMIN
|
||||
} else {
|
||||
const appRole = Object.keys(user.roles).find(x => x === prodAppId)
|
||||
if (appRole) {
|
||||
role = user.roles[appRole]
|
||||
filteredUsers = $usersFetch.rows
|
||||
.filter(user => !user?.admin?.global) // filter out global admins
|
||||
.map(user => {
|
||||
const isAdminOrGlobalBuilder = sdk.users.isAdminOrGlobalBuilder(
|
||||
user,
|
||||
prodAppId
|
||||
)
|
||||
const isAppBuilder = sdk.users.hasAppBuilderPermissions(user, prodAppId)
|
||||
let role
|
||||
if (isAdminOrGlobalBuilder) {
|
||||
role = Constants.Roles.ADMIN
|
||||
} else if (isAppBuilder) {
|
||||
role = Constants.Roles.CREATOR
|
||||
} else {
|
||||
const appRole = user.roles[prodAppId]
|
||||
if (appRole) {
|
||||
role = appRole
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
role,
|
||||
isAdminOrBuilder,
|
||||
}
|
||||
})
|
||||
return {
|
||||
...user,
|
||||
role,
|
||||
isAdminOrGlobalBuilder,
|
||||
isAppBuilder,
|
||||
}
|
||||
})
|
||||
.sort(sortRoles)
|
||||
}
|
||||
|
||||
const sortInviteRoles = (a, b) => {
|
||||
const aEmpty =
|
||||
!a.info?.appBuilders?.length && Object.keys(a.info.apps).length === 0
|
||||
const bEmpty =
|
||||
!b.info?.appBuilders?.length && Object.keys(b.info.apps).length === 0
|
||||
|
||||
if (aEmpty && !bEmpty) return 1
|
||||
if (!aEmpty && bEmpty) return -1
|
||||
}
|
||||
|
||||
const sortRoles = (a, b) => {
|
||||
const roleA = a.role
|
||||
const roleB = b.role
|
||||
|
||||
const priorityA = RoleUtils.getRolePriority(roleA)
|
||||
const priorityB = RoleUtils.getRolePriority(roleB)
|
||||
|
||||
if (roleA === undefined && roleB !== undefined) {
|
||||
return 1
|
||||
} else if (roleA !== undefined && roleB === undefined) {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (priorityA < priorityB) {
|
||||
return 1
|
||||
} else if (priorityA > priorityB) {
|
||||
return -1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
const debouncedUpdateFetch = Utils.debounce(searchUsers, 250)
|
||||
|
@ -160,6 +214,12 @@
|
|||
if (user.role === role) {
|
||||
return
|
||||
}
|
||||
if (user.isAppBuilder) {
|
||||
await removeAppBuilder(user._id, prodAppId)
|
||||
}
|
||||
if (role === Constants.Roles.CREATOR) {
|
||||
await removeAppBuilder(user._id, prodAppId)
|
||||
}
|
||||
await updateAppUser(user, role)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
@ -189,6 +249,9 @@
|
|||
return
|
||||
}
|
||||
try {
|
||||
if (group?.builder?.apps.includes(prodAppId)) {
|
||||
await removeGroupAppBuilder(group._id)
|
||||
}
|
||||
await updateAppGroup(group, role)
|
||||
} catch {
|
||||
notifications.error("Group update failed")
|
||||
|
@ -225,14 +288,17 @@
|
|||
return nameMatch
|
||||
})
|
||||
.map(enrichGroupRole)
|
||||
.sort(sortRoles)
|
||||
}
|
||||
|
||||
const enrichGroupRole = group => {
|
||||
return {
|
||||
...group,
|
||||
role: group.roles?.[
|
||||
groups.actions.getGroupAppIds(group).find(x => x === prodAppId)
|
||||
],
|
||||
role: group?.builder?.apps.includes(prodAppId)
|
||||
? Constants.Roles.CREATOR
|
||||
: group.roles?.[
|
||||
groups.actions.getGroupAppIds(group).find(x => x === prodAppId)
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -245,7 +311,6 @@
|
|||
$: filteredGroups = searchGroups(enrichedGroups, query)
|
||||
$: groupUsers = buildGroupUsers(filteredGroups, filteredUsers)
|
||||
$: allUsers = [...filteredUsers, ...groupUsers]
|
||||
|
||||
/*
|
||||
Create pseudo users from the "users" attribute on app groups.
|
||||
These users will appear muted in the UI and show the ROLE
|
||||
|
@ -291,21 +356,28 @@
|
|||
}
|
||||
|
||||
async function inviteUser() {
|
||||
if (!queryIsEmail) {
|
||||
if (!validEmail) {
|
||||
notifications.error("Email is not valid")
|
||||
return
|
||||
}
|
||||
const newUserEmail = query + ""
|
||||
const newUserEmail = email + ""
|
||||
inviting = true
|
||||
|
||||
const payload = [
|
||||
{
|
||||
email: newUserEmail,
|
||||
builder: false,
|
||||
admin: false,
|
||||
apps: { [prodAppId]: Constants.Roles.BASIC },
|
||||
builder: !!creationRoleType === Constants.BudibaseRoles.Admin,
|
||||
admin: !!creationRoleType === Constants.BudibaseRoles.Admin,
|
||||
},
|
||||
]
|
||||
|
||||
if (creationAccessType === Constants.Roles.CREATOR) {
|
||||
payload[0].appBuilders = [prodAppId]
|
||||
} else {
|
||||
payload[0].apps = {
|
||||
[prodAppId]: creationAccessType,
|
||||
}
|
||||
}
|
||||
let userInviteResponse
|
||||
try {
|
||||
userInviteResponse = await users.onboard(payload)
|
||||
|
@ -317,16 +389,23 @@
|
|||
return userInviteResponse
|
||||
}
|
||||
|
||||
const openInviteFlow = () => {
|
||||
$licensing.userLimitReached
|
||||
? userLimitReachedModal.show()
|
||||
: (invitingFlow = true)
|
||||
}
|
||||
|
||||
const onInviteUser = async () => {
|
||||
form.validate()
|
||||
userOnboardResponse = await inviteUser()
|
||||
const originalQuery = query + ""
|
||||
query = null
|
||||
const originalQuery = email + ""
|
||||
email = null
|
||||
|
||||
const newUser = userOnboardResponse?.successful.find(
|
||||
user => user.email === originalQuery
|
||||
)
|
||||
if (newUser) {
|
||||
query = originalQuery
|
||||
email = originalQuery
|
||||
notifications.success(
|
||||
userOnboardResponse.created
|
||||
? "User created successfully"
|
||||
|
@ -344,16 +423,27 @@
|
|||
notifications.error(inviteFailureResponse)
|
||||
}
|
||||
userOnboardResponse = null
|
||||
invitingFlow = false
|
||||
// trigger reload of the users
|
||||
query = ""
|
||||
}
|
||||
|
||||
const onUpdateUserInvite = async (invite, role) => {
|
||||
await users.updateInvite({
|
||||
let updateBody = {
|
||||
code: invite.code,
|
||||
apps: {
|
||||
...invite.apps,
|
||||
[prodAppId]: role,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (role === Constants.Roles.CREATOR) {
|
||||
updateBody.appBuilders = [...(updateBody.appBuilders ?? []), prodAppId]
|
||||
delete updateBody?.apps?.[prodAppId]
|
||||
} else if (role !== Constants.Roles.CREATOR && invite?.appBuilders) {
|
||||
invite.appBuilders = []
|
||||
}
|
||||
await users.updateInvite(updateBody)
|
||||
await filterInvites(query)
|
||||
}
|
||||
|
||||
|
@ -373,6 +463,22 @@
|
|||
})
|
||||
}
|
||||
|
||||
const addAppBuilder = async userId => {
|
||||
await users.addAppBuilder(userId, prodAppId)
|
||||
}
|
||||
|
||||
const removeAppBuilder = async userId => {
|
||||
await users.removeAppBuilder(userId, prodAppId)
|
||||
}
|
||||
|
||||
const addGroupAppBuilder = async groupId => {
|
||||
await groups.actions.addGroupAppBuilder(groupId, prodAppId)
|
||||
}
|
||||
|
||||
const removeGroupAppBuilder = async groupId => {
|
||||
await groups.actions.removeGroupAppBuilder(groupId, prodAppId)
|
||||
}
|
||||
|
||||
const initSidePanel = async sidePaneOpen => {
|
||||
if (sidePaneOpen === true) {
|
||||
await groups.actions.init()
|
||||
|
@ -383,27 +489,17 @@
|
|||
$: initSidePanel($store.builderSidePanel)
|
||||
|
||||
function handleKeyDown(evt) {
|
||||
if (evt.key === "Enter" && queryIsEmail && !inviting) {
|
||||
if (evt.key === "Enter" && validEmail && !inviting) {
|
||||
onInviteUser()
|
||||
}
|
||||
}
|
||||
|
||||
const userTitle = user => {
|
||||
if (sdk.users.isAdmin(user)) {
|
||||
return "Admin"
|
||||
} else if (sdk.users.isBuilder(user, prodAppId)) {
|
||||
return "Developer"
|
||||
} else {
|
||||
return "App user"
|
||||
}
|
||||
}
|
||||
|
||||
const getRoleFooter = user => {
|
||||
if (user.group) {
|
||||
const role = $roles.find(role => role._id === user.role)
|
||||
return `This user has been given ${role?.name} access from the ${user.group} group`
|
||||
}
|
||||
if (user.isAdminOrBuilder) {
|
||||
if (user.isAdminOrGlobalBuilder) {
|
||||
return "This user's role grants admin access to all apps"
|
||||
}
|
||||
return null
|
||||
|
@ -423,227 +519,300 @@
|
|||
}}
|
||||
>
|
||||
<div class="builder-side-panel-header">
|
||||
<Heading size="S">Users</Heading>
|
||||
<Icon
|
||||
color="var(--spectrum-global-color-gray-600)"
|
||||
name="RailRightClose"
|
||||
hoverable
|
||||
<div
|
||||
on:click={() => {
|
||||
store.update(state => {
|
||||
state.builderSidePanel = false
|
||||
return state
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="search" class:focused={searchFocus}>
|
||||
<span class="search-input">
|
||||
<Input
|
||||
placeholder={"Add users and groups to your app"}
|
||||
autocomplete="off"
|
||||
disabled={inviting}
|
||||
value={query}
|
||||
on:input={e => {
|
||||
query = e.target.value.trim()
|
||||
}}
|
||||
on:focus={() => (searchFocus = true)}
|
||||
on:blur={() => (searchFocus = false)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="search-input-icon"
|
||||
class:searching={query || !filterByAppAccess}
|
||||
on:click={() => {
|
||||
if (!filterByAppAccess) {
|
||||
filterByAppAccess = true
|
||||
}
|
||||
if (!query) {
|
||||
return
|
||||
}
|
||||
query = null
|
||||
userOnboardResponse = null
|
||||
filterByAppAccess = true
|
||||
invitingFlow = false
|
||||
}}
|
||||
class="header"
|
||||
>
|
||||
<Icon name={!filterByAppAccess || query ? "Close" : "Search"} />
|
||||
</span>
|
||||
{#if invitingFlow}
|
||||
<Icon name="BackAndroid" />
|
||||
{/if}
|
||||
<Heading size="S">{invitingFlow ? "Invite new user" : "Users"}</Heading>
|
||||
</div>
|
||||
<div class="header">
|
||||
<Button on:click={openInviteFlow} size="S" cta>Invite user</Button>
|
||||
<Icon
|
||||
color="var(--spectrum-global-color-gray-600)"
|
||||
name="RailRightClose"
|
||||
hoverable
|
||||
on:click={() => {
|
||||
store.update(state => {
|
||||
state.builderSidePanel = false
|
||||
return state
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if !invitingFlow}
|
||||
<div class="search" class:focused={searchFocus}>
|
||||
<span class="search-input">
|
||||
<Input
|
||||
placeholder={"Add users and groups to your app"}
|
||||
autocomplete="off"
|
||||
disabled={inviting}
|
||||
value={query}
|
||||
on:input={e => {
|
||||
query = e.target.value.trim()
|
||||
}}
|
||||
on:focus={() => (searchFocus = true)}
|
||||
on:blur={() => (searchFocus = false)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div class="body">
|
||||
{#if promptInvite && !userOnboardResponse}
|
||||
<Layout gap="S" paddingX="XL">
|
||||
<div class="invite-header">
|
||||
<Heading size="XS">No user found</Heading>
|
||||
<div class="invite-directions">
|
||||
Add a valid email to invite a new user
|
||||
</div>
|
||||
</div>
|
||||
<div class="invite-form">
|
||||
<span>{query || ""}</span>
|
||||
<ActionButton
|
||||
icon="UserAdd"
|
||||
disabled={!queryIsEmail || inviting}
|
||||
on:click={$licensing.userLimitReached
|
||||
? userLimitReachedModal.show
|
||||
: onInviteUser}
|
||||
>
|
||||
Add user
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Layout>
|
||||
{/if}
|
||||
<span
|
||||
class="search-input-icon"
|
||||
class:searching={query || !filterByAppAccess}
|
||||
on:click={() => {
|
||||
if (!query) {
|
||||
return
|
||||
}
|
||||
query = null
|
||||
userOnboardResponse = null
|
||||
}}
|
||||
>
|
||||
<Icon name={!filterByAppAccess || query ? "Close" : "Search"} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if !promptInvite}
|
||||
<Layout gap="L" noPadding>
|
||||
{#if filteredInvites?.length}
|
||||
<Layout noPadding gap="XS">
|
||||
<div class="auth-entity-header">
|
||||
<div class="auth-entity-title">Pending invites</div>
|
||||
<div class="auth-entity-access-title">Access</div>
|
||||
</div>
|
||||
{#each filteredInvites as invite}
|
||||
<div class="auth-entity">
|
||||
<div class="details">
|
||||
<div class="user-email" title={invite.email}>
|
||||
{invite.email}
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-entity-access">
|
||||
<RoleSelect
|
||||
placeholder={false}
|
||||
value={invite.info.apps?.[prodAppId]}
|
||||
allowRemove={invite.info.apps?.[prodAppId]}
|
||||
allowPublic={false}
|
||||
quiet={true}
|
||||
on:change={e => {
|
||||
onUpdateUserInvite(invite, e.detail)
|
||||
}}
|
||||
on:remove={() => {
|
||||
onUninviteAppUser(invite)
|
||||
}}
|
||||
autoWidth
|
||||
align="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</Layout>
|
||||
{/if}
|
||||
|
||||
{#if $licensing.groupsEnabled && filteredGroups?.length}
|
||||
<Layout noPadding gap="XS">
|
||||
<div class="auth-entity-header">
|
||||
<div class="auth-entity-title">Groups</div>
|
||||
<div class="auth-entity-access-title">Access</div>
|
||||
</div>
|
||||
{#each filteredGroups as group}
|
||||
<div
|
||||
class="auth-entity group"
|
||||
on:click={() => {
|
||||
if (selectedGroup != group._id) {
|
||||
selectedGroup = group._id
|
||||
} else {
|
||||
selectedGroup = null
|
||||
}
|
||||
}}
|
||||
on:keydown={() => {}}
|
||||
<div class="body">
|
||||
{#if promptInvite && !userOnboardResponse}
|
||||
<Layout gap="S" paddingX="XL">
|
||||
<div class="invite-header">
|
||||
<Heading size="XS">No user found</Heading>
|
||||
<div class="invite-directions">
|
||||
Try searching a different email or <span
|
||||
class="underlined"
|
||||
on:click={openInviteFlow}>invite a new user</span
|
||||
>
|
||||
<div class="details">
|
||||
<GroupIcon {group} size="S" />
|
||||
<div>
|
||||
{group.name}
|
||||
</div>
|
||||
<div class="auth-entity-meta">
|
||||
{`${group.users?.length} user${
|
||||
group.users?.length != 1 ? "s" : ""
|
||||
}`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-entity-access">
|
||||
<RoleSelect
|
||||
placeholder={false}
|
||||
value={group.role}
|
||||
allowRemove={group.role}
|
||||
allowPublic={false}
|
||||
quiet={true}
|
||||
on:change={e => {
|
||||
onUpdateGroup(group, e.detail)
|
||||
}}
|
||||
on:remove={() => {
|
||||
onUpdateGroup(group)
|
||||
}}
|
||||
autoWidth
|
||||
align="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</Layout>
|
||||
{/if}
|
||||
|
||||
{#if filteredUsers?.length}
|
||||
<div class="auth-entity-section">
|
||||
<div class="auth-entity-header">
|
||||
<div class="auth-entity-title">Users</div>
|
||||
<div class="auth-entity-access-title">Access</div>
|
||||
</div>
|
||||
{#each allUsers as user}
|
||||
<div class="auth-entity">
|
||||
<div class="details">
|
||||
<div class="user-email" title={user.email}>
|
||||
{user.email}
|
||||
</div>
|
||||
<div class="auth-entity-meta">
|
||||
{userTitle(user)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-entity-access" class:muted={user.group}>
|
||||
<RoleSelect
|
||||
footer={getRoleFooter(user)}
|
||||
placeholder={false}
|
||||
value={user.role}
|
||||
allowRemove={user.role && !user.group}
|
||||
allowPublic={false}
|
||||
quiet={true}
|
||||
on:change={e => {
|
||||
onUpdateUser(user, e.detail)
|
||||
}}
|
||||
on:remove={() => {
|
||||
onUpdateUser(user)
|
||||
}}
|
||||
autoWidth
|
||||
align="right"
|
||||
allowedRoles={user.isAdminOrBuilder
|
||||
? [Constants.Roles.ADMIN]
|
||||
: null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Layout>
|
||||
{/if}
|
||||
</Layout>
|
||||
{/if}
|
||||
|
||||
{#if userOnboardResponse?.created}
|
||||
<Layout gap="S" paddingX="XL">
|
||||
<div class="invite-header">
|
||||
<Heading size="XS">User added!</Heading>
|
||||
<div class="invite-directions">
|
||||
Email invites are not available without SMTP configuration. Here is
|
||||
the password that has been generated for this user.
|
||||
{#if !promptInvite}
|
||||
<Layout gap="L" noPadding>
|
||||
{#if filteredInvites?.length}
|
||||
<Layout noPadding gap="XS">
|
||||
<div class="auth-entity-header">
|
||||
<div class="auth-entity-title">Pending invites</div>
|
||||
<div class="auth-entity-access-title">Access</div>
|
||||
</div>
|
||||
{#each filteredInvites as invite}
|
||||
<div class="auth-entity">
|
||||
<div class="details">
|
||||
<div class="user-email" title={invite.email}>
|
||||
{invite.email}
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-entity-access">
|
||||
<RoleSelect
|
||||
placeholder={false}
|
||||
value={invite.info?.appBuilders?.includes(prodAppId)
|
||||
? Constants.Roles.CREATOR
|
||||
: invite.info.apps?.[prodAppId]}
|
||||
allowRemove={invite.info.apps?.[prodAppId]}
|
||||
allowPublic={false}
|
||||
allowCreator={true}
|
||||
quiet={true}
|
||||
on:change={e => {
|
||||
onUpdateUserInvite(invite, e.detail)
|
||||
}}
|
||||
on:remove={() => {
|
||||
onUninviteAppUser(invite)
|
||||
}}
|
||||
autoWidth
|
||||
align="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</Layout>
|
||||
{/if}
|
||||
|
||||
{#if $licensing.groupsEnabled && filteredGroups?.length}
|
||||
<Layout noPadding gap="XS">
|
||||
<div class="auth-entity-header">
|
||||
<div class="auth-entity-title">Groups</div>
|
||||
<div class="auth-entity-access-title">Access</div>
|
||||
</div>
|
||||
{#each filteredGroups as group}
|
||||
<div
|
||||
class="auth-entity group"
|
||||
on:click={() => {
|
||||
if (selectedGroup != group._id) {
|
||||
selectedGroup = group._id
|
||||
} else {
|
||||
selectedGroup = null
|
||||
}
|
||||
}}
|
||||
on:keydown={() => {}}
|
||||
>
|
||||
<div class="details">
|
||||
<GroupIcon {group} size="S" />
|
||||
<div>
|
||||
{group.name}
|
||||
</div>
|
||||
<div class="auth-entity-meta">
|
||||
{`${group.users?.length} user${
|
||||
group.users?.length != 1 ? "s" : ""
|
||||
}`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-entity-access">
|
||||
<RoleSelect
|
||||
placeholder={false}
|
||||
value={group.role}
|
||||
allowRemove={group.role}
|
||||
allowPublic={false}
|
||||
quiet={true}
|
||||
allowCreator={true}
|
||||
on:change={e => {
|
||||
if (e.detail === Constants.Roles.CREATOR) {
|
||||
addGroupAppBuilder(group._id)
|
||||
} else {
|
||||
onUpdateGroup(group, e.detail)
|
||||
}
|
||||
}}
|
||||
on:remove={() => {
|
||||
onUpdateGroup(group)
|
||||
}}
|
||||
autoWidth
|
||||
align="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</Layout>
|
||||
{/if}
|
||||
|
||||
{#if filteredUsers?.length}
|
||||
<div class="auth-entity-section">
|
||||
<div class="auth-entity-header">
|
||||
<div class="auth-entity-title">Users</div>
|
||||
<div class="auth-entity-access-title">Access</div>
|
||||
</div>
|
||||
{#each allUsers as user}
|
||||
<div class="auth-entity">
|
||||
<div class="details">
|
||||
<div class="user-email" title={user.email}>
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-entity-access" class:muted={user.group}>
|
||||
<RoleSelect
|
||||
footer={getRoleFooter(user)}
|
||||
placeholder={false}
|
||||
value={user.role}
|
||||
allowRemove={user.role && !user.group}
|
||||
allowPublic={false}
|
||||
allowCreator={true}
|
||||
quiet={true}
|
||||
on:addcreator={() => {}}
|
||||
on:change={e => {
|
||||
if (e.detail === Constants.Roles.CREATOR) {
|
||||
addAppBuilder(user._id)
|
||||
} else {
|
||||
onUpdateUser(user, e.detail)
|
||||
}
|
||||
}}
|
||||
on:remove={() => {
|
||||
onUpdateUser(user)
|
||||
}}
|
||||
autoWidth
|
||||
align="right"
|
||||
allowedRoles={user.isAdminOrGlobalBuilder
|
||||
? [Constants.Roles.ADMIN]
|
||||
: null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Layout>
|
||||
{/if}
|
||||
|
||||
{#if userOnboardResponse?.created}
|
||||
<Layout gap="S" paddingX="XL">
|
||||
<div class="invite-header">
|
||||
<Heading size="XS">User added!</Heading>
|
||||
<div class="invite-directions">
|
||||
Email invites are not available without SMTP configuration. Here
|
||||
is the password that has been generated for this user.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<CopyInput
|
||||
value={userOnboardResponse.successful[0]?.password}
|
||||
label="Password"
|
||||
/>
|
||||
<div>
|
||||
<CopyInput
|
||||
value={userOnboardResponse.successful[0]?.password}
|
||||
label="Password"
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Divider />
|
||||
<div class="body">
|
||||
<Layout gap="L" noPadding>
|
||||
<div class="user-invite-form">
|
||||
<FancyForm bind:this={form}>
|
||||
<FancyInput
|
||||
disabled={false}
|
||||
label="Email"
|
||||
value={email}
|
||||
on:change={e => {
|
||||
email = e.detail
|
||||
}}
|
||||
validate={() => {
|
||||
if (!email) {
|
||||
return "Please enter an email"
|
||||
}
|
||||
return null
|
||||
}}
|
||||
{error}
|
||||
/>
|
||||
<FancySelect
|
||||
bind:value={creationRoleType}
|
||||
options={sdk.users.isAdmin($auth.user)
|
||||
? Constants.BudibaseRoleOptionsNew
|
||||
: Constants.BudibaseRoleOptionsNew.filter(
|
||||
option => option.value !== Constants.BudibaseRoles.Admin
|
||||
)}
|
||||
label="Role"
|
||||
/>
|
||||
{#if creationRoleType !== Constants.BudibaseRoles.Admin}
|
||||
<RoleSelect
|
||||
placeholder={false}
|
||||
bind:value={creationAccessType}
|
||||
allowPublic={false}
|
||||
allowCreator={true}
|
||||
quiet={true}
|
||||
autoWidth
|
||||
align="right"
|
||||
fancySelect
|
||||
/>
|
||||
{/if}
|
||||
</FancyForm>
|
||||
{#if creationRoleType === Constants.BudibaseRoles.Admin}
|
||||
<div class="admin-info">
|
||||
<Icon name="Info" />
|
||||
Admins will get full access to all apps and settings
|
||||
</div>
|
||||
{/if}
|
||||
<span class="add-user">
|
||||
<Button
|
||||
newStyles
|
||||
cta
|
||||
disabled={!email?.length}
|
||||
on:click={onInviteUser}>Add user</Button
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</Layout>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<Modal bind:this={userLimitReachedModal}>
|
||||
<UpgradeModal {isOwner} />
|
||||
</Modal>
|
||||
|
@ -659,6 +828,27 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.add-user {
|
||||
padding-top: var(--spacing-xl);
|
||||
width: 100%;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.admin-info {
|
||||
margin-top: var(--spacing-xl);
|
||||
padding: var(--spacing-l) var(--spacing-l) var(--spacing-l) var(--spacing-l);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xl);
|
||||
height: 30px;
|
||||
background-color: var(--background-alt);
|
||||
}
|
||||
|
||||
.underlined {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
@ -746,12 +936,6 @@
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.invite-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#builder-side-panel-container .search {
|
||||
padding-top: var(--spacing-m);
|
||||
padding-bottom: var(--spacing-m);
|
||||
|
@ -798,6 +982,16 @@
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-l);
|
||||
}
|
||||
|
||||
.user-invite-form {
|
||||
padding: 0 var(--spacing-xl) var(--spacing-xl) var(--spacing-xl);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
@ -110,7 +110,7 @@
|
|||
if (mode === "table") {
|
||||
datasourceModal.show()
|
||||
} else if (mode === "blank") {
|
||||
let templates = getTemplates($store, $tables.list)
|
||||
let templates = getTemplates($tables.list)
|
||||
const blankScreenTemplate = templates.find(
|
||||
t => t.id === "createFromScratch"
|
||||
)
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
return publishedApps
|
||||
}
|
||||
return publishedApps.filter(app => {
|
||||
if (sdk.users.isBuilder(user, app.appId)) {
|
||||
if (sdk.users.isBuilder(user, app.prodId)) {
|
||||
return true
|
||||
}
|
||||
if (!Object.keys(user?.roles).length && user?.userGroups) {
|
||||
|
@ -142,7 +142,12 @@
|
|||
<div class="group">
|
||||
<Layout gap="S" noPadding>
|
||||
{#each userApps as app (app.appId)}
|
||||
<a class="app" target="_blank" href={getUrl(app)}>
|
||||
<a
|
||||
class="app"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={getUrl(app)}
|
||||
>
|
||||
<div class="preview" use:gradient={{ seed: app.name }} />
|
||||
<div class="app-info">
|
||||
<Heading size="XS">{app.name}</Heading>
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
import GroupIcon from "./_components/GroupIcon.svelte"
|
||||
import GroupUsers from "./_components/GroupUsers.svelte"
|
||||
import { sdk } from "@budibase/shared-core"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
|
||||
export let groupId
|
||||
|
||||
|
@ -45,7 +46,7 @@
|
|||
|
||||
let loaded = false
|
||||
let editModal, deleteModal
|
||||
|
||||
$: console.log(group)
|
||||
$: scimEnabled = $features.isScimEnabled
|
||||
$: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled
|
||||
$: group = $groups.find(x => x._id === groupId)
|
||||
|
@ -57,8 +58,11 @@
|
|||
)
|
||||
.map(app => ({
|
||||
...app,
|
||||
role: group?.roles?.[apps.getProdAppID(app.devId)],
|
||||
role: group?.builder?.apps.includes(apps.getProdAppID(app.devId))
|
||||
? Constants.Roles.CREATOR
|
||||
: group?.roles?.[apps.getProdAppID(app.devId)],
|
||||
}))
|
||||
$: console.log(groupApps)
|
||||
$: {
|
||||
if (loaded && !group?._id) {
|
||||
$goto("./")
|
||||
|
|
|
@ -1,9 +1,19 @@
|
|||
<script>
|
||||
import { Icon } from "@budibase/bbui"
|
||||
import { sdk } from "@budibase/shared-core"
|
||||
|
||||
export let value
|
||||
export let row
|
||||
$: count = getCount(Object.keys(value || {}).length)
|
||||
|
||||
$: count = Object.keys(value || {}).length
|
||||
const getCount = () => {
|
||||
return sdk.users.hasAppBuilderPermissions(row)
|
||||
? row.builder.apps.length +
|
||||
Object.keys(row.roles || {}).filter(appId =>
|
||||
row.builder.apps.includes(appId)
|
||||
).length
|
||||
: value?.length || 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="align">
|
||||
|
|
|
@ -89,7 +89,7 @@
|
|||
$: scimEnabled = $features.isScimEnabled
|
||||
$: isSSO = !!user?.provider
|
||||
$: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled
|
||||
$: privileged = sdk.users.isAdminOrBuilder(user)
|
||||
$: privileged = sdk.users.isAdminOrGlobalBuilder(user)
|
||||
$: nameLabel = getNameLabel(user)
|
||||
$: filteredGroups = getFilteredGroups($groups, searchTerm)
|
||||
$: availableApps = getAvailableApps($apps, privileged, user?.roles)
|
||||
|
@ -98,17 +98,14 @@
|
|||
return y._id === userId
|
||||
})
|
||||
})
|
||||
$: globalRole = sdk.users.isAdmin(user)
|
||||
? "admin"
|
||||
: sdk.users.isBuilder(user)
|
||||
? "developer"
|
||||
: "appUser"
|
||||
$: globalRole = sdk.users.isAdmin(user) ? "admin" : "appUser"
|
||||
|
||||
const getAvailableApps = (appList, privileged, roles) => {
|
||||
let availableApps = appList.slice()
|
||||
if (!privileged) {
|
||||
availableApps = availableApps.filter(x => {
|
||||
return Object.keys(roles || {}).find(y => {
|
||||
let roleKeys = Object.keys(roles || {})
|
||||
return roleKeys.concat(user?.builder?.apps).find(y => {
|
||||
return x.appId === apps.extractAppId(y)
|
||||
})
|
||||
})
|
||||
|
@ -119,7 +116,7 @@
|
|||
name: app.name,
|
||||
devId: app.devId,
|
||||
icon: app.icon,
|
||||
role: privileged ? Constants.Roles.ADMIN : roles[prodAppId],
|
||||
role: getRole(prodAppId, roles),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -132,6 +129,18 @@
|
|||
return groups.filter(group => group.name?.toLowerCase().includes(search))
|
||||
}
|
||||
|
||||
const getRole = (prodAppId, roles) => {
|
||||
if (privileged) {
|
||||
return Constants.Roles.ADMIN
|
||||
}
|
||||
|
||||
if (user?.builder?.apps?.includes(prodAppId)) {
|
||||
return Constants.Roles.CREATOR
|
||||
}
|
||||
|
||||
return roles[prodAppId]
|
||||
}
|
||||
|
||||
const getNameLabel = user => {
|
||||
const { firstName, lastName, email } = user || {}
|
||||
if (!firstName && !lastName) {
|
||||
|
|
|
@ -2,12 +2,16 @@
|
|||
import { StatusLight } from "@budibase/bbui"
|
||||
import { RoleUtils } from "@budibase/frontend-core"
|
||||
import { roles } from "stores/backend"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
import { capitalise } from "helpers"
|
||||
|
||||
export let value
|
||||
|
||||
const getRoleLabel = roleId => {
|
||||
const role = $roles.find(x => x._id === roleId)
|
||||
return role?.name || "Custom role"
|
||||
return roleId === Constants.Roles.CREATOR
|
||||
? capitalise(Constants.Roles.CREATOR.toLowerCase())
|
||||
: role?.name || "Custom role"
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -5,9 +5,22 @@
|
|||
|
||||
export let value
|
||||
export let row
|
||||
|
||||
$: console.log(row)
|
||||
$: priviliged = sdk.users.isAdminOrBuilder(row)
|
||||
$: count = priviliged ? $apps.length : value?.length || 0
|
||||
$: count = getCount(row)
|
||||
|
||||
const getCount = () => {
|
||||
if (priviliged) {
|
||||
return $apps.length
|
||||
} else {
|
||||
return sdk.users.hasAppBuilderPermissions(row)
|
||||
? row.builder.apps.length +
|
||||
Object.keys(row.roles || {}).filter(appId =>
|
||||
row.builder.apps.includes(appId)
|
||||
).length
|
||||
: value?.length || 0
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="align">
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
export let row
|
||||
|
||||
const TooltipMap = {
|
||||
appUser: "Only has access to published apps",
|
||||
appUser: "Only has access to assigned apps",
|
||||
developer: "Access to the app builder",
|
||||
admin: "Full access",
|
||||
}
|
||||
|
|
|
@ -78,7 +78,19 @@ export function createGroupsStore() {
|
|||
},
|
||||
|
||||
getGroupAppIds: group => {
|
||||
return Object.keys(group?.roles || {})
|
||||
let groupAppIds = Object.keys(group?.roles || {})
|
||||
if (group?.builder?.apps) {
|
||||
groupAppIds = groupAppIds.concat(group.builder.apps)
|
||||
}
|
||||
return groupAppIds
|
||||
},
|
||||
|
||||
addGroupAppBuilder: async (groupId, appId) => {
|
||||
return await API.addGroupAppBuilder({ groupId, appId })
|
||||
},
|
||||
|
||||
removeGroupAppBuilder: async (groupId, appId) => {
|
||||
return await API.removeGroupAppBuilder({ groupId, appId })
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -125,6 +125,10 @@ export const createLicensingStore = () => {
|
|||
const syncAutomationsEnabled = license.features.includes(
|
||||
Constants.Features.SYNC_AUTOMATIONS
|
||||
)
|
||||
const perAppBuildersEnabled = license.features.includes(
|
||||
Constants.Features.APP_BUILDERS
|
||||
)
|
||||
|
||||
const isViewPermissionsEnabled = license.features.includes(
|
||||
Constants.Features.VIEW_PERMISSIONS
|
||||
)
|
||||
|
@ -144,6 +148,7 @@ export const createLicensingStore = () => {
|
|||
enforceableSSO,
|
||||
syncAutomationsEnabled,
|
||||
isViewPermissionsEnabled,
|
||||
perAppBuildersEnabled,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
|
|
@ -112,12 +112,16 @@ export function createUsersStore() {
|
|||
return await API.saveUser(user)
|
||||
}
|
||||
|
||||
async function addAppBuilder(userId, appId) {
|
||||
return await API.addAppBuilder({ userId, appId })
|
||||
}
|
||||
|
||||
async function removeAppBuilder(userId, appId) {
|
||||
return await API.removeAppBuilder({ userId, appId })
|
||||
}
|
||||
|
||||
const getUserRole = user =>
|
||||
sdk.users.isAdmin(user)
|
||||
? "admin"
|
||||
: sdk.users.isBuilder(user)
|
||||
? "developer"
|
||||
: "appUser"
|
||||
sdk.users.isAdminOrGlobalBuilder(user) ? "admin" : "appUser"
|
||||
|
||||
const refreshUsage =
|
||||
fn =>
|
||||
|
@ -139,6 +143,8 @@ export function createUsersStore() {
|
|||
getInvites,
|
||||
updateInvite,
|
||||
getUserCountByApp,
|
||||
addAppBuilder,
|
||||
removeAppBuilder,
|
||||
// any operation that adds or deletes users
|
||||
acceptInvite,
|
||||
create: refreshUsage(create),
|
||||
|
|
|
@ -104,5 +104,27 @@ export const buildGroupsEndpoints = API => {
|
|||
removeAppsFromGroup: async (groupId, appArray) => {
|
||||
return updateGroupResource(groupId, "apps", "remove", appArray)
|
||||
},
|
||||
|
||||
/**
|
||||
* Add app builder to group
|
||||
* @param groupId The group to update
|
||||
* @param appId The app id where the builder will be added
|
||||
*/
|
||||
addGroupAppBuilder: async ({ groupId, appId }) => {
|
||||
return await API.post({
|
||||
url: `/api/global/groups/${groupId}/app/${appId}/builder`,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove app builder from group
|
||||
* @param groupId The group to update
|
||||
* @param appId The app id where the builder will be removed
|
||||
*/
|
||||
removeGroupAppBuilder: async ({ groupId, appId }) => {
|
||||
return await API.delete({
|
||||
url: `/api/global/groups/${groupId}/app/${appId}/builder`,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -156,13 +156,14 @@ export const buildUserEndpoints = API => ({
|
|||
return await API.post({
|
||||
url: "/api/global/users/onboard",
|
||||
body: payload.map(invite => {
|
||||
const { email, admin, builder, apps } = invite
|
||||
const { email, admin, builder, apps, appBuilders } = invite
|
||||
return {
|
||||
email,
|
||||
userInfo: {
|
||||
admin: admin ? { global: true } : undefined,
|
||||
builder: builder ? { global: true } : undefined,
|
||||
apps: apps ? apps : undefined,
|
||||
appBuilders,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
@ -175,10 +176,12 @@ export const buildUserEndpoints = API => ({
|
|||
* @param invite the invite code sent in the email
|
||||
*/
|
||||
updateUserInvite: async invite => {
|
||||
console.log(invite)
|
||||
await API.post({
|
||||
url: `/api/global/users/invite/update/${invite.code}`,
|
||||
body: {
|
||||
apps: invite.apps,
|
||||
appBuilders: invite.appBuilders,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
@ -250,4 +253,26 @@ export const buildUserEndpoints = API => ({
|
|||
url: `/api/global/users/count/${appId}`,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds a per app builder to the selected app
|
||||
* @param appId the applications id
|
||||
* @param userId The id of the user to add as a builder
|
||||
*/
|
||||
addAppBuilder: async ({ userId, appId }) => {
|
||||
return await API.post({
|
||||
url: `/api/global/users/${userId}/app/${appId}/builder`,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes a per app builder to the selected app
|
||||
* @param appId the applications id
|
||||
* @param userId The id of the user to remove as a builder
|
||||
*/
|
||||
removeAppBuilder: async ({ userId, appId }) => {
|
||||
return await API.delete({
|
||||
url: `/api/global/users/${userId}/app/${appId}/builder`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
@ -24,11 +24,23 @@ export const BudibaseRoles = {
|
|||
}
|
||||
|
||||
export const BudibaseRoleOptions = [
|
||||
{ label: "App User", value: BudibaseRoles.AppUser },
|
||||
{ label: "Developer", value: BudibaseRoles.Developer },
|
||||
{ label: "Member", value: BudibaseRoles.AppUser },
|
||||
{ label: "Admin", value: BudibaseRoles.Admin },
|
||||
]
|
||||
|
||||
export const BudibaseRoleOptionsNew = [
|
||||
{
|
||||
label: "Admin",
|
||||
value: "admin",
|
||||
subtitle: "Has full access to all apps and settings in your account",
|
||||
},
|
||||
{
|
||||
label: "Member",
|
||||
value: "appUser",
|
||||
subtitle: "Can only view apps they have access to",
|
||||
},
|
||||
]
|
||||
|
||||
export const BuilderRoleDescriptions = [
|
||||
{
|
||||
value: BudibaseRoles.AppUser,
|
||||
|
@ -70,6 +82,7 @@ export const Roles = {
|
|||
BASIC: "BASIC",
|
||||
PUBLIC: "PUBLIC",
|
||||
BUILDER: "BUILDER",
|
||||
CREATOR: "CREATOR",
|
||||
}
|
||||
|
||||
export const Themes = [
|
||||
|
|
|
@ -1,20 +1,22 @@
|
|||
import { Roles } from "../constants"
|
||||
|
||||
const RolePriorities = {
|
||||
[Roles.ADMIN]: 4,
|
||||
[Roles.ADMIN]: 5,
|
||||
[Roles.CREATOR]: 4,
|
||||
[Roles.POWER]: 3,
|
||||
[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 = roleId => {
|
||||
return RolePriorities[roleId] ?? 0
|
||||
export const getRolePriority = role => {
|
||||
return RolePriorities[role] ?? 0
|
||||
}
|
||||
|
||||
export const getRoleColour = roleId => {
|
||||
|
|
|
@ -35,6 +35,13 @@ export function isAdminOrBuilder(
|
|||
return isBuilder(user, appId) || isAdmin(user)
|
||||
}
|
||||
|
||||
export function isAdminOrGlobalBuilder(
|
||||
user: User | ContextUser,
|
||||
appId?: string
|
||||
): boolean {
|
||||
return isGlobalBuilder(user) || isAdmin(user)
|
||||
}
|
||||
|
||||
// check if they are a builder within an app (not necessarily a global builder)
|
||||
export function hasAppBuilderPermissions(user?: User | ContextUser): boolean {
|
||||
if (!user) {
|
||||
|
|
|
@ -266,14 +266,17 @@ export const onboardUsers = async (ctx: Ctx<InviteUsersRequest>) => {
|
|||
|
||||
// Temp password to be passed to the user.
|
||||
createdPasswords[invite.email] = password
|
||||
|
||||
let builder: { global: boolean; apps?: string[] } = { global: false }
|
||||
if (invite.userInfo.appBuilders) {
|
||||
builder.apps = invite.userInfo.appBuilders
|
||||
}
|
||||
return {
|
||||
email: invite.email,
|
||||
password,
|
||||
forceResetPassword: true,
|
||||
roles: invite.userInfo.apps,
|
||||
admin: { global: false },
|
||||
builder: { global: false },
|
||||
builder,
|
||||
tenantId: tenancy.getTenantId(),
|
||||
}
|
||||
})
|
||||
|
@ -368,6 +371,15 @@ export const updateInvite = async (ctx: any) => {
|
|||
...invite,
|
||||
}
|
||||
|
||||
if (!updateBody?.appBuilders || !updateBody.appBuilders?.length) {
|
||||
updated.info.appBuilders = []
|
||||
} else {
|
||||
updated.info.appBuilders = [
|
||||
...(invite.info.appBuilders ?? []),
|
||||
...updateBody.appBuilders,
|
||||
]
|
||||
}
|
||||
|
||||
if (!updateBody?.apps || !Object.keys(updateBody?.apps).length) {
|
||||
updated.info.apps = []
|
||||
} else {
|
||||
|
@ -392,7 +404,7 @@ export const inviteAccept = async (
|
|||
// info is an extension of the user object that was stored by global
|
||||
const { email, info }: any = await checkInviteCode(inviteCode)
|
||||
const user = await tenancy.doInTenant(info.tenantId, async () => {
|
||||
let request = {
|
||||
let request: any = {
|
||||
firstName,
|
||||
lastName,
|
||||
password,
|
||||
|
@ -400,9 +412,14 @@ export const inviteAccept = async (
|
|||
roles: info.apps,
|
||||
tenantId: info.tenantId,
|
||||
}
|
||||
let builder: { global: boolean; apps?: string[] } = { global: false }
|
||||
|
||||
if (info.appBuilders) {
|
||||
builder.apps = info.appBuilders
|
||||
request.builder = builder
|
||||
delete info.appBuilders
|
||||
}
|
||||
delete info.apps
|
||||
|
||||
request = {
|
||||
...request,
|
||||
...info,
|
||||
|
|
Loading…
Reference in New Issue