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 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>

View File

@ -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>

View File

@ -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}

View File

@ -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
/>

View File

@ -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,7 +93,29 @@
}
</script>
<Select
{#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}
@ -77,6 +128,10 @@
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 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])

View File

@ -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) {
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 = Object.keys(user.roles).find(x => x === prodAppId)
const appRole = user.roles[prodAppId]
if (appRole) {
role = user.roles[appRole]
role = appRole
}
}
return {
...user,
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)
@ -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,12 +288,15 @@
return nameMatch
})
.map(enrichGroupRole)
.sort(sortRoles)
}
const enrichGroupRole = group => {
return {
...group,
role: group.roles?.[
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,7 +519,19 @@
}}
>
<div class="builder-side-panel-header">
<Heading size="S">Users</Heading>
<div
on:click={() => {
invitingFlow = false
}}
class="header"
>
{#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"
@ -436,6 +544,8 @@
}}
/>
</div>
</div>
{#if !invitingFlow}
<div class="search" class:focused={searchFocus}>
<span class="search-input">
<Input
@ -455,15 +565,11 @@
class="search-input-icon"
class:searching={query || !filterByAppAccess}
on:click={() => {
if (!filterByAppAccess) {
filterByAppAccess = true
}
if (!query) {
return
}
query = null
userOnboardResponse = null
filterByAppAccess = true
}}
>
<Icon name={!filterByAppAccess || query ? "Close" : "Search"} />
@ -476,20 +582,11 @@
<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}
Try searching a different email or <span
class="underlined"
on:click={openInviteFlow}>invite a new user</span
>
Add user
</ActionButton>
</div>
</div>
</Layout>
{/if}
@ -512,9 +609,12 @@
<div class="auth-entity-access">
<RoleSelect
placeholder={false}
value={invite.info.apps?.[prodAppId]}
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)
@ -567,8 +667,13 @@
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)
@ -594,9 +699,6 @@
<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
@ -605,16 +707,22 @@
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.isAdminOrBuilder
allowedRoles={user.isAdminOrGlobalBuilder
? [Constants.Roles.ADMIN]
: null}
/>
@ -631,8 +739,8 @@
<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.
Email invites are not available without SMTP configuration. Here
is the password that has been generated for this user.
</div>
</div>
<div>
@ -644,6 +752,67 @@
</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>
</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;

View File

@ -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>

View File

@ -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("./")

View File

@ -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">

View File

@ -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) {

View File

@ -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>

View File

@ -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">

View File

@ -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",
}

View File

@ -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 })
},
}

View File

@ -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,
}
})
},

View File

@ -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),

View File

@ -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`,
})
},
}
}

View File

@ -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`,
})
},
})

View File

@ -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 = [

View File

@ -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 => {

View File

@ -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) {

View File

@ -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,