Builder user onboarding

This commit is contained in:
Dean 2023-02-28 09:37:03 +00:00
parent c135a029f9
commit 61ed62e6c4
17 changed files with 1133 additions and 146 deletions

View File

@ -5,6 +5,7 @@ import {
generateAppUserID,
queryGlobalView,
UNICODE_MAX,
directCouchFind,
} from "./db"
import { BulkDocsResponse, User } from "@budibase/types"
import { getGlobalDB } from "./context"
@ -64,12 +65,52 @@ export const searchGlobalUsersByApp = async (appId: any, opts: any) => {
})
params.startkey = opts && opts.startkey ? opts.startkey : params.startkey
let response = await queryGlobalView(ViewName.USER_BY_APP, params)
if (!response) {
response = []
}
return Array.isArray(response) ? response : [response]
}
/*
Return any user who potentially has access to the application
Admins, developers and app users with the explicitly role.
*/
export const searchGlobalUsersByAppAccess = async (appId: any, opts: any) => {
const roleSelector = `roles.${appId}`
let orQuery: any[] = [
{
"builder.global": true,
},
{
"admin.global": true,
},
]
if (appId) {
const roleCheck = {
[roleSelector]: {
$exists: true,
},
}
orQuery.push(roleCheck)
}
let searchOptions = {
selector: {
$or: orQuery,
_id: {
$regex: "^us_",
},
},
limit: opts?.limit || 50,
}
const resp = await directCouchFind(context.getGlobalDBName(), searchOptions)
return resp?.rows
}
export const getGlobalUserByAppPage = (appId: string, user: User) => {
if (!user) {
return

View File

@ -1,6 +1,9 @@
<script>
import "@spectrum-css/actionbutton/dist/index-vars.css"
import { createEventDispatcher } from "svelte"
import Tooltip from "../Tooltip/Tooltip.svelte"
import { fade } from "svelte/transition"
const dispatch = createEventDispatcher()
export let quiet = false
@ -13,6 +16,9 @@
export let active = false
export let fullWidth = false
export let noPadding = false
export let tooltip = ""
let showTooltip = false
function longPress(element) {
if (!longPressable) return
@ -35,42 +41,54 @@
}
</script>
<button
use:longPress
class:spectrum-ActionButton--quiet={quiet}
class:spectrum-ActionButton--emphasized={emphasized}
class:is-selected={selected}
class:noPadding
class:fullWidth
class="spectrum-ActionButton spectrum-ActionButton--size{size}"
class:active
{disabled}
on:longPress
on:click|preventDefault
<span
class="btn-wrap"
on:mouseover={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
on:focus={() => (showTooltip = true)}
>
{#if longPressable}
<svg
class="spectrum-Icon spectrum-UIIcon-CornerTriangle100 spectrum-ActionButton-hold"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-CornerTriangle100" />
</svg>
{/if}
{#if icon}
<svg
class="spectrum-Icon spectrum-Icon--size{size}"
focusable="false"
aria-hidden="true"
aria-label={icon}
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
{/if}
{#if $$slots}
<span class="spectrum-ActionButton-label"><slot /></span>
{/if}
</button>
<button
use:longPress
class:spectrum-ActionButton--quiet={quiet}
class:spectrum-ActionButton--emphasized={emphasized}
class:is-selected={selected}
class:noPadding
class:fullWidth
class="spectrum-ActionButton spectrum-ActionButton--size{size}"
class:active
{disabled}
on:longPress
on:click|preventDefault
>
{#if longPressable}
<svg
class="spectrum-Icon spectrum-UIIcon-CornerTriangle100 spectrum-ActionButton-hold"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-CornerTriangle100" />
</svg>
{/if}
{#if icon}
<svg
class="spectrum-Icon spectrum-Icon--size{size}"
focusable="false"
aria-hidden="true"
aria-label={icon}
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
{/if}
{#if $$slots}
<span class="spectrum-ActionButton-label"><slot /></span>
{/if}
{#if tooltip && showTooltip}
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}>
<Tooltip textWrapping direction="bottom" text={tooltip} />
</div>
{/if}
</button>
</span>
<style>
.fullWidth {
@ -98,4 +116,14 @@
.is-selected:not(.emphasized) .spectrum-Icon {
color: var(--spectrum-global-color-gray-900);
}
.tooltip {
position: absolute;
pointer-events: none;
left: 50%;
top: calc(100% + 4px);
width: 100vw;
max-width: 150px;
transform: translateX(-50%);
text-align: center;
}
</style>

View File

@ -41,7 +41,7 @@
const getFieldText = (value, options, placeholder) => {
// Always use placeholder if no value
if (value == null || value === "") {
return placeholder || "Choose an option"
return placeholder !== false ? "Choose an option" : ""
}
return getFieldAttribute(getOptionLabel, value, options)
@ -74,7 +74,7 @@
{autocomplete}
{sort}
isPlaceholder={value == null || value === ""}
placeholderOption={placeholder}
placeholderOption={placeholder === false ? null : placeholder}
isOptionSelected={option => option === value}
onSelectOption={selectOption}
/>

View File

@ -6,8 +6,10 @@
Heading,
Body,
Button,
Icon,
ActionButton,
} from "@budibase/bbui"
import RevertModal from "components/deploy/RevertModal.svelte"
import VersionModal from "components/deploy/VersionModal.svelte"
import { processStringSync } from "@budibase/string-templates"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import analytics, { Events, EventSource } from "analytics"
@ -16,6 +18,8 @@
import { onMount } from "svelte"
import DeployModal from "components/deploy/DeployModal.svelte"
import { apps } from "stores/portal"
import { store } from "builderStore"
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
export let application
@ -108,66 +112,93 @@
})
</script>
<div class="deployment-top-nav">
{#if isPublished}
<div class="publish-popover">
<div bind:this={publishPopoverAnchor}>
<Icon
size="M"
hoverable
name="Globe"
tooltip="Your published app"
on:click={publishPopover.show()}
/>
</div>
<Popover
bind:this={publishPopover}
align="right"
disabled={!isPublished}
anchor={publishPopoverAnchor}
offset={10}
>
<div class="popover-content">
<Layout noPadding gap="M">
<Heading size="XS">Your published app</Heading>
<Body size="S">
<span class="publish-popover-message">
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
</span>
</Body>
<div class="buttons">
<Button
warning={true}
icon="GlobeStrike"
disabled={!isPublished}
on:click={unpublishApp}
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View app</Button>
</div>
</Layout>
</div>
</Popover>
<div class="action-top-nav">
<div class="action-buttons">
<div class="version">
<VersionModal />
</div>
{/if}
<RevertModal />
{#if !isPublished}
<Icon
size="M"
name="GlobeStrike"
disabled
tooltip="Your app has not been published yet"
/>
{/if}
{#if isPublished}
<div class="publish-popover">
<div bind:this={publishPopoverAnchor}>
<ActionButton
quiet
icon="Globe"
size="M"
tooltip="Your published app"
on:click={publishPopover.show()}
/>
</div>
<Popover
bind:this={publishPopover}
align="right"
disabled={!isPublished}
anchor={publishPopoverAnchor}
offset={10}
>
<div class="popover-content">
<Layout noPadding gap="M">
<Heading size="XS">Your published app</Heading>
<Body size="S">
<span class="publish-popover-message">
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
</span>
</Body>
<div class="buttons">
<Button
warning={true}
icon="GlobeStrike"
disabled={!isPublished}
on:click={unpublishApp}
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View app</Button>
</div>
</Layout>
</div>
</Popover>
</div>
{/if}
{#if !isPublished}
<ActionButton
quiet
icon="GlobeStrike"
size="M"
tooltip="Your app has not been published yet"
disabled
/>
{/if}
<TourWrap tourStepKey={`builder-user-management`}>
<span id="builder-app-users-button">
<ActionButton
quiet
icon="UserGroup"
size="M"
on:click={() => {
store.update(state => {
state.builderSidePanel = true
return state
})
}}
>
Users
</ActionButton>
</span>
</TourWrap>
</div>
</div>
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
@ -183,6 +214,11 @@
</div>
<style>
/* .banner-btn {
display: flex;
align-items: center;
gap: var(--spacing-s);
} */
.popover-content {
padding: var(--spacing-xl);
}
@ -191,6 +227,22 @@
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-m);
gap: var(--spacing-l);
}
.action-buttons {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
/* gap: var(--spacing-s); */
}
.version {
margin-right: var(--spacing-s);
}
.action-top-nav {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
</style>

View File

@ -1,10 +1,10 @@
<script>
import {
Icon,
Input,
Modal,
notifications,
ModalContent,
ActionButton,
} from "@budibase/bbui"
import { store } from "builderStore"
import { API } from "api"
@ -28,12 +28,14 @@
}
</script>
<Icon
name="Revert"
hoverable
on:click={revertModal.show}
<ActionButton
quiet
icon="Revert"
size="M"
tooltip="Revert changes"
on:click={revertModal.show}
/>
<Modal bind:this={revertModal}>
<ModalContent
title="Revert Changes"

View File

@ -122,7 +122,9 @@
<Layout noPadding gap="M">
<div class="tour-header">
<Heading size="XS">{tourStep?.title || "-"}</Heading>
<div>{`${tourStepIdx + 1}/${tourSteps?.length}`}</div>
{#if tourSteps?.length > 1}
<div>{`${tourStepIdx + 1}/${tourSteps?.length}`}</div>
{/if}
</div>
<Body size="S">
<span class="tour-body">

View File

@ -9,11 +9,14 @@ export const TOUR_STEP_KEYS = {
BUILDER_APP_PUBLISH: "builder-app-publish",
BUILDER_DATA_SECTION: "builder-data-section",
BUILDER_DESIGN_SECTION: "builder-design-section",
BUILDER_USER_MANAGEMENT: "builder-user-management",
BUILDER_AUTOMATE_SECTION: "builder-automate-section",
FEATURE_USER_MANAGEMENT: "feature-user-management",
}
export const TOUR_KEYS = {
TOUR_BUILDER_ONBOARDING: "builder-onboarding",
FEATURE_ONBOARDING: "feature-onboarding",
}
const tourEvent = eventKey => {
@ -58,6 +61,15 @@ const getTours = () => {
},
align: "left",
},
{
id: TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT,
title: "Users",
query: ".toprightnav #builder-app-users-button",
body: "Choose which users you want to see to have access to your app and control what level of access they have.",
onLoad: () => {
tourEvent(TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT)
},
},
{
id: TOUR_STEP_KEYS.BUILDER_APP_PUBLISH,
title: "Publish",
@ -90,6 +102,18 @@ const getTours = () => {
},
},
],
[TOUR_KEYS.FEATURE_ONBOARDING]: [
{
id: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT,
title: "Users",
query: ".toprightnav #builder-app-users-button",
body: "Choose which users you want to have access to your app and control what level of access they have.",
onLoad: () => {
tourEvent(TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT)
},
align: "left",
},
],
}
}

View File

@ -0,0 +1,735 @@
<script>
import {
Icon,
Heading,
Layout,
Input,
clickOutside,
notifications,
ActionButton,
} from "@budibase/bbui"
import { store } from "builderStore"
import { groups, licensing, apps, users } from "stores/portal"
import { fetchData } from "@budibase/frontend-core"
import { API } from "api"
import { onMount } from "svelte"
import GroupIcon from "../../../portal/users/groups/_components/GroupIcon.svelte"
import RoleSelect from "components/common/RoleSelect.svelte"
import { Constants, Utils } from "@budibase/frontend-core"
import { emailValidator } from "helpers/validation"
import CopyInput from "components/common/inputs/CopyInput.svelte"
let query = null
let loaded = false
let rendered = false
let inviting = false
let searchFocus = false
let appInvites = []
let filteredInvites = []
let filteredUsers = []
let filteredGroups = []
let selectedGroup
let userOnboardResponse = null
$: queryIsEmail = emailValidator(query) === true
$: prodAppId = apps.getProdAppID($store.appId)
$: promptInvite = showInvite(
filteredInvites,
filteredUsers,
filteredGroups,
query
)
const showInvite = (invites, users, groups, query) => {
return !invites?.length && !users?.length && !groups?.length && query
}
const filterInvites = async query => {
appInvites = await getInvites()
if (!query || query == "") {
filteredInvites = appInvites
return
}
filteredInvites = appInvites.filter(invite => invite.email.includes(query))
}
$: filterInvites(query)
const usersFetch = fetchData({
API,
datasource: {
type: "user",
},
})
const searchUsers = async (query, sidePaneOpen, loaded) => {
if (!sidePaneOpen || !loaded) {
return
}
if (!prodAppId) {
console.log("Application id required")
return
}
await usersFetch.update({
query: {
appId: query ? null : prodAppId,
email: query,
paginated: query ? null : false,
},
})
await usersFetch.refresh()
filteredUsers = $usersFetch.rows.map(user => {
const appRole = Object.keys(user.roles).find(x => x === prodAppId)
// if (
// !appRole &&
// user.userGroups &&
// !user.builder?.global &&
// !user.admin.global
// ) {
// console.log("Hi, I don't have groups > ", user.email)
// }
return {
...user,
role: !appRole ? undefined : user.roles[appRole],
}
})
}
const debouncedUpdateFetch = Utils.debounce(searchUsers, 250)
$: debouncedUpdateFetch(query, $store.builderSidePanel, loaded)
const updateAppUser = async (user, role) => {
if (!prodAppId) {
notifications.error("Application id must be specified")
return
}
const update = await users.get(user._id)
await users.save({
...update,
roles: {
...update.roles,
[prodAppId]: role,
},
})
await searchUsers(query, $store.builderSidePanel, loaded)
}
const onUpdateUser = async (user, role) => {
if (!user) {
notifications.error("A user must be specified")
return
}
try {
await updateAppUser(user, role)
} catch (error) {
console.error(error)
notifications.error("User could not be updated")
}
}
const updateAppGroup = async (target, role) => {
if (!prodAppId) {
notifications.error("Application id must be specified")
return
}
if (!role) {
await groups.actions.removeApp(target._id, prodAppId)
} else {
await groups.actions.addApp(target._id, prodAppId, role)
}
await usersFetch.refresh()
await groups.actions.init()
}
const onUpdateGroup = async (group, role) => {
if (!group) {
notifications.error("A group must be specified")
return
}
try {
await updateAppGroup(group, role)
} catch {
notifications.error("Group update failed")
}
}
const getAppGroups = (allGroups, appId) => {
if (!allGroups) {
return []
}
return allGroups.filter(group => {
if (!group.roles) {
return false
}
return groups.actions.getGroupAppIds(group).includes(appId)
})
}
const searchGroups = (userGroups, query) => {
let filterGroups = query?.length
? userGroups
: getAppGroups(userGroups, prodAppId)
return filterGroups
.filter(group => {
if (!query?.length) {
return true
}
//Group Name only.
const nameMatch = group.name
?.toLowerCase()
.includes(query?.toLowerCase())
return nameMatch
})
.map(enrichGroupRole)
}
const enrichGroupRole = group => {
return {
...group,
role: group.roles[
groups.actions.getGroupAppIds(group).find(x => x === prodAppId)
],
}
}
const getEnrichedGroups = groups => {
return groups.map(enrichGroupRole)
}
// Adds the 'role' attribute and sets it to the current app.
$: enrichedGroups = getEnrichedGroups($groups)
$: filteredGroups = searchGroups(enrichedGroups, query)
// $: enrichedAppGroupsById = getAppGroups(enrichedGroups, prodAppId).map(
// group => group._id
// )
// $: console.log("ALL GROUP IDS ", enrichedAppGroupsById)
$: 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
inherited from their parent group. The users allow assigning of user
specific roles for the app.
*/
const buildGroupUsers = (userGroups, filteredUsers) => {
if (query) {
return []
}
// Must exclude users who have explicit privileges
const userByEmail = filteredUsers.reduce((acc, user) => {
if (user.role || user.admin?.global || user.builder?.global) {
acc.push(user.email)
}
return acc
}, [])
const indexedUsers = userGroups.reduce((acc, group) => {
group.users.forEach(user => {
if (userByEmail.indexOf(user.email) == -1) {
acc[user._id] = {
_id: user._id,
email: user.email,
role: group.role,
group: group.name,
}
}
})
return acc
}, {})
return Object.values(indexedUsers)
}
const getInvites = async () => {
try {
const invites = await users.getInvites()
return invites
} catch (error) {
notifications.error(error.message)
return []
}
}
async function inviteUser() {
if (!queryIsEmail) {
notifications.error("Email is not valid")
return
}
const newUserEmail = query + ""
inviting = true
const payload = [
{
email: newUserEmail,
builder: false,
admin: false,
apps: { [prodAppId]: Constants.Roles.BASIC },
},
]
let userInviteResponse
try {
userInviteResponse = await users.onboard(payload)
const newUser = userInviteResponse?.successful.find(
user => user.email === newUserEmail
)
if (newUser) {
notifications.success(
userInviteResponse.created
? "User created successfully"
: "User invite successful"
)
} else {
throw new Error("User invite failed")
}
} catch (error) {
console.error(error.message)
notifications.error("Error inviting user")
}
inviting = false
return userInviteResponse
}
const onInviteUser = async () => {
userOnboardResponse = await inviteUser()
const userInviteSuccess = userOnboardResponse?.successful
if (userInviteSuccess && userInviteSuccess[0].email === query) {
query = null
query = userInviteSuccess[0].email
}
}
const onUpdateUserInvite = async (invite, role) => {
await users.updateInvite({
code: invite.code,
apps: {
...invite.apps,
[prodAppId]: role,
},
})
await filterInvites()
}
const onUninviteAppUser = async invite => {
await uninviteAppUser(invite)
await filterInvites()
}
// Purge only the app from the invite or recind the invite if only 1 app remains?
const uninviteAppUser = async invite => {
let updated = { ...invite }
delete updated.info.apps[prodAppId]
return await users.updateInvite({
code: updated.code,
apps: updated.apps,
})
}
const initSidePanel = async sidePaneOpen => {
if (sidePaneOpen === true) {
await groups.actions.init()
}
loaded = true
}
$: initSidePanel($store.builderSidePanel)
onMount(() => {
rendered = true
})
const userTitle = user => {
if (user.admin?.global) {
return "Admin"
} else if (user.builder?.global) {
return "Developer"
} else {
return "App user"
}
}
const roleNote = user => {
if (user.group) {
return "Part of a group"
}
return null
}
</script>
<div
id="builder-side-panel-container"
class:open={$store.builderSidePanel}
use:clickOutside={$store.builderSidePanel
? () => {
store.update(state => {
state.builderSidePanel = false
return state
})
}
: () => {}}
>
<div class="builder-side-panel-header">
<Heading size="S">Users</Heading>
<Icon
color="var(--spectrum-global-color-gray-600)"
name="RailRightClose"
hoverable
on:click={() => {
store.update(state => {
state.builderSidePanel = false
return state
})
}}
/>
</div>
<div class="search" class:focused={searchFocus}>
<span class="search-input">
<Input
placeholder={"Add users and groups to your app"}
autocomplete="off"
disabled={inviting}
value={query}
on:input={e => {
query = e.target.value.trim()
}}
on:focus={() => (searchFocus = true)}
on:blur={() => (searchFocus = false)}
/>
</span>
<span
class="search-input-icon"
class:searching={query}
on:click={() => {
if (!query) {
return
}
query = null
userOnboardResponse = null
}}
>
<Icon name={query ? "Close" : "Search"} />
</span>
</div>
{#if promptInvite && !userOnboardResponse}
<Layout gap="S" paddingX="XL">
<div class="invite-header">
<Heading size="XS">No user found</Heading>
<div class="invite-directions">
Add a valid email to invite a new user
</div>
</div>
<div class="invite-form">
<span>{query || ""}</span>
<ActionButton
icon="UserAdd"
disabled={!queryIsEmail || inviting}
on:click={onInviteUser}
>
Add user
</ActionButton>
</div>
</Layout>
{/if}
{#if !promptInvite}
<Layout gap="L" noPadding>
{#if filteredInvites?.length}
<Layout noPadding gap="XS">
<div class="auth-entity-header">
<div class="auth-entity-title">Pending invites</div>
<div class="auth-entity-access-title">Access</div>
</div>
{#each filteredInvites as invite}
<div class="auth-entity">
<div class="details">
<div class="user-email" title={invite.email}>
{invite.email}
</div>
</div>
<div class="auth-entity-access">
<RoleSelect
placeholder={false}
value={invite.info.apps?.[prodAppId]}
allowRemove={invite.info.apps?.[prodAppId]}
allowPublic={false}
quiet={true}
on:change={e => {
onUpdateUserInvite(invite, e.detail)
}}
on:remove={() => {
onUninviteAppUser(invite)
}}
/>
</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)
}}
/>
</div>
</div>
{/each}
</Layout>
{/if}
{#if filteredUsers?.length}
<div class="auth-entity-section">
<div class="auth-entity-header ">
<div class="auth-entity-title">Users</div>
<div class="auth-entity-access-title">Access</div>
</div>
{#each allUsers as user}
<div class="auth-entity">
<div class="details">
<div class="user-email" title={user.email}>
{user.email}
</div>
<div class="auth-entity-meta">
{userTitle(user)}
</div>
</div>
<div class="auth-entity-access" class:muted={user.group}>
<RoleSelect
note={roleNote(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)
}}
/>
</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>
<style>
.search :global(input) {
padding-left: 0px;
}
.search {
display: flex;
align-items: center;
}
.search-input {
flex: 1;
}
.search-input-icon.searching {
cursor: pointer;
}
.auth-entity-section {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
width: 400px;
}
.auth-entity-meta {
color: var(--spectrum-global-color-gray-600);
font-size: 12px;
white-space: nowrap;
}
.auth-entity-access.muted :global(.spectrum-Picker-label),
.auth-entity-access.muted :global(.spectrum-StatusLight) {
opacity: 0.7;
}
.auth-entity-header {
color: var(--spectrum-global-color-gray-600);
}
.auth-entity,
.auth-entity-header {
padding: 0px var(--spacing-xl);
}
.auth-entity,
.auth-entity-header {
display: grid;
grid-template-columns: 65% auto;
align-items: center;
gap: var(--spacing-xl);
}
.auth-entity .details {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
.auth-entity .user-email {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
#builder-side-panel-container {
box-sizing: border-box;
max-width: calc(100vw - 40px);
background: var(--background);
border-left: var(--border-light);
z-index: 3;
padding: var(--spacing-xl) 0px;
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
overflow-y: auto;
overflow-x: hidden;
transition: transform 130ms ease-out;
position: absolute;
width: 400px;
right: 0;
transform: translateX(100%);
height: 100%;
}
.builder-side-panel-header,
#builder-side-panel-container .search {
padding: 0px var(--spacing-xl);
}
#builder-side-panel-container .auth-entity .details {
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);
border-top: 1px solid var(--spectrum-alias-border-color-mid);
border-bottom: 1px solid var(--spectrum-alias-border-color-mid);
}
#builder-side-panel-container .search :global(input) {
border: none;
border-radius: 0px;
background: none;
}
#builder-side-panel-container .search :global(input) {
border: none;
border-radius: 0px;
}
#builder-side-panel-container .search.focused {
border-color: var(
--spectrum-textfield-m-border-color-down,
var(--spectrum-alias-border-color-mouse-focus)
);
}
#builder-side-panel-container .search :global(input::placeholder) {
font-style: normal;
}
#builder-side-panel-container.open {
transform: translateX(0);
box-shadow: 0 0 40px 10px rgba(0, 0, 0, 0.1);
}
.builder-side-panel-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.invite-header {
display: flex;
gap: var(--spacing-s);
flex-direction: column;
}
</style>

View File

@ -13,15 +13,14 @@
notifications,
} from "@budibase/bbui"
import RevertModal from "components/deploy/RevertModal.svelte"
import VersionModal from "components/deploy/VersionModal.svelte"
import DeployNavigation from "components/deploy/DeployNavigation.svelte"
import AppActions from "components/deploy/AppActions.svelte"
import { API } from "api"
import { isActive, goto, layout, redirect } from "@roxi/routify"
import { capitalise } from "helpers"
import { onMount, onDestroy } from "svelte"
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
import TourPopover from "components/portal/onboarding/TourPopover.svelte"
import BuilderSidePanel from "./_components/BuilderSidePanel.svelte"
import { TOUR_KEYS, TOURS } from "components/portal/onboarding/tours.js"
export let application
@ -116,6 +115,11 @@
<div class="loading" />
{:then _}
<TourPopover />
{#if $store.builderSidePanel}
<BuilderSidePanel />
{/if}
<div class="root">
<div class="top-nav">
<div class="topleftnav">
@ -181,11 +185,7 @@
</Tabs>
</div>
<div class="toprightnav">
<div class="version">
<VersionModal />
</div>
<RevertModal />
<DeployNavigation {application} />
<AppActions {application} />
</div>
</div>
<slot />
@ -250,10 +250,6 @@
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-xl);
}
.version {
margin-right: var(--spacing-s);
gap: var(--spacing-l);
}
</style>

View File

@ -26,9 +26,15 @@ export function createUsersStore() {
return await API.getUsers()
}
// One or more users.
async function onboard(payload) {
return await API.onboardUsers(payload)
}
async function invite(payload) {
return API.inviteUsers(payload)
}
async function acceptInvite(inviteCode, password, firstName, lastName) {
return API.acceptInvite({
inviteCode,
@ -42,6 +48,14 @@ export function createUsersStore() {
return API.getUserInvite(inviteCode)
}
async function getInvites() {
return API.getUserInvites()
}
async function updateInvite(invite) {
return API.updateUserInvite(invite)
}
async function create(data) {
let mappedUsers = data.users.map(user => {
const body = {
@ -106,8 +120,11 @@ export function createUsersStore() {
getUserRole,
fetch,
invite,
onboard,
acceptInvite,
fetchInvite,
getInvites,
updateInvite,
create,
save,
bulkDelete,

View File

@ -12,8 +12,10 @@ export const buildUserEndpoints = API => ({
* Gets a list of users in the current tenant.
* @param {string} page The page to retrieve
* @param {string} search The starts with string to search username/email by.
* @param {string} appId Facilitate app/role based user searching
* @param {boolean} paginated Allow the disabling of pagination
*/
searchUsers: async ({ page, email, appId } = {}) => {
searchUsers: async ({ paginated, page, email, appId } = {}) => {
const opts = {}
if (page) {
opts.page = page
@ -24,6 +26,9 @@ export const buildUserEndpoints = API => ({
if (appId) {
opts.appId = appId
}
if (typeof paginated === "boolean") {
opts.paginated = paginated
}
return await API.post({
url: `/api/global/users/search`,
body: opts,
@ -133,7 +138,7 @@ export const buildUserEndpoints = API => ({
* @param builder whether the user should be a global builder
* @param admin whether the user should be a global admin
*/
inviteUser: async ({ email, builder, admin }) => {
inviteUser: async ({ email, builder, admin, apps }) => {
return await API.post({
url: "/api/global/users/invite",
body: {
@ -141,11 +146,43 @@ export const buildUserEndpoints = API => ({
userInfo: {
admin: admin ? { global: true } : undefined,
builder: builder ? { global: true } : undefined,
apps: apps ? apps : undefined,
},
},
})
},
onboardUsers: async payload => {
return await API.post({
url: "/api/global/users/onboard",
body: payload.map(invite => {
const { email, admin, builder, apps } = invite
return {
email,
userInfo: {
admin: admin ? { global: true } : undefined,
builder: builder ? { global: true } : undefined,
apps: apps ? apps : undefined,
},
}
}),
})
},
/**
* Accepts a user invite as a body and will update the associated app roles.
* for an existing invite
* @param invite the invite code sent in the email
*/
updateUserInvite: async invite => {
await API.post({
url: `/api/global/users/invite/update/${invite.code}`,
body: {
apps: invite.apps,
},
})
},
/**
* Retrieves the invitation associated with a provided code.
* @param code The unique code for the target invite
@ -156,6 +193,16 @@ export const buildUserEndpoints = API => ({
})
},
/**
* Retrieves the invitation associated with a provided code.
* @param code The unique code for the target invite
*/
getUserInvites: async () => {
return await API.get({
url: `/api/global/users/invites`,
})
},
/**
* Invites multiple users to the current tenant.
* @param users An array of users to invite
@ -169,6 +216,7 @@ export const buildUserEndpoints = API => ({
admin: user.admin ? { global: true } : undefined,
builder: user.admin || user.builder ? { global: true } : undefined,
userGroups: user.groups,
roles: user.apps ? user.apps : undefined,
},
})),
})

View File

@ -35,6 +35,7 @@ export default class UserFetch extends DataFetch {
page: cursor,
email: query.email,
appId: query.appId,
paginated: query.paginated,
})
return {
rows: res?.data || [],

View File

@ -50,7 +50,7 @@ export interface SearchUsersRequest {
page?: string
email?: string
appId?: string
userIds?: string[]
paginated?: boolean
}
export interface CreateAdminUserRequest {

View File

@ -185,16 +185,28 @@ export const destroy = async (ctx: any) => {
}
}
export const getAppUsers = async (ctx: any) => {
const body = ctx.request.body as SearchUsersRequest
const users = await userSdk.getUsersByAppAccess(body?.appId)
ctx.body = { data: users }
}
export const search = async (ctx: any) => {
const body = ctx.request.body as SearchUsersRequest
const paginated = await userSdk.paginatedUsers(body)
// user hashed password shouldn't ever be returned
for (let user of paginated.data) {
if (user) {
delete user.password
if (body.paginated === false) {
await getAppUsers(ctx)
} else {
const paginated = await userSdk.paginatedUsers(body)
// user hashed password shouldn't ever be returned
for (let user of paginated.data) {
if (user) {
delete user.password
}
}
ctx.body = paginated
}
ctx.body = paginated
}
// called internally by app server user fetch
@ -242,12 +254,18 @@ export const onboardUsers = async (ctx: any) => {
onboardingResponse = await userSdk.bulkCreate(assignUsers, groups)
ctx.body = onboardingResponse
} else if (emailConfigured) {
onboardingResponse = await inviteMultiple(ctx)
onboardingResponse = await invite(ctx)
} else if (!emailConfigured) {
const inviteRequest = ctx.request.body as InviteUsersRequest
let createdPasswords: any = {}
const users: User[] = inviteRequest.map(invite => {
let password = Math.random().toString(36).substring(2, 22)
// Temp password to be passed to the user.
createdPasswords[invite.email] = password
return {
email: invite.email,
password,
@ -259,19 +277,28 @@ export const onboardUsers = async (ctx: any) => {
}
})
let bulkCreateReponse = await userSdk.bulkCreate(users, [])
onboardingResponse = {
// Apply temporary credentials
let createWithCredentials = {
...bulkCreateReponse,
successful: bulkCreateReponse?.successful.map(user => {
return {
...user,
password: createdPasswords[user.email],
}
}),
created: true,
}
ctx.body = onboardingResponse
ctx.body = createWithCredentials
} else {
ctx.throw(400, "User onboarding failed")
}
}
export const invite = async (ctx: any) => {
const request = ctx.request.body as InviteUserRequest
const response = await userSdk.invite([request])
const request = ctx.request.body as InviteUsersRequest
const response = await userSdk.invite(request)
// explicitly throw for single user invite
if (response.unsuccessful.length) {

View File

@ -38,13 +38,6 @@ function buildInviteMultipleValidation() {
))
}
function buildInviteLookupValidation() {
// prettier-ignore
return auth.joiValidator.params(Joi.object({
code: Joi.string().required()
}).unknown(true))
}
const createUserAdminOnly = (ctx: any, next: any) => {
if (!ctx.request.body._id) {
return auth.adminOnly(ctx, next)
@ -88,22 +81,34 @@ router
.get("/api/global/roles/:appId")
.post(
"/api/global/users/invite",
auth.adminOnly,
auth.builderOrAdmin,
buildInviteValidation(),
controller.invite
)
.post(
"/api/global/users/onboard",
auth.builderOrAdmin,
buildInviteMultipleValidation(),
controller.onboardUsers
)
.post(
"/api/global/users/multi/invite",
auth.adminOnly,
auth.builderOrAdmin,
buildInviteMultipleValidation(),
controller.inviteMultiple
)
// non-global endpoints
.get("/api/global/users/invite/:code", controller.checkInvite)
.post(
"/api/global/users/invite/update/:code",
auth.builderOrAdmin,
controller.updateInvite
)
.get(
"/api/global/users/invite/:code",
buildInviteLookupValidation(),
controller.checkInvite
"/api/global/users/invites",
auth.builderOrAdmin,
controller.getUserInvites
)
.post(
"/api/global/users/invite/accept",

View File

@ -56,11 +56,22 @@ export const countUsersByApp = async (appId: string) => {
}
}
export const getUsersByAppAccess = async (appId?: string) => {
const opts: any = {
include_docs: true,
limit: 50,
}
let response: User[] = await usersCore.searchGlobalUsersByAppAccess(
appId,
opts
)
return response
}
export const paginatedUsers = async ({
page,
email,
appId,
userIds,
}: SearchUsersRequest = {}) => {
const db = tenancy.getGlobalDB()
// get one extra document, to have the next page

View File

@ -130,11 +130,9 @@ export async function checkInviteCode(
/**
Get all currently available user invitations.
@return {Object[]} A
@return {Object[]} A list of all objects containing invite metadata
**/
export async function getInviteCodes(
tenantIds?: string[] //should default to the current tenant of the user session.
) {
export async function getInviteCodes(tenantIds?: string[]) {
const client = await getClient(redis.utils.Databases.INVITATIONS)
const invites: any[] = await client.scan()