Merge pull request #11596 from Budibase/feat/per-app-builder-fe

Per App Builder Frontend
This commit is contained in:
Peter Clement 2023-08-31 11:16:16 +01:00 committed by GitHub
commit 28f9664297
23 changed files with 825 additions and 378 deletions

View File

@ -2,8 +2,9 @@
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import FancyField from "./FancyField.svelte" import FancyField from "./FancyField.svelte"
import Icon from "../Icon/Icon.svelte" import Icon from "../Icon/Icon.svelte"
import Popover from "../Popover/Popover.svelte"
import FancyFieldLabel from "./FancyFieldLabel.svelte" import FancyFieldLabel from "./FancyFieldLabel.svelte"
import StatusLight from "../StatusLight/StatusLight.svelte"
import Picker from "../Form/Core/Picker.svelte"
export let label export let label
export let value export let value
@ -11,18 +12,30 @@
export let error = null export let error = null
export let validate = null export let validate = null
export let options = [] export let options = []
export let isOptionEnabled = () => true
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
export let getOptionSubtitle = option => extractProperty(option, "subtitle")
export let getOptionColour = () => null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let open = false let open = false
let popover
let wrapper let wrapper
$: placeholder = !value $: placeholder = !value
$: selectedLabel = getSelectedLabel(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) => { const extractProperty = (value, property) => {
if (value && typeof value === "object") { if (value && typeof value === "object") {
return value[property] return value[property]
@ -64,46 +77,45 @@
<FancyFieldLabel {placeholder}>{label}</FancyFieldLabel> <FancyFieldLabel {placeholder}>{label}</FancyFieldLabel>
{/if} {/if}
{#if fieldColour}
<span class="align">
<StatusLight square color={fieldColour} />
</span>
{/if}
<div class="value" class:placeholder> <div class="value" class:placeholder>
{selectedLabel || ""} {selectedLabel || ""}
</div> </div>
<div class="arrow"> <div class="align arrow-alignment">
<Icon name="ChevronDown" /> <Icon name="ChevronDown" />
</div> </div>
</FancyField> </FancyField>
<Popover <div id="picker-wrapper">
anchor={wrapper} <Picker
align="left" customAnchor={wrapper}
portalTarget={document.documentElement} onlyPopover={true}
bind:this={popover} bind:open
{open} {error}
on:close={() => (open = false)} {disabled}
useAnchorWidth={true} {options}
maxWidth={null} {getOptionLabel}
> {getOptionValue}
<div class="popover-content"> {getOptionSubtitle}
{#if options.length} {getOptionColour}
{#each options as option, idx} {isOptionEnabled}
<div isPlaceholder={value == null || value === ""}
class="popover-option" placeholderOption={placeholder === false ? null : placeholder}
tabindex="0" onSelectOption={onChange}
on:click={() => onChange(getOptionValue(option, idx))} isOptionSelected={option => option === value}
> />
<span class="option-text"> </div>
{getOptionLabel(option, idx)}
</span>
{#if value === getOptionValue(option, idx)}
<Icon name="Checkmark" />
{/if}
</div>
{/each}
{/if}
</div>
</Popover>
<style> <style>
#picker-wrapper :global(.spectrum-Picker) {
display: none;
}
.value { .value {
display: block; display: block;
flex: 1 1 auto; flex: 1 1 auto;
@ -118,30 +130,23 @@
width: 0; width: 0;
transform: translateY(9px); 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 { .value.placeholder {
transform: translateY(0); transform: translateY(0);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
margin-top: 0; 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> </style>

View File

@ -8,6 +8,8 @@
import Icon from "../../Icon/Icon.svelte" import Icon from "../../Icon/Icon.svelte"
import StatusLight from "../../StatusLight/StatusLight.svelte" import StatusLight from "../../StatusLight/StatusLight.svelte"
import Popover from "../../Popover/Popover.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 id = null
export let disabled = false export let disabled = false
@ -26,6 +28,7 @@
export let getOptionIcon = () => null export let getOptionIcon = () => null
export let useOptionIconImage = false export let useOptionIconImage = false
export let getOptionColour = () => null export let getOptionColour = () => null
export let getOptionSubtitle = () => null
export let open = false export let open = false
export let readonly = false export let readonly = false
export let quiet = false export let quiet = false
@ -37,7 +40,7 @@
export let customPopoverHeight export let customPopoverHeight
export let align = "left" export let align = "left"
export let footer = null export let footer = null
export let customAnchor = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let searchTerm = null let searchTerm = null
@ -99,7 +102,7 @@
bind:this={button} bind:this={button}
> >
{#if fieldIcon} {#if fieldIcon}
{#if !useOptionIconImage} {#if !useOptionIconImage}x
<span class="option-extra icon"> <span class="option-extra icon">
<Icon size="S" name={fieldIcon} /> <Icon size="S" name={fieldIcon} />
</span> </span>
@ -139,9 +142,8 @@
<use xlink:href="#spectrum-css-icon-Chevron100" /> <use xlink:href="#spectrum-css-icon-Chevron100" />
</svg> </svg>
</button> </button>
<Popover <Popover
anchor={button} anchor={customAnchor ? customAnchor : button}
align={align || "left"} align={align || "left"}
bind:this={popover} bind:this={popover}
{open} {open}
@ -215,8 +217,21 @@
</span> </span>
{/if} {/if}
<span class="spectrum-Menu-itemLabel"> <span class="spectrum-Menu-itemLabel">
{#if getOptionSubtitle(option, idx)}
<span class="subtitle-text"
>{getOptionSubtitle(option, idx)}</span
>
{/if}
{getOptionLabel(option, idx)} {getOptionLabel(option, idx)}
</span> </span>
{#if option.tag}
<span class="option-tag">
<Tags>
<Tag icon="LockClosed">{option.tag}</Tag>
</Tags>
</span>
{/if}
<svg <svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
focusable="false" focusable="false"
@ -242,6 +257,17 @@
width: 100%; width: 100%;
box-shadow: none; 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 { .spectrum-Picker-label.auto-width {
margin-right: var(--spacing-xs); margin-right: var(--spacing-xs);
} }
@ -321,4 +347,12 @@
.option-extra.icon.field-icon { .option-extra.icon.field-icon {
display: flex; 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> </style>

View File

@ -21,7 +21,7 @@
export let sort = false export let sort = false
export let align export let align
export let footer = null export let footer = null
export let tag = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let open = false let open = false
@ -83,6 +83,7 @@
{isOptionEnabled} {isOptionEnabled}
{autocomplete} {autocomplete}
{sort} {sort}
{tag}
isPlaceholder={value == null || value === ""} isPlaceholder={value == null || value === ""}
placeholderOption={placeholder === false ? null : placeholder} placeholderOption={placeholder === false ? null : placeholder}
isOptionSelected={option => option === value} isOptionSelected={option => option === value}

View File

@ -25,7 +25,7 @@
export let customPopoverHeight export let customPopoverHeight
export let align export let align
export let footer = null export let footer = null
export let tag = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
value = e.detail value = e.detail
@ -61,6 +61,7 @@
{isOptionEnabled} {isOptionEnabled}
{autocomplete} {autocomplete}
{customPopoverHeight} {customPopoverHeight}
{tag}
on:change={onChange} on:change={onChange}
on:click on:click
/> />

View File

@ -1,8 +1,11 @@
<script> <script>
import { Select } from "@budibase/bbui" import { Select, FancySelect } from "@budibase/bbui"
import { roles } from "stores/backend" import { roles } from "stores/backend"
import { licensing } from "stores/portal"
import { Constants, RoleUtils } from "@budibase/frontend-core" import { Constants, RoleUtils } from "@budibase/frontend-core"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { capitalise } from "helpers"
export let value export let value
export let error export let error
@ -15,17 +18,43 @@
export let align export let align
export let footer = null export let footer = null
export let allowedRoles = null export let allowedRoles = null
export let allowCreator = false
export let fancySelect = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const RemoveID = "remove" const RemoveID = "remove"
$: options = getOptions($roles, allowPublic, allowRemove, allowedRoles) $: options = getOptions(
$roles,
const getOptions = (roles, allowPublic, allowRemove, allowedRoles) => { allowPublic,
allowRemove,
allowedRoles,
allowCreator
)
const getOptions = (
roles,
allowPublic,
allowRemove,
allowedRoles,
allowCreator
) => {
if (allowedRoles?.length) { if (allowedRoles?.length) {
return roles.filter(role => allowedRoles.includes(role._id)) return roles.filter(role => allowedRoles.includes(role._id))
} }
let newRoles = [...roles] let newRoles = [...roles]
if (allowCreator) {
newRoles = [
{
_id: Constants.Roles.CREATOR,
name: "Creator",
tag:
!$licensing.perAppBuildersEnabled &&
capitalise(Constants.PlanType.BUSINESS),
},
...newRoles,
]
}
if (allowRemove) { if (allowRemove) {
newRoles = [ newRoles = [
...newRoles, ...newRoles,
@ -64,19 +93,45 @@
} }
</script> </script>
<Select {#if fancySelect}
{autoWidth} <FancySelect
{quiet} {autoWidth}
{disabled} {quiet}
{align} {disabled}
{footer} {align}
bind:value {footer}
on:change={onChange} bind:value
{options} on:change={onChange}
getOptionLabel={role => role.name} {options}
getOptionValue={role => role._id} label="Access on this app"
getOptionColour={getColor} getOptionLabel={role => role.name}
getOptionIcon={getIcon} getOptionValue={role => role._id}
{placeholder} getOptionColour={getColor}
{error} 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}

View File

@ -26,6 +26,9 @@ export const capitalise = s => {
export const lowercase = s => s.substring(0, 1).toLowerCase() + s.substring(1) 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_name = s => (!s ? "" : last(s.split("/")))
export const get_capitalised_name = name => pipe(name, [get_name, capitalise]) export const get_capitalised_name = name => pipe(name, [get_name, capitalise])

View File

@ -1,18 +1,27 @@
<script> <script>
import { import {
Icon, Icon,
Divider,
Heading, Heading,
Layout, Layout,
Input, Input,
clickOutside, clickOutside,
notifications, notifications,
ActionButton,
CopyInput, CopyInput,
Modal, Modal,
FancyForm,
FancyInput,
Button,
FancySelect,
} from "@budibase/bbui" } from "@budibase/bbui"
import { store } from "builderStore" import { store } from "builderStore"
import { groups, licensing, apps, users, auth, admin } from "stores/portal" 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 { sdk } from "@budibase/shared-core"
import { API } from "api" import { API } from "api"
import GroupIcon from "../../../portal/users/groups/_components/GroupIcon.svelte" import GroupIcon from "../../../portal/users/groups/_components/GroupIcon.svelte"
@ -26,10 +35,15 @@
let loaded = false let loaded = false
let inviting = false let inviting = false
let searchFocus = false let searchFocus = false
let invitingFlow = false
// Initially filter entities without app access // Initially filter entities without app access
// Show all when false // 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 appInvites = []
let filteredInvites = [] let filteredInvites = []
@ -40,8 +54,7 @@
let userLimitReachedModal let userLimitReachedModal
let inviteFailureResponse = "" let inviteFailureResponse = ""
$: validEmail = emailValidator(email) === true
$: queryIsEmail = emailValidator(query) === true
$: prodAppId = apps.getProdAppID($store.appId) $: prodAppId = apps.getProdAppID($store.appId)
$: promptInvite = showInvite( $: promptInvite = showInvite(
filteredInvites, filteredInvites,
@ -50,7 +63,6 @@
query query
) )
$: isOwner = $auth.accountPortalAccess && $admin.cloud $: isOwner = $auth.accountPortalAccess && $admin.cloud
const showInvite = (invites, users, groups, query) => { const showInvite = (invites, users, groups, query) => {
return !invites?.length && !users?.length && !groups?.length && query return !invites?.length && !users?.length && !groups?.length && query
} }
@ -66,9 +78,9 @@
if (!filterByAppAccess && !query) { if (!filterByAppAccess && !query) {
filteredInvites = filteredInvites =
appInvites.length > 100 ? appInvites.slice(0, 100) : [...appInvites] appInvites.length > 100 ? appInvites.slice(0, 100) : [...appInvites]
filteredInvites.sort(sortInviteRoles)
return return
} }
filteredInvites = appInvites.filter(invite => { filteredInvites = appInvites.filter(invite => {
const inviteInfo = invite.info?.apps const inviteInfo = invite.info?.apps
if (!query && inviteInfo && prodAppId) { if (!query && inviteInfo && prodAppId) {
@ -76,8 +88,8 @@
} }
return invite.email.includes(query) return invite.email.includes(query)
}) })
filteredInvites.sort(sortInviteRoles)
} }
$: filterByAppAccess, prodAppId, filterInvites(query) $: filterByAppAccess, prodAppId, filterInvites(query)
$: if (searchFocus === true) { $: if (searchFocus === true) {
filterByAppAccess = false filterByAppAccess = false
@ -107,24 +119,66 @@
}) })
await usersFetch.refresh() await usersFetch.refresh()
filteredUsers = $usersFetch.rows.map(user => { filteredUsers = $usersFetch.rows
const isAdminOrBuilder = sdk.users.isAdminOrBuilder(user, prodAppId) .filter(user => !user?.admin?.global) // filter out global admins
let role = undefined .map(user => {
if (isAdminOrBuilder) { const isAdminOrGlobalBuilder = sdk.users.isAdminOrGlobalBuilder(
role = Constants.Roles.ADMIN user,
} else { prodAppId
const appRole = Object.keys(user.roles).find(x => x === prodAppId) )
if (appRole) { const isAppBuilder = sdk.users.hasAppBuilderPermissions(user, prodAppId)
role = user.roles[appRole] 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 { return {
...user, ...user,
role, role,
isAdminOrBuilder, 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) const debouncedUpdateFetch = Utils.debounce(searchUsers, 250)
@ -160,6 +214,12 @@
if (user.role === role) { if (user.role === role) {
return return
} }
if (user.isAppBuilder) {
await removeAppBuilder(user._id, prodAppId)
}
if (role === Constants.Roles.CREATOR) {
await removeAppBuilder(user._id, prodAppId)
}
await updateAppUser(user, role) await updateAppUser(user, role)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@ -189,6 +249,9 @@
return return
} }
try { try {
if (group?.builder?.apps.includes(prodAppId)) {
await removeGroupAppBuilder(group._id)
}
await updateAppGroup(group, role) await updateAppGroup(group, role)
} catch { } catch {
notifications.error("Group update failed") notifications.error("Group update failed")
@ -225,14 +288,17 @@
return nameMatch return nameMatch
}) })
.map(enrichGroupRole) .map(enrichGroupRole)
.sort(sortRoles)
} }
const enrichGroupRole = group => { const enrichGroupRole = group => {
return { return {
...group, ...group,
role: group.roles?.[ role: group?.builder?.apps.includes(prodAppId)
groups.actions.getGroupAppIds(group).find(x => x === prodAppId) ? Constants.Roles.CREATOR
], : group.roles?.[
groups.actions.getGroupAppIds(group).find(x => x === prodAppId)
],
} }
} }
@ -245,8 +311,7 @@
$: filteredGroups = searchGroups(enrichedGroups, query) $: filteredGroups = searchGroups(enrichedGroups, query)
$: groupUsers = buildGroupUsers(filteredGroups, filteredUsers) $: groupUsers = buildGroupUsers(filteredGroups, filteredUsers)
$: allUsers = [...filteredUsers, ...groupUsers] $: allUsers = [...filteredUsers, ...groupUsers]
/*
/*
Create pseudo users from the "users" attribute on app groups. Create pseudo users from the "users" attribute on app groups.
These users will appear muted in the UI and show the ROLE These users will appear muted in the UI and show the ROLE
inherited from their parent group. The users allow assigning of user inherited from their parent group. The users allow assigning of user
@ -291,21 +356,28 @@
} }
async function inviteUser() { async function inviteUser() {
if (!queryIsEmail) { if (!validEmail) {
notifications.error("Email is not valid") notifications.error("Email is not valid")
return return
} }
const newUserEmail = query + "" const newUserEmail = email + ""
inviting = true inviting = true
const payload = [ const payload = [
{ {
email: newUserEmail, email: newUserEmail,
builder: false, builder: !!creationRoleType === Constants.BudibaseRoles.Admin,
admin: false, admin: !!creationRoleType === Constants.BudibaseRoles.Admin,
apps: { [prodAppId]: Constants.Roles.BASIC },
}, },
] ]
if (creationAccessType === Constants.Roles.CREATOR) {
payload[0].appBuilders = [prodAppId]
} else {
payload[0].apps = {
[prodAppId]: creationAccessType,
}
}
let userInviteResponse let userInviteResponse
try { try {
userInviteResponse = await users.onboard(payload) userInviteResponse = await users.onboard(payload)
@ -317,16 +389,23 @@
return userInviteResponse return userInviteResponse
} }
const openInviteFlow = () => {
$licensing.userLimitReached
? userLimitReachedModal.show()
: (invitingFlow = true)
}
const onInviteUser = async () => { const onInviteUser = async () => {
form.validate()
userOnboardResponse = await inviteUser() userOnboardResponse = await inviteUser()
const originalQuery = query + "" const originalQuery = email + ""
query = null email = null
const newUser = userOnboardResponse?.successful.find( const newUser = userOnboardResponse?.successful.find(
user => user.email === originalQuery user => user.email === originalQuery
) )
if (newUser) { if (newUser) {
query = originalQuery email = originalQuery
notifications.success( notifications.success(
userOnboardResponse.created userOnboardResponse.created
? "User created successfully" ? "User created successfully"
@ -344,16 +423,27 @@
notifications.error(inviteFailureResponse) notifications.error(inviteFailureResponse)
} }
userOnboardResponse = null userOnboardResponse = null
invitingFlow = false
// trigger reload of the users
query = ""
} }
const onUpdateUserInvite = async (invite, role) => { const onUpdateUserInvite = async (invite, role) => {
await users.updateInvite({ let updateBody = {
code: invite.code, code: invite.code,
apps: { apps: {
...invite.apps, ...invite.apps,
[prodAppId]: role, [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) 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 => { const initSidePanel = async sidePaneOpen => {
if (sidePaneOpen === true) { if (sidePaneOpen === true) {
await groups.actions.init() await groups.actions.init()
@ -383,27 +489,17 @@
$: initSidePanel($store.builderSidePanel) $: initSidePanel($store.builderSidePanel)
function handleKeyDown(evt) { function handleKeyDown(evt) {
if (evt.key === "Enter" && queryIsEmail && !inviting) { if (evt.key === "Enter" && validEmail && !inviting) {
onInviteUser() 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 => { const getRoleFooter = user => {
if (user.group) { if (user.group) {
const role = $roles.find(role => role._id === user.role) const role = $roles.find(role => role._id === user.role)
return `This user has been given ${role?.name} access from the ${user.group} group` 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 "This user's role grants admin access to all apps"
} }
return null return null
@ -423,227 +519,300 @@
}} }}
> >
<div class="builder-side-panel-header"> <div class="builder-side-panel-header">
<Heading size="S">Users</Heading> <div
<Icon
color="var(--spectrum-global-color-gray-600)"
name="RailRightClose"
hoverable
on:click={() => { on:click={() => {
store.update(state => { invitingFlow = false
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
}} }}
class="header"
> >
<Icon name={!filterByAppAccess || query ? "Close" : "Search"} /> {#if invitingFlow}
</span> <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> </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"> <span
{#if promptInvite && !userOnboardResponse} class="search-input-icon"
<Layout gap="S" paddingX="XL"> class:searching={query || !filterByAppAccess}
<div class="invite-header"> on:click={() => {
<Heading size="XS">No user found</Heading> if (!query) {
<div class="invite-directions"> return
Add a valid email to invite a new user }
</div> query = null
</div> userOnboardResponse = null
<div class="invite-form"> }}
<span>{query || ""}</span> >
<ActionButton <Icon name={!filterByAppAccess || query ? "Close" : "Search"} />
icon="UserAdd" </span>
disabled={!queryIsEmail || inviting} </div>
on:click={$licensing.userLimitReached
? userLimitReachedModal.show
: onInviteUser}
>
Add user
</ActionButton>
</div>
</Layout>
{/if}
{#if !promptInvite} <div class="body">
<Layout gap="L" noPadding> {#if promptInvite && !userOnboardResponse}
{#if filteredInvites?.length} <Layout gap="S" paddingX="XL">
<Layout noPadding gap="XS"> <div class="invite-header">
<div class="auth-entity-header"> <Heading size="XS">No user found</Heading>
<div class="auth-entity-title">Pending invites</div> <div class="invite-directions">
<div class="auth-entity-access-title">Access</div> Try searching a different email or <span
</div> class="underlined"
{#each filteredInvites as invite} on:click={openInviteFlow}>invite a new user</span
<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="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> </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> </div>
{/if} </Layout>
</Layout> {/if}
{/if}
{#if userOnboardResponse?.created} {#if !promptInvite}
<Layout gap="S" paddingX="XL"> <Layout gap="L" noPadding>
<div class="invite-header"> {#if filteredInvites?.length}
<Heading size="XS">User added!</Heading> <Layout noPadding gap="XS">
<div class="invite-directions"> <div class="auth-entity-header">
Email invites are not available without SMTP configuration. Here is <div class="auth-entity-title">Pending invites</div>
the password that has been generated for this user. <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> <div>
<div> <CopyInput
<CopyInput value={userOnboardResponse.successful[0]?.password}
value={userOnboardResponse.successful[0]?.password} label="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> </div>
</Layout> </Layout>
{/if} </div>
</div> {/if}
<Modal bind:this={userLimitReachedModal}> <Modal bind:this={userLimitReachedModal}>
<UpgradeModal {isOwner} /> <UpgradeModal {isOwner} />
</Modal> </Modal>
@ -659,6 +828,27 @@
align-items: center; 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 { .search-input {
flex: 1; flex: 1;
} }
@ -746,12 +936,6 @@
box-sizing: border-box; box-sizing: border-box;
} }
.invite-form {
display: flex;
align-items: center;
justify-content: space-between;
}
#builder-side-panel-container .search { #builder-side-panel-container .search {
padding-top: var(--spacing-m); padding-top: var(--spacing-m);
padding-bottom: var(--spacing-m); padding-bottom: var(--spacing-m);
@ -798,6 +982,16 @@
flex-direction: column; 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 { .body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -39,7 +39,7 @@
return publishedApps return publishedApps
} }
return publishedApps.filter(app => { return publishedApps.filter(app => {
if (sdk.users.isBuilder(user, app.appId)) { if (sdk.users.isBuilder(user, app.prodId)) {
return true return true
} }
if (!Object.keys(user?.roles).length && user?.userGroups) { if (!Object.keys(user?.roles).length && user?.userGroups) {
@ -142,7 +142,12 @@
<div class="group"> <div class="group">
<Layout gap="S" noPadding> <Layout gap="S" noPadding>
{#each userApps as app (app.appId)} {#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="preview" use:gradient={{ seed: app.name }} />
<div class="app-info"> <div class="app-info">
<Heading size="XS">{app.name}</Heading> <Heading size="XS">{app.name}</Heading>

View File

@ -21,6 +21,7 @@
import GroupIcon from "./_components/GroupIcon.svelte" import GroupIcon from "./_components/GroupIcon.svelte"
import GroupUsers from "./_components/GroupUsers.svelte" import GroupUsers from "./_components/GroupUsers.svelte"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { Constants } from "@budibase/frontend-core"
export let groupId export let groupId
@ -45,7 +46,7 @@
let loaded = false let loaded = false
let editModal, deleteModal let editModal, deleteModal
$: console.log(group)
$: scimEnabled = $features.isScimEnabled $: scimEnabled = $features.isScimEnabled
$: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled $: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled
$: group = $groups.find(x => x._id === groupId) $: group = $groups.find(x => x._id === groupId)
@ -57,8 +58,11 @@
) )
.map(app => ({ .map(app => ({
...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) { if (loaded && !group?._id) {
$goto("./") $goto("./")

View File

@ -1,9 +1,19 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { sdk } from "@budibase/shared-core"
export let value 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> </script>
<div class="align"> <div class="align">

View File

@ -89,7 +89,7 @@
$: scimEnabled = $features.isScimEnabled $: scimEnabled = $features.isScimEnabled
$: isSSO = !!user?.provider $: isSSO = !!user?.provider
$: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled $: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled
$: privileged = sdk.users.isAdminOrBuilder(user) $: privileged = sdk.users.isAdminOrGlobalBuilder(user)
$: nameLabel = getNameLabel(user) $: nameLabel = getNameLabel(user)
$: filteredGroups = getFilteredGroups($groups, searchTerm) $: filteredGroups = getFilteredGroups($groups, searchTerm)
$: availableApps = getAvailableApps($apps, privileged, user?.roles) $: availableApps = getAvailableApps($apps, privileged, user?.roles)
@ -98,17 +98,14 @@
return y._id === userId return y._id === userId
}) })
}) })
$: globalRole = sdk.users.isAdmin(user) $: globalRole = sdk.users.isAdmin(user) ? "admin" : "appUser"
? "admin"
: sdk.users.isBuilder(user)
? "developer"
: "appUser"
const getAvailableApps = (appList, privileged, roles) => { const getAvailableApps = (appList, privileged, roles) => {
let availableApps = appList.slice() let availableApps = appList.slice()
if (!privileged) { if (!privileged) {
availableApps = availableApps.filter(x => { 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) return x.appId === apps.extractAppId(y)
}) })
}) })
@ -119,7 +116,7 @@
name: app.name, name: app.name,
devId: app.devId, devId: app.devId,
icon: app.icon, 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)) 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 getNameLabel = user => {
const { firstName, lastName, email } = user || {} const { firstName, lastName, email } = user || {}
if (!firstName && !lastName) { if (!firstName && !lastName) {

View File

@ -2,12 +2,16 @@
import { StatusLight } from "@budibase/bbui" import { StatusLight } from "@budibase/bbui"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
import { roles } from "stores/backend" import { roles } from "stores/backend"
import { Constants } from "@budibase/frontend-core"
import { capitalise } from "helpers"
export let value export let value
const getRoleLabel = roleId => { const getRoleLabel = roleId => {
const role = $roles.find(x => x._id === 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> </script>

View File

@ -5,9 +5,22 @@
export let value export let value
export let row export let row
$: console.log(row)
$: priviliged = sdk.users.isAdminOrBuilder(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> </script>
<div class="align"> <div class="align">

View File

@ -5,7 +5,7 @@
export let row export let row
const TooltipMap = { const TooltipMap = {
appUser: "Only has access to published apps", appUser: "Only has access to assigned apps",
developer: "Access to the app builder", developer: "Access to the app builder",
admin: "Full access", admin: "Full access",
} }

View File

@ -78,7 +78,19 @@ export function createGroupsStore() {
}, },
getGroupAppIds: group => { 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 })
}, },
} }

View File

@ -125,6 +125,10 @@ export const createLicensingStore = () => {
const syncAutomationsEnabled = license.features.includes( const syncAutomationsEnabled = license.features.includes(
Constants.Features.SYNC_AUTOMATIONS Constants.Features.SYNC_AUTOMATIONS
) )
const perAppBuildersEnabled = license.features.includes(
Constants.Features.APP_BUILDERS
)
const isViewPermissionsEnabled = license.features.includes( const isViewPermissionsEnabled = license.features.includes(
Constants.Features.VIEW_PERMISSIONS Constants.Features.VIEW_PERMISSIONS
) )
@ -144,6 +148,7 @@ export const createLicensingStore = () => {
enforceableSSO, enforceableSSO,
syncAutomationsEnabled, syncAutomationsEnabled,
isViewPermissionsEnabled, isViewPermissionsEnabled,
perAppBuildersEnabled,
} }
}) })
}, },

View File

@ -112,12 +112,16 @@ export function createUsersStore() {
return await API.saveUser(user) 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 => const getUserRole = user =>
sdk.users.isAdmin(user) sdk.users.isAdminOrGlobalBuilder(user) ? "admin" : "appUser"
? "admin"
: sdk.users.isBuilder(user)
? "developer"
: "appUser"
const refreshUsage = const refreshUsage =
fn => fn =>
@ -139,6 +143,8 @@ export function createUsersStore() {
getInvites, getInvites,
updateInvite, updateInvite,
getUserCountByApp, getUserCountByApp,
addAppBuilder,
removeAppBuilder,
// any operation that adds or deletes users // any operation that adds or deletes users
acceptInvite, acceptInvite,
create: refreshUsage(create), create: refreshUsage(create),

View File

@ -104,5 +104,27 @@ export const buildGroupsEndpoints = API => {
removeAppsFromGroup: async (groupId, appArray) => { removeAppsFromGroup: async (groupId, appArray) => {
return updateGroupResource(groupId, "apps", "remove", 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`,
})
},
} }
} }

View File

@ -156,13 +156,14 @@ export const buildUserEndpoints = API => ({
return await API.post({ return await API.post({
url: "/api/global/users/onboard", url: "/api/global/users/onboard",
body: payload.map(invite => { body: payload.map(invite => {
const { email, admin, builder, apps } = invite const { email, admin, builder, apps, appBuilders } = invite
return { return {
email, email,
userInfo: { userInfo: {
admin: admin ? { global: true } : undefined, admin: admin ? { global: true } : undefined,
builder: builder ? { global: true } : undefined, builder: builder ? { global: true } : undefined,
apps: apps ? apps : undefined, apps: apps ? apps : undefined,
appBuilders,
}, },
} }
}), }),
@ -175,10 +176,12 @@ export const buildUserEndpoints = API => ({
* @param invite the invite code sent in the email * @param invite the invite code sent in the email
*/ */
updateUserInvite: async invite => { updateUserInvite: async invite => {
console.log(invite)
await API.post({ await API.post({
url: `/api/global/users/invite/update/${invite.code}`, url: `/api/global/users/invite/update/${invite.code}`,
body: { body: {
apps: invite.apps, apps: invite.apps,
appBuilders: invite.appBuilders,
}, },
}) })
}, },
@ -250,4 +253,26 @@ export const buildUserEndpoints = API => ({
url: `/api/global/users/count/${appId}`, 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`,
})
},
}) })

View File

@ -24,11 +24,23 @@ export const BudibaseRoles = {
} }
export const BudibaseRoleOptions = [ export const BudibaseRoleOptions = [
{ label: "App User", value: BudibaseRoles.AppUser }, { label: "Member", value: BudibaseRoles.AppUser },
{ label: "Developer", value: BudibaseRoles.Developer },
{ label: "Admin", value: BudibaseRoles.Admin }, { 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 = [ export const BuilderRoleDescriptions = [
{ {
value: BudibaseRoles.AppUser, value: BudibaseRoles.AppUser,
@ -70,6 +82,7 @@ export const Roles = {
BASIC: "BASIC", BASIC: "BASIC",
PUBLIC: "PUBLIC", PUBLIC: "PUBLIC",
BUILDER: "BUILDER", BUILDER: "BUILDER",
CREATOR: "CREATOR",
} }
export const Themes = [ export const Themes = [

View File

@ -1,20 +1,22 @@
import { Roles } from "../constants" import { Roles } from "../constants"
const RolePriorities = { const RolePriorities = {
[Roles.ADMIN]: 4, [Roles.ADMIN]: 5,
[Roles.CREATOR]: 4,
[Roles.POWER]: 3, [Roles.POWER]: 3,
[Roles.BASIC]: 2, [Roles.BASIC]: 2,
[Roles.PUBLIC]: 1, [Roles.PUBLIC]: 1,
} }
const RoleColours = { const RoleColours = {
[Roles.ADMIN]: "var(--spectrum-global-color-static-red-400)", [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.POWER]: "var(--spectrum-global-color-static-orange-400)",
[Roles.BASIC]: "var(--spectrum-global-color-static-green-400)", [Roles.BASIC]: "var(--spectrum-global-color-static-green-400)",
[Roles.PUBLIC]: "var(--spectrum-global-color-static-blue-400)", [Roles.PUBLIC]: "var(--spectrum-global-color-static-blue-400)",
} }
export const getRolePriority = roleId => { export const getRolePriority = role => {
return RolePriorities[roleId] ?? 0 return RolePriorities[role] ?? 0
} }
export const getRoleColour = roleId => { export const getRoleColour = roleId => {

View File

@ -35,6 +35,13 @@ export function isAdminOrBuilder(
return isBuilder(user, appId) || isAdmin(user) 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) // check if they are a builder within an app (not necessarily a global builder)
export function hasAppBuilderPermissions(user?: User | ContextUser): boolean { export function hasAppBuilderPermissions(user?: User | ContextUser): boolean {
if (!user) { if (!user) {

View File

@ -266,14 +266,17 @@ export const onboardUsers = async (ctx: Ctx<InviteUsersRequest>) => {
// Temp password to be passed to the user. // Temp password to be passed to the user.
createdPasswords[invite.email] = password createdPasswords[invite.email] = password
let builder: { global: boolean; apps?: string[] } = { global: false }
if (invite.userInfo.appBuilders) {
builder.apps = invite.userInfo.appBuilders
}
return { return {
email: invite.email, email: invite.email,
password, password,
forceResetPassword: true, forceResetPassword: true,
roles: invite.userInfo.apps, roles: invite.userInfo.apps,
admin: { global: false }, admin: { global: false },
builder: { global: false }, builder,
tenantId: tenancy.getTenantId(), tenantId: tenancy.getTenantId(),
} }
}) })
@ -368,6 +371,15 @@ export const updateInvite = async (ctx: any) => {
...invite, ...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) { if (!updateBody?.apps || !Object.keys(updateBody?.apps).length) {
updated.info.apps = [] updated.info.apps = []
} else { } else {
@ -392,7 +404,7 @@ export const inviteAccept = async (
// info is an extension of the user object that was stored by global // info is an extension of the user object that was stored by global
const { email, info }: any = await checkInviteCode(inviteCode) const { email, info }: any = await checkInviteCode(inviteCode)
const user = await tenancy.doInTenant(info.tenantId, async () => { const user = await tenancy.doInTenant(info.tenantId, async () => {
let request = { let request: any = {
firstName, firstName,
lastName, lastName,
password, password,
@ -400,9 +412,14 @@ export const inviteAccept = async (
roles: info.apps, roles: info.apps,
tenantId: info.tenantId, 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 delete info.apps
request = { request = {
...request, ...request,
...info, ...info,