1
0
Fork 0
mirror of synced 2024-07-14 18:55:45 +12:00

Merge pull request #9827 from Budibase/feature/app-user-onboarding-ux

Feature/app user onboarding ux
This commit is contained in:
deanhannigan 2023-03-01 09:52:39 +00:00 committed by GitHub
commit 3dcd74b20b
23 changed files with 1420 additions and 170 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"
@ -101,6 +102,7 @@ export const searchGlobalUsersByApp = async (
})
params.startkey = opts && opts.startkey ? opts.startkey : params.startkey
let response = await queryGlobalView(ViewName.USER_BY_APP, params)
if (!response) {
response = []
}
@ -111,6 +113,45 @@ export const searchGlobalUsersByApp = async (
return users
}
/*
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

@ -33,6 +33,9 @@
export let sort = false
export let fetchTerm = null
export let customPopoverHeight
export let align = "left"
export let footer = null
const dispatch = createEventDispatcher()
let searchTerm = null
@ -131,7 +134,7 @@
<Popover
anchor={button}
align="left"
align={align || "left"}
bind:this={popover}
{open}
on:close={() => (open = false)}
@ -208,6 +211,12 @@
{/each}
{/if}
</ul>
{#if footer}
<div class="footer">
{footer}
</div>
{/if}
</div>
</Popover>
@ -284,4 +293,11 @@
.popover-content :global(.spectrum-Search .spectrum-Textfield-icon) {
top: 9px;
}
.footer {
padding: 4px 12px 12px 12px;
font-style: italic;
max-width: 170px;
font-size: 12px;
}
</style>

View file

@ -18,6 +18,8 @@
export let autoWidth = false
export let autocomplete = false
export let sort = false
export let align
export let footer = null
const dispatch = createEventDispatcher()
@ -41,7 +43,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)
@ -66,6 +68,8 @@
{fieldColour}
{options}
{autoWidth}
{align}
{footer}
{getOptionLabel}
{getOptionValue}
{getOptionIcon}
@ -74,7 +78,7 @@
{autocomplete}
{sort}
isPlaceholder={value == null || value === ""}
placeholderOption={placeholder}
placeholderOption={placeholder === false ? null : placeholder}
isOptionSelected={option => option === value}
onSelectOption={selectOption}
/>

View file

@ -22,6 +22,8 @@
export let tooltip = ""
export let autocomplete = false
export let customPopoverHeight
export let align
export let footer = null
const dispatch = createEventDispatcher()
const onChange = e => {
@ -48,6 +50,8 @@
{placeholder}
{autoWidth}
{sort}
{align}
{footer}
{getOptionLabel}
{getOptionValue}
{getOptionIcon}

View file

@ -72,6 +72,8 @@ const INITIAL_FRONTEND_STATE = {
// onboarding
onboarding: false,
tourNodes: null,
builderSidePanel: false,
}
export const getFrontendStore = () => {

View file

@ -11,16 +11,24 @@
export let quiet = false
export let allowPublic = true
export let allowRemove = false
export let disabled = false
export let align
export let footer = null
export let allowedRoles = null
const dispatch = createEventDispatcher()
const RemoveID = "remove"
$: options = getOptions($roles, allowPublic, allowRemove)
$: options = getOptions($roles, allowPublic, allowRemove, allowedRoles)
const getOptions = (roles, allowPublic) => {
const getOptions = (roles, allowPublic, allowRemove, allowedRoles) => {
if (allowedRoles?.length) {
return roles.filter(role => allowedRoles.includes(role._id))
}
let newRoles = [...roles]
if (allowRemove) {
roles = [
...roles,
newRoles = [
...newRoles,
{
_id: RemoveID,
name: "Remove",
@ -28,9 +36,9 @@
]
}
if (allowPublic) {
return roles
return newRoles
}
return roles.filter(role => role._id !== Constants.Roles.PUBLIC)
return newRoles.filter(role => role._id !== Constants.Roles.PUBLIC)
}
const getColor = role => {
@ -59,6 +67,9 @@
<Select
{autoWidth}
{quiet}
{disabled}
{align}
{footer}
bind:value
on:change={onChange}
{options}

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,9 @@
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"
import { TOUR_STEP_KEYS } from "components/portal/onboarding/tours.js"
export let application
@ -108,66 +113,97 @@
})
</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={$store.onboarding
? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT
: TOUR_STEP_KEYS.FEATURE_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 +219,11 @@
</div>
<style>
/* .banner-btn {
display: flex;
align-items: center;
gap: var(--spacing-s);
} */
.popover-content {
padding: var(--spacing-xl);
}
@ -191,6 +232,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

@ -6,16 +6,19 @@
export let tourStepKey
let currentTour
let currentTourStep
let ready = false
let handler
onMount(() => {
if (!$store.tourKey) return
currentTour = TOURS[$store.tourKey].find(step => step.id === tourStepKey)
currentTourStep = TOURS[$store.tourKey].find(
step => step.id === tourStepKey
)
if (!currentTourStep) return
const elem = document.querySelector(currentTour.query)
const elem = document.querySelector(currentTourStep.query)
handler = tourHandler(elem, tourStepKey)
ready = true
})

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: "Add users 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",
@ -79,6 +91,37 @@ const getTours = () => {
// Update the cached user
await auth.getSelf()
store.update(state => ({
...state,
tourNodes: undefined,
tourKey: undefined,
tourKeyStep: undefined,
onboarding: false,
}))
}
},
},
],
[TOUR_KEYS.FEATURE_ONBOARDING]: [
{
id: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT,
title: "Users",
query: ".toprightnav #builder-app-users-button",
body: "Add users to your app and control what level of access they have.",
onLoad: () => {
tourEvent(TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT)
},
onComplete: async () => {
// Push the onboarding forward
if (get(auth).user) {
await users.save({
...get(auth).user,
onboardedAt: new Date().toISOString(),
})
// Update the cached user
await auth.getSelf()
store.update(state => ({
...state,
tourNodes: undefined,

View file

@ -0,0 +1,763 @@
<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"
import { roles } from "stores/backend"
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 isBuilderOrAdmin = user.admin?.global || user.builder?.global
let role = undefined
if (isBuilderOrAdmin) {
role = Constants.Roles.ADMIN
} else {
const appRole = Object.keys(user.roles).find(x => x === prodAppId)
if (appRole) {
role = user.roles[appRole]
}
}
return {
...user,
role,
isBuilderOrAdmin,
}
})
}
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 {
if (user.role === role) {
return
}
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)
$: 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 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.isBuilderOrAdmin) {
return "This user's role grants admin access to all apps"
}
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>
<div class="body">
{#if promptInvite && !userOnboardResponse}
<Layout gap="S" paddingX="XL">
<div class="invite-header">
<Heading size="XS">No user found</Heading>
<div class="invite-directions">
Add a valid email to invite a new user
</div>
</div>
<div class="invite-form">
<span>{query || ""}</span>
<ActionButton
icon="UserAdd"
disabled={!queryIsEmail || inviting}
on:click={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)
}}
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>
{#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.isBuilderOrAdmin
? [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>
</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 {
margin-right: var(--spacing-m);
}
.auth-entity-access.muted :global(.spectrum-Picker-label),
.auth-entity-access.muted :global(.spectrum-StatusLight) {
opacity: 0.5;
}
.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: 1fr 110px;
align-items: center;
gap: var(--spacing-xl);
}
.auth-entity .details {
display: flex;
align-items: center;
gap: var(--spacing-m);
color: var(--spectrum-global-color-gray-900);
}
.auth-entity .user-email {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
color: var(--spectrum-global-color-gray-900);
}
#builder-side-panel-container {
box-sizing: border-box;
max-width: calc(100vw - 40px);
background: var(--background);
border-left: var(--border-light);
z-index: 3;
display: flex;
flex-direction: column;
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: var(--border-light);
border-bottom: var(--border-light);
border-left: 2px solid transparent;
border-right: 2px solid transparent;
margin-right: 1px;
}
#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 {
height: 58px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.invite-header {
display: flex;
gap: var(--spacing-s);
flex-direction: column;
}
.body {
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
padding: var(--spacing-xl) 0;
}
</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
@ -69,22 +68,32 @@
}
const initTour = async () => {
if (
!$auth.user?.onboardedAt &&
isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)
) {
// Determine the correct step
const activeNav = $layout.children.find(c => $isActive(c.path))
const onboardingTour = TOURS[TOUR_KEYS.TOUR_BUILDER_ONBOARDING]
const targetStep = activeNav
? onboardingTour.find(step => step.route === activeNav?.path)
: null
await store.update(state => ({
...state,
onboarding: true,
tourKey: TOUR_KEYS.TOUR_BUILDER_ONBOARDING,
tourStepKey: targetStep?.id,
}))
// Check if onboarding is enabled.
if (isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)) {
if (!$auth.user?.onboardedAt) {
// Determine the correct step
const activeNav = $layout.children.find(c => $isActive(c.path))
const onboardingTour = TOURS[TOUR_KEYS.TOUR_BUILDER_ONBOARDING]
const targetStep = activeNav
? onboardingTour.find(step => step.route === activeNav?.path)
: null
await store.update(state => ({
...state,
onboarding: true,
tourKey: TOUR_KEYS.TOUR_BUILDER_ONBOARDING,
tourStepKey: targetStep?.id,
}))
} else {
// Feature tour date
const release_date = new Date("2023-03-01T00:00:00.000Z")
const onboarded = new Date($auth.user?.onboardedAt)
if (onboarded < release_date) {
await store.update(state => ({
...state,
tourKey: TOUR_KEYS.FEATURE_ONBOARDING,
}))
}
}
}
}
@ -116,6 +125,11 @@
<div class="loading" />
{:then _}
<TourPopover />
{#if $store.builderSidePanel}
<BuilderSidePanel />
{/if}
<div class="root">
<div class="top-nav">
<div class="topleftnav">
@ -181,11 +195,7 @@
</Tabs>
</div>
<div class="toprightnav">
<div class="version">
<VersionModal />
</div>
<RevertModal />
<DeployNavigation {application} />
<AppActions {application} />
</div>
</div>
<slot />
@ -250,10 +260,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

@ -16,6 +16,7 @@ export interface BulkUserRequest {
userIds: string[]
}
create?: {
roles?: any[]
users: User[]
groups: any[]
}
@ -49,7 +50,7 @@ export interface SearchUsersRequest {
page?: string
email?: string
appId?: string
userIds?: string[]
paginated?: boolean
}
export interface CreateAdminUserRequest {

View file

@ -1,4 +1,9 @@
import { checkInviteCode } from "../../../utilities/redis"
import {
checkInviteCode,
getInviteCodes,
updateInviteCode,
} from "../../../utilities/redis"
// import sdk from "../../../sdk"
import * as userSdk from "../../../sdk/users"
import env from "../../../environment"
import {
@ -28,6 +33,7 @@ import {
platform,
} from "@budibase/backend-core"
import { checkAnyUserExists } from "../../../utilities/users"
import { isEmailConfigured } from "../../../utilities/email"
const MAX_USERS_UPLOAD_LIMIT = 1000
@ -179,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
@ -218,9 +236,71 @@ export const tenantUserLookup = async (ctx: any) => {
}
}
/*
Encapsulate the app user onboarding flows here.
*/
export const onboardUsers = async (ctx: any) => {
const request = ctx.request.body as InviteUsersRequest | BulkUserRequest
const isBulkCreate = "create" in request
const emailConfigured = await isEmailConfigured()
let onboardingResponse
if (isBulkCreate) {
// @ts-ignore
const { users, groups, roles } = request.create
const assignUsers = users.map((user: User) => (user.roles = roles))
onboardingResponse = await userSdk.bulkCreate(assignUsers, groups)
ctx.body = onboardingResponse
} else if (emailConfigured) {
onboardingResponse = await inviteMultiple(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,
forceResetPassword: true,
roles: invite.userInfo.apps,
admin: { global: false },
builder: { global: false },
tenantId: tenancy.getTenantId(),
}
})
let bulkCreateReponse = await userSdk.bulkCreate(users, [])
// Apply temporary credentials
let createWithCredentials = {
...bulkCreateReponse,
successful: bulkCreateReponse?.successful.map(user => {
return {
...user,
password: createdPasswords[user.email],
}
}),
created: true,
}
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])
let multiRequest = [request] as InviteUsersRequest
const response = await userSdk.invite(multiRequest)
// explicitly throw for single user invite
if (response.unsuccessful.length) {
@ -234,6 +314,8 @@ export const invite = async (ctx: any) => {
ctx.body = {
message: "Invitation has been sent.",
successful: response.successful,
unsuccessful: response.unsuccessful,
}
}
@ -255,6 +337,53 @@ export const checkInvite = async (ctx: any) => {
}
}
export const getUserInvites = async (ctx: any) => {
let invites
try {
// Restricted to the currently authenticated tenant
invites = await getInviteCodes([ctx.user.tenantId])
} catch (e) {
ctx.throw(400, "There was a problem fetching invites")
}
ctx.body = invites
}
export const updateInvite = async (ctx: any) => {
const { code } = ctx.params
let updateBody = { ...ctx.request.body }
delete updateBody.email
let invite
try {
invite = await checkInviteCode(code, false)
if (!invite) {
throw new Error("The invite could not be retrieved")
}
} catch (e) {
ctx.throw(400, "There was a problem with the invite")
}
let updated = {
...invite,
}
if (!updateBody?.apps || !Object.keys(updateBody?.apps).length) {
updated.info.apps = []
} else {
updated.info = {
...invite.info,
apps: {
...invite.info.apps,
...updateBody.apps,
},
}
}
await updateInviteCode(code, updated)
ctx.body = { ...invite }
}
export const inviteAccept = async (
ctx: Ctx<AcceptUserInviteRequest, AcceptUserInviteResponse>
) => {
@ -263,13 +392,23 @@ 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 () => {
const saved = await userSdk.save({
let request = {
firstName,
lastName,
password,
email,
roles: info.apps,
tenantId: info.tenantId,
}
delete info.apps
request = {
...request,
...info,
})
}
const saved = await userSdk.save(request)
const db = tenancy.getGlobalDB()
const user = await db.get(saved._id)
await events.user.inviteAccepted(user)

View file

@ -30,7 +30,11 @@ describe("/api/global/users", () => {
email
)
expect(res.body).toEqual({ message: "Invitation has been sent." })
expect(res.body?.message).toBe("Invitation has been sent.")
expect(res.body?.unsuccessful.length).toBe(0)
expect(res.body?.successful.length).toBe(1)
expect(res.body?.successful[0].email).toBe(email)
expect(sendMailMock).toHaveBeenCalled()
expect(code).toBeDefined()
expect(events.user.invited).toBeCalledTimes(1)

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

@ -57,11 +57,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
@ -234,7 +245,7 @@ export const save = async (
const tenantId = tenancy.getTenantId()
const db = tenancy.getGlobalDB()
let { email, _id, userGroups = [] } = user
let { email, _id, userGroups = [], roles } = user
if (!email && !_id) {
throw new Error("_id or email is required")
@ -276,6 +287,10 @@ export const save = async (
builtUser.roles = dbUser.roles
}
if (!dbUser && roles?.length) {
builtUser.roles = { ...roles }
}
// make sure we set the _id field for a new user
// Also if this is a new user, associate groups with them
let groupPromises = []

View file

@ -7,7 +7,7 @@ function getExpirySecondsForDB(db: string) {
return 3600
case redis.utils.Databases.INVITATIONS:
// a day
return 86400
return 604800
}
}
@ -29,6 +29,20 @@ async function writeACode(db: string, value: any) {
return code
}
async function updateACode(db: string, code: string, value: any) {
const client = await getClient(db)
await client.store(code, value, getExpirySecondsForDB(db))
}
/**
* Given an invite code and invite body, allow the update an existing/valid invite in redis
* @param {string} inviteCode The invite code for an invite in redis
* @param {object} value The body of the updated user invitation
*/
export async function updateInviteCode(inviteCode: string, value: string) {
await updateACode(redis.utils.Databases.INVITATIONS, inviteCode, value)
}
async function getACode(db: string, code: string, deleteCode = true) {
const client = await getClient(db)
const value = await client.get(code)
@ -113,3 +127,27 @@ export async function checkInviteCode(
throw "Invitation is not valid or has expired, please request a new one."
}
}
/**
Get all currently available user invitations.
@return {Object[]} A list of all objects containing invite metadata
**/
export async function getInviteCodes(tenantIds?: string[]) {
const client = await getClient(redis.utils.Databases.INVITATIONS)
const invites: any[] = await client.scan()
const results = invites.map(invite => {
return {
...invite.value,
code: invite.key,
}
})
return results.reduce((acc, invite) => {
if (tenantIds?.length && tenantIds.includes(invite.info.tenantId)) {
acc.push(invite)
} else {
acc.push(invite)
}
return acc
}, [])
}