builder side panel changes to support inviting creators

This commit is contained in:
Peter Clement 2023-08-29 14:41:56 +01:00
parent e88efe2d1a
commit 8b8bce186c
3 changed files with 395 additions and 241 deletions

View File

@ -1,5 +1,5 @@
<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 { licensing } from "stores/portal"
@ -18,6 +18,7 @@
export let footer = null export let footer = null
export let allowedRoles = null export let allowedRoles = null
export let allowCreator = false export let allowCreator = false
export let fancySelect = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const RemoveID = "remove" const RemoveID = "remove"
@ -47,7 +48,7 @@
{ {
_id: CreatorID, _id: CreatorID,
name: "Creator", name: "Creator",
tag: $licensing.perAppBuildersEnabled && null, tag: !$licensing.perAppBuildersEnabled && "Business",
}, },
...newRoles, ...newRoles,
] ]
@ -82,7 +83,6 @@
} }
const onChange = e => { const onChange = e => {
console.log(e.detail)
if (allowRemove && e.detail === RemoveID) { if (allowRemove && e.detail === RemoveID) {
dispatch("remove") dispatch("remove")
} else if (e.detail === CreatorID) { } else if (e.detail === CreatorID) {
@ -93,26 +93,53 @@
} }
</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}
isOptionEnabled={option => { getOptionColour={getColor}
if (option._id == CreatorID && !$licensing.perAppBuildersEnabled) { getOptionIcon={getIcon}
return false isOptionEnabled={option => {
} else { if (option._id == CreatorID && !$licensing.perAppBuildersEnabled) {
return true return false
} } else {
}} return true
{placeholder} }
{error} }}
/> {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 => {
if (option._id == CreatorID && !$licensing.perAppBuildersEnabled) {
return false
} else {
return true
}
}}
{placeholder}
{error}
/>
{/if}

View File

@ -1,6 +1,7 @@
<script> <script>
import { import {
Icon, Icon,
Divider,
Heading, Heading,
Layout, Layout,
Input, Input,
@ -9,6 +10,10 @@
ActionButton, 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"
@ -31,11 +36,17 @@
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 = false let filterByAppAccess = false
let email
let error
let form
let creationRoleType = "appUser"
let creationAccessType = "BASIC"
let appInvites = [] let appInvites = []
let filteredInvites = [] let filteredInvites = []
let filteredUsers = [] let filteredUsers = []
@ -54,7 +65,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
} }
@ -292,7 +302,6 @@
$: filteredGroups = searchGroups(enrichedGroups, query) $: filteredGroups = searchGroups(enrichedGroups, query)
$: groupUsers = buildGroupUsers(filteredGroups, filteredUsers) $: groupUsers = buildGroupUsers(filteredGroups, filteredUsers)
$: allUsers = [...filteredUsers, ...groupUsers] $: allUsers = [...filteredUsers, ...groupUsers]
$: console.log(filteredGroups)
/* /*
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
@ -348,11 +357,20 @@
const payload = [ const payload = [
{ {
email: newUserEmail, email: newUserEmail,
builder: false, builder:
admin: false, creationRoleType === Constants.BudibaseRoles.Admin ? true : false,
apps: { [prodAppId]: Constants.Roles.BASIC }, admin:
creationRoleType === Constants.BudibaseRoles.Admin ? true : false,
}, },
] ]
if (creationAccessType === Constants.Roles.CREATOR) {
payload[0].appBuilder = prodAppId
} else {
payload[0].apps = {
[prodAppId]: creationAccessType,
}
}
let userInviteResponse let userInviteResponse
try { try {
userInviteResponse = await users.onboard(payload) userInviteResponse = await users.onboard(payload)
@ -364,6 +382,10 @@
return userInviteResponse return userInviteResponse
} }
const openInviteFlow = () => {
invitingFlow = true
}
const onInviteUser = async () => { const onInviteUser = async () => {
userOnboardResponse = await inviteUser() userOnboardResponse = await inviteUser()
const originalQuery = query + "" const originalQuery = query + ""
@ -391,6 +413,7 @@
notifications.error(inviteFailureResponse) notifications.error(inviteFailureResponse)
} }
userOnboardResponse = null userOnboardResponse = null
invitingFlow = false
} }
const onUpdateUserInvite = async (invite, role) => { const onUpdateUserInvite = async (invite, role) => {
@ -476,7 +499,17 @@
}} }}
> >
<div class="builder-side-panel-header"> <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>
<Icon <Icon
color="var(--spectrum-global-color-gray-600)" color="var(--spectrum-global-color-gray-600)"
name="RailRightClose" name="RailRightClose"
@ -489,219 +522,280 @@
}} }}
/> />
</div> </div>
<div class="search" class:focused={searchFocus}> {#if !invitingFlow}
<span class="search-input"> <div class="search" class:focused={searchFocus}>
<Input <span class="search-input">
placeholder={"Add users and groups to your app"} <Input
autocomplete="off" placeholder={"Add users and groups to your app"}
disabled={inviting} autocomplete="off"
value={query} disabled={inviting}
on:input={e => { value={query}
query = e.target.value.trim() on:input={e => {
}} query = e.target.value.trim()
on:focus={() => (searchFocus = true)} }}
on:blur={() => (searchFocus = false)} on:focus={() => (searchFocus = true)}
/> on:blur={() => (searchFocus = false)}
</span> />
</span>
<span <span
class="search-input-icon" class="search-input-icon"
class:searching={query || !filterByAppAccess} class:searching={query || !filterByAppAccess}
on:click={() => { on:click={() => {
if (!filterByAppAccess) { if (!filterByAppAccess) {
filterByAppAccess = true
}
if (!query) {
return
}
query = null
userOnboardResponse = null
filterByAppAccess = true filterByAppAccess = true
} }}
if (!query) { >
return <Icon name={!filterByAppAccess || query ? "Close" : "Search"} />
} </span>
query = null </div>
userOnboardResponse = null
filterByAppAccess = true
}}
>
<Icon name={!filterByAppAccess || query ? "Close" : "Search"} />
</span>
</div>
<div class="body"> <div class="body">
{#if promptInvite && !userOnboardResponse} {#if promptInvite && !userOnboardResponse}
<Layout gap="S" paddingX="XL"> <Layout gap="S" paddingX="XL">
<div class="invite-header"> <div class="invite-header">
<Heading size="XS">No user found</Heading> <Heading size="XS">No user found</Heading>
<div class="invite-directions"> <div class="invite-directions">
Add a valid email to invite a new user Add a valid email to invite a new user
</div>
</div> </div>
</div> <div class="invite-form">
<div class="invite-form"> <span>{query || ""}</span>
<span>{query || ""}</span> <ActionButton
<ActionButton icon="UserAdd"
icon="UserAdd" disabled={!queryIsEmail || inviting}
disabled={!queryIsEmail || inviting} on:click={$licensing.userLimitReached
on:click={$licensing.userLimitReached ? userLimitReachedModal.show
? userLimitReachedModal.show : openInviteFlow}
: onInviteUser} >
> Add user
Add user </ActionButton>
</ActionButton> </div>
</div> </Layout>
</Layout> {/if}
{/if}
{#if !promptInvite} {#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}
allowCreator={true}
quiet={true}
on:addcreator={() => {
onUpdateUserInvite(invite, Constants.Roles.CREATOR)
}}
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 => {
onUpdateGroup(group, e.detail)
}}
on:addcreator={() => {
addGroupAppBuilder(group._id)
}}
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={() => {
addAppBuilder(user._id)
}}
on:change={e => {
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>
<CopyInput
value={userOnboardResponse.successful[0]?.password}
label="Password"
/>
</div>
</Layout>
{/if}
</div>
{:else}
<Divider />
<div class="body">
<Layout gap="L" noPadding> <Layout gap="L" noPadding>
{#if filteredInvites?.length} <div class="user-form">
<Layout noPadding gap="XS"> <FancyForm bind:this={form}>
<div class="auth-entity-header"> <FancyInput
<div class="auth-entity-title">Pending invites</div> disabled={false}
<div class="auth-entity-access-title">Access</div> label="Email"
value={query}
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 !== "ADMIN"}
<RoleSelect
placeholder={false}
bind:value={creationAccessType}
allowPublic={false}
allowCreator={true}
quiet={true}
autoWidth
align="right"
fancySelect
/>
{/if}
</FancyForm>
{#if creationRoleType === Constants.Roles.ADMIN}
<div class="admin-info">
<Icon name="Info" />
Admins will get full access to all apps and settings
</div> </div>
{#each filteredInvites as invite} {/if}
<div class="auth-entity"> <span class="add-user">
<div class="details"> <Button newStyles cta on:click={onInviteUser}>Add user</Button>
<div class="user-email" title={invite.email}> </span>
{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}
allowCreator={true}
on:change={e => {
onUpdateGroup(group, e.detail)
}}
on:addcreator={() => {
addGroupAppBuilder(group._id)
}}
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={() => {
addAppBuilder(user._id)
}}
on:change={e => {
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>
<CopyInput
value={userOnboardResponse.successful[0]?.password}
label="Password"
/>
</div> </div>
</Layout> </Layout>
{/if} </div>
</div> {/if}
<Modal bind:this={userLimitReachedModal}> <Modal bind:this={userLimitReachedModal}>
<UpgradeModal {isOwner} /> <UpgradeModal {isOwner} />
</Modal> </Modal>
@ -717,6 +811,22 @@
align-items: center; align-items: center;
} }
.add-user {
padding-top: var(--spacing-xl);
width: 100%;
display: grid;
}
.admin-info {
padding: var(--spacing-l) var(--spacing-l) var(--spacing-xs)
var(--spacing-l);
display: flex;
align-items: center;
gap: var(--spacing-xl);
height: 50px;
background-color: var(--background-alt);
}
.search-input { .search-input {
flex: 1; flex: 1;
} }
@ -856,6 +966,16 @@
flex-direction: column; flex-direction: column;
} }
.header {
display: flex;
align-items: center;
gap: var(--spacing-lf);
}
.user-form {
padding: var(--spacing-xl);
}
.body { .body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

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.appBuilder) {
builder.apps = [invite.userInfo.appBuilder]
}
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(),
} }
}) })
@ -392,7 +395,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 +403,13 @@ 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.appBuilder) {
builder.apps = [info.appBuilder]
request.builder = builder
}
delete info.apps delete info.apps
request = { request = {
...request, ...request,
...info, ...info,