1
0
Fork 0
mirror of synced 2024-10-03 19:43:32 +13:00

add InputPicker component and finish onboarding flow

This commit is contained in:
Peter Clement 2022-06-29 19:03:32 +01:00
parent 82ebc9526f
commit 4c4a6ccb14
23 changed files with 954 additions and 255 deletions

View file

@ -0,0 +1,215 @@
<script>
import "@spectrum-css/inputgroup/dist/index-vars.css"
import "@spectrum-css/popover/dist/index-vars.css"
import "@spectrum-css/menu/dist/index-vars.css"
import { fly } from "svelte/transition"
import { createEventDispatcher } from "svelte"
import clickOutside from "../../Actions/click_outside"
export let inputValue
export let dropdownValue
export let id = null
export let inputType = "text"
export let placeholder = "Choose an option or type"
export let disabled = false
export let readonly = false
export let updateOnChange = true
export let error = null
export let options = []
export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value")
export let isOptionSelected = () => false
const dispatch = createEventDispatcher()
let open = false
let focus = false
$: fieldText = getFieldText(dropdownValue, options, placeholder)
const getFieldText = (dropdownValue, options, placeholder) => {
// Always use placeholder if no value
if (dropdownValue == null || dropdownValue === "") {
return placeholder || "Choose an option or type"
}
// Wait for options to load if there is a value but no options
if (!options?.length) {
return ""
}
// Render the label if the selected option is found, otherwise raw value
const selected = options.find(
option => getOptionValue(option) === dropdownValue
)
return selected ? getOptionLabel(selected) : dropdownValue
}
const updateValue = newValue => {
if (readonly) {
return
}
dispatch("change", newValue)
}
const onFocus = () => {
if (readonly) {
return
}
focus = true
}
const onBlur = event => {
if (readonly) {
return
}
focus = false
updateValue(event.target.value)
}
const onInput = event => {
if (readonly || !updateOnChange) {
return
}
updateValue(event.target.value)
}
const updateValueOnEnter = event => {
if (readonly) {
return
}
if (event.key === "Enter") {
updateValue(event.target.value)
}
}
const onClick = () => {
dispatch("click")
if (readonly) {
return
}
open = true
}
const onPick = newValue => {
dispatch("pick", newValue)
open = false
}
const extractProperty = (value, property) => {
if (value && typeof value === "object") {
return value[property]
}
return value
}
</script>
<div
class="spectrum-InputGroup"
class:is-invalid={!!error}
class:is-disabled={disabled}
>
<div
class="spectrum-Textfield spectrum-InputGroup-textfield"
class:is-invalid={!!error}
class:is-disabled={disabled}
class:is-focused={focus}
>
<input
{id}
on:click
on:blur
on:focus
on:input
on:keyup
on:blur={onBlur}
on:focus={onFocus}
on:input={onInput}
on:keyup={updateValueOnEnter}
value={inputValue || ""}
placeholder={placeholder || ""}
{disabled}
{readonly}
{inputType}
class="spectrum-Textfield-input spectrum-InputGroup-input"
/>
</div>
<div style="width: 30%">
<button
{id}
class="spectrum-Picker spectrum-Picker--sizeM override-borders"
{disabled}
class:is-open={open}
aria-haspopup="listbox"
on:mousedown={onClick}
>
<span class="spectrum-Picker-label">
{fieldText}
</span>
<svg
class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Chevron100" />
</svg>
</button>
{#if open}
<div
use:clickOutside={() => (open = false)}
transition:fly|local={{ y: -20, duration: 200 }}
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open"
>
<ul class="spectrum-Menu" role="listbox">
{#each options as option, idx}
<li
class="spectrum-Menu-item"
class:is-selected={isOptionSelected(getOptionValue(option, idx))}
role="option"
aria-selected="true"
tabindex="0"
on:click={() => onPick(getOptionValue(option, idx))}
>
<span class="spectrum-Menu-itemLabel">
{getOptionLabel(option, idx)}
</span>
<svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Checkmark100" />
</svg>
</li>
{/each}
</ul>
</div>
{/if}
</div>
</div>
<style>
.spectrum-InputGroup {
min-width: 0;
width: 100%;
}
.spectrum-InputGroup-input {
border-right-width: 1px;
}
.spectrum-Textfield {
width: 100%;
}
.spectrum-Textfield-input {
width: 0;
}
.override-borders {
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
.spectrum-Popover {
max-height: 240px;
z-index: 999;
top: 100%;
}
</style>

View file

@ -13,6 +13,7 @@
export let readonly = false export let readonly = false
export let autocomplete = false export let autocomplete = false
export let sort = false export let sort = false
export let autoWidth = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: selectedLookupMap = getSelectedLookupMap(value) $: selectedLookupMap = getSelectedLookupMap(value)
@ -85,4 +86,5 @@
{getOptionValue} {getOptionValue}
onSelectOption={toggleOption} onSelectOption={toggleOption}
{sort} {sort}
{autoWidth}
/> />

View file

@ -0,0 +1,55 @@
<script>
import Field from "./Field.svelte"
import InputDropdown from "./Core/InputDropdown.svelte"
import { createEventDispatcher } from "svelte"
export let inputValue = null
export let dropdownValue = null
export let inputType = "text"
export let label = null
export let labelPosition = "above"
export let placeholder = null
export let disabled = false
export let readonly = false
export let error = null
export let updateOnChange = true
export let quiet = false
export let dataCy
export let autofocus
export let options = []
const dispatch = createEventDispatcher()
const onPick = e => {
dropdownValue = e.detail
dispatch("pick", e.detail)
}
const onChange = e => {
inputValue = e.detail
dispatch("change", e.detail)
}
</script>
<Field {label} {labelPosition} {error}>
<InputDropdown
{dataCy}
{updateOnChange}
{error}
{disabled}
{readonly}
{inputValue}
{dropdownValue}
{placeholder}
{inputType}
{quiet}
{autofocus}
{options}
on:change={onChange}
on:pick={onPick}
on:click
on:input
on:blur
on:focus
on:keyup
/>
</Field>

View file

@ -14,7 +14,7 @@
export let getOptionLabel = option => option export let getOptionLabel = option => option
export let getOptionValue = option => option export let getOptionValue = option => option
export let sort = false export let sort = false
export let autoWidth = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
value = e.detail value = e.detail
@ -33,6 +33,7 @@
{sort} {sort}
{getOptionLabel} {getOptionLabel}
{getOptionValue} {getOptionValue}
{autoWidth}
on:change={onChange} on:change={onChange}
on:click on:click
/> />

View file

@ -445,7 +445,7 @@
width: 100%; width: 100%;
border-radius: 0; border-radius: 0;
display: grid; display: grid;
overflow: auto; overflow: visible;
} }
/* Header */ /* Header */
@ -513,7 +513,7 @@
z-index: 3; z-index: 3;
} }
.spectrum-Table-headCell .title { .spectrum-Table-headCell .title {
overflow: hidden; overflow: visible;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.spectrum-Table-headCell:hover .spectrum-Table-editIcon { .spectrum-Table-headCell:hover .spectrum-Table-editIcon {

View file

@ -23,6 +23,7 @@ export { default as Icon, directions } from "./Icon/Icon.svelte"
export { default as Toggle } from "./Form/Toggle.svelte" export { default as Toggle } from "./Form/Toggle.svelte"
export { default as RadioGroup } from "./Form/RadioGroup.svelte" export { default as RadioGroup } from "./Form/RadioGroup.svelte"
export { default as Checkbox } from "./Form/Checkbox.svelte" export { default as Checkbox } from "./Form/Checkbox.svelte"
export { default as InputDropdown } from "./Form/InputDropdown.svelte"
export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte" export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte"
export { default as Popover } from "./Popover/Popover.svelte" export { default as Popover } from "./Popover/Popover.svelte"
export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte" export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte"
@ -71,6 +72,7 @@ export { default as ListItem } from "./List/ListItem.svelte"
// Renderers // Renderers
export { default as BoldRenderer } from "./Table/BoldRenderer.svelte" export { default as BoldRenderer } from "./Table/BoldRenderer.svelte"
export { default as CodeRenderer } from "./Table/CodeRenderer.svelte" export { default as CodeRenderer } from "./Table/CodeRenderer.svelte"
export { default as InternalRenderer } from "./Table/InternalRenderer.svelte"
// Typography // Typography
export { default as Body } from "./Typography/Body.svelte" export { default as Body } from "./Typography/Body.svelte"

View file

@ -0,0 +1,73 @@
<script>
import { ActionButton, Icon, Search, Divider, Detail } from "@budibase/bbui"
export let searchTerm = ""
export let selected
export let filtered
export let addAll
export let select
export let title
export let key
</script>
<div style="padding: var(--spacing-m)">
<Search placeholder="Search" bind:value={searchTerm} />
<div class="header sub-header">
<div>
<Detail
>{filtered.length} {title}{filtered.length === 1 ? "" : "s"}</Detail
>
</div>
<div>
<ActionButton on:click={addAll} emphasized size="S">Add all</ActionButton>
</div>
</div>
<Divider noMargin />
<div>
{#each filtered as item}
<div
on:click={select(item._id)}
style="padding-bottom: var(--spacing-m)"
class="selection"
>
<div>
{item[key]}
</div>
{#if selected.includes(item._id)}
<div>
<Icon
color="var(--spectrum-global-color-blue-600);"
name="Checkmark"
/>
</div>
{/if}
</div>
{/each}
</div>
</div>
<style>
.header {
align-items: center;
padding: var(--spacing-m) 0 var(--spacing-m) 0;
display: flex;
justify-content: space-between;
}
.selection {
align-items: end;
display: flex;
justify-content: space-between;
cursor: pointer;
}
.selection > :first-child {
padding-top: var(--spacing-m);
}
.sub-header {
display: flex;
justify-content: space-between;
}
</style>

View file

@ -12,7 +12,7 @@
$: wide = $: wide =
$page.path.includes("email/:template") || $page.path.includes("email/:template") ||
$page.path.includes("users") || ($page.path.includes("users") && !$page.path.includes(":userId")) ||
($page.path.includes("groups") && !$page.path.includes(":groupId")) ($page.path.includes("groups") && !$page.path.includes(":groupId"))
</script> </script>

View file

@ -8,14 +8,13 @@
Body, Body,
Icon, Icon,
Popover, Popover,
Search,
Divider,
Detail,
notifications, notifications,
List, List,
ListItem, ListItem,
StatusLight, StatusLight,
} from "@budibase/bbui" } from "@budibase/bbui"
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
import { users, apps, groups } from "stores/portal" import { users, apps, groups } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
@ -47,8 +46,9 @@
} }
async function selectUser(id) { async function selectUser(id) {
let selectedUser = selectedUsers.find(user_id => user_id === id) let selectedUser = selectedUsers.includes(id)
let enrichedUser = $users.find(user => user._id === id) let enrichedUser = $users.find(user => user._id === id)
if (selectedUser) { if (selectedUser) {
selectedUsers = selectedUsers.filter(id => id !== selectedUser) selectedUsers = selectedUsers.filter(id => id !== selectedUser)
let newUsers = group.users.filter(user => user._id !== id) let newUsers = group.users.filter(user => user._id !== id)
@ -57,6 +57,7 @@
selectedUsers = [...selectedUsers, id] selectedUsers = [...selectedUsers, id]
group.users.push(enrichedUser) group.users.push(enrichedUser)
} }
await groups.actions.save(group) await groups.actions.save(group)
} }
@ -97,55 +98,24 @@
<Button on:click={popover.show()} icon="UserAdd" cta>Add User</Button> <Button on:click={popover.show()} icon="UserAdd" cta>Add User</Button>
</div> </div>
<Popover align="right" bind:this={popover} anchor={popoverAnchor}> <Popover align="right" bind:this={popover} anchor={popoverAnchor}>
<div style="padding: var(--spacing-m)"> <UserGroupPicker
<Search placeholder="Search" bind:value={searchTerm} /> key={"email"}
<div class="users-header header"> title={"User"}
<div> bind:searchTerm
<Detail bind:selected={selectedUsers}
>{filteredUsers.length} User{filteredUsers.length === 1 bind:filtered={filteredUsers}
? "" {addAll}
: "s"}</Detail select={selectUser}
> />
</div>
<div>
<ActionButton on:click={addAll} emphasized size="S"
>Add all</ActionButton
>
</div>
</div>
<Divider noMargin />
<div>
{#each filteredUsers as user}
<div
on:click={selectUser(user._id)}
style="padding-bottom: var(--spacing-m)"
class="user-selection"
>
<div>
{user.email}
</div>
{#if selectedUsers.includes(user._id)}
<div>
<Icon
color="var(--spectrum-global-color-blue-600);"
name="Checkmark"
/>
</div>
{/if}
</div>
{/each}
</div>
</div>
</Popover> </Popover>
</div> </div>
<List> <List>
{#if group?.users.length} {#if group?.users.length}
{#each group.users as user} {#each group.users as user}
<ListItem subtitle={user.access} title={user.email} avatar <ListItem subtitle={user?.access} title={user?.email} avatar
><Icon ><Icon
on:click={() => removeUser(user._id)} on:click={() => removeUser(user?._id)}
hoverable hoverable
size="L" size="L"
name="Close" name="Close"
@ -180,7 +150,7 @@
</ListItem> </ListItem>
{/each} {/each}
{:else} {:else}
<ListItem icon="UserGroup" title="You have no users in this team" /> <ListItem icon="UserGroup" title="No apps" />
{/if} {/if}
</List> </List>
</Layout> </Layout>
@ -190,24 +160,6 @@
margin-left: var(--spacing-l); margin-left: var(--spacing-l);
} }
.users-header {
align-items: center;
padding: var(--spacing-m) 0 var(--spacing-m) 0;
display: flex;
justify-content: space-between;
}
.user-selection {
align-items: end;
display: flex;
justify-content: space-between;
cursor: pointer;
}
.user-selection > :first-child {
padding-top: var(--spacing-m);
}
.header { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View file

@ -2,43 +2,44 @@
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { import {
ActionButton, ActionButton,
ActionMenu,
Avatar,
Button, Button,
Layout, Layout,
Heading, Heading,
Body, Body,
Divider,
Label, Label,
List,
ListItem,
Icon,
Input, Input,
MenuItem,
Popover,
Select, Select,
Toggle,
Modal, Modal,
Table,
ModalContent, ModalContent,
notifications, notifications,
StatusLight,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount } from "svelte"
import { fetchData } from "helpers" import { fetchData } from "helpers"
import { users, auth } from "stores/portal" import { users, auth, groups } from "stores/portal"
import { Constants } from "@budibase/frontend-core"
import TagsRenderer from "./_components/RolesTagsTableRenderer.svelte"
import UpdateRolesModal from "./_components/UpdateRolesModal.svelte"
import ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte" import ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte"
import { RoleUtils } from "@budibase/frontend-core"
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
export let userId export let userId
let deleteUserModal let deleteUserModal
let editRolesModal
let resetPasswordModal let resetPasswordModal
let popoverAnchor
const roleSchema = { let searchTerm = ""
name: { displayName: "App" }, let popover
role: {}, let selectedGroups = []
}
const noRoleSchema = {
name: { displayName: "App" },
}
$: defaultRoleId = $userFetch?.data?.builder?.global ? "ADMIN" : "" $: defaultRoleId = $userFetch?.data?.builder?.global ? "ADMIN" : ""
// Merge the Apps list and the roles response to get something that makes sense for the table // Merge the Apps list and the roles response to get something that makes sense for the table
$: allAppList = Object.keys($apps?.data).map(id => { $: allAppList = Object.keys($apps?.data).map(id => {
const roleId = $userFetch?.data?.roles?.[id] || defaultRoleId const roleId = $userFetch?.data?.roles?.[id] || defaultRoleId
@ -50,19 +51,23 @@
} }
}) })
$: appList = allAppList.filter(app => !!app.role[0]) // Used for searching through groups in the add group popover
$: noRoleAppList = allAppList $: filteredGroups = $groups.filter(
.filter(app => !app.role[0]) group =>
.map(app => { selectedGroups &&
delete app.role group?.name?.toLowerCase().includes(searchTerm.toLowerCase())
return app )
})
let selectedApp $: appList = allAppList.filter(app => !!app.role[0])
$: userGroups = $groups.filter(x => {
return x.users?.some(y => {
return y._id === userId
})
})
const userFetch = fetchData(`/api/global/users/${userId}`) const userFetch = fetchData(`/api/global/users/${userId}`)
const apps = fetchData(`/api/global/roles`) const apps = fetchData(`/api/global/roles`)
async function deleteUser() { async function deleteUser() {
try { try {
await users.delete(userId) await users.delete(userId)
@ -73,8 +78,17 @@
} }
} }
let toggleDisabled = false function getHighestRole(roles) {
let highestRole
let highestRoleNumber = 0
roles.forEach(role => {
let roleNumber = RoleUtils.getRolePriority(role._id)
if (roleNumber > highestRoleNumber) {
highestRole = role
}
})
return highestRole
}
async function updateUserFirstName(evt) { async function updateUserFirstName(evt) {
try { try {
await users.save({ ...$userFetch?.data, firstName: evt.target.value }) await users.save({ ...$userFetch?.data, firstName: evt.target.value })
@ -84,6 +98,13 @@
} }
} }
async function removeGroup(id) {
let updatedGroup = $groups.find(x => x._id === id)
let newUsers = updatedGroup.users.filter(user => user._id !== userId)
updatedGroup.users = newUsers
groups.actions.save(updatedGroup)
}
async function updateUserLastName(evt) { async function updateUserLastName(evt) {
try { try {
await users.save({ ...$userFetch?.data, lastName: evt.target.value }) await users.save({ ...$userFetch?.data, lastName: evt.target.value })
@ -93,6 +114,30 @@
} }
} }
async function updateUserRole() {
return
}
async function addGroup(groupId) {
let selectedGroup = selectedGroups.includes(groupId)
let newUser = $users.find(user => user._id === userId)
let group = $groups.find(group => group._id === groupId)
if (selectedGroup) {
selectedGroups = selectedGroups.filter(id => id === selectedGroup)
let newUsers = group.users.filter(user => user._id !== newUser._id)
group.users = newUsers
} else {
selectedGroups = [...selectedGroups, groupId]
group.users.push(newUser)
}
await groups.actions.save(group)
}
function addAll() {}
/*
async function toggleFlag(flagName, detail) { async function toggleFlag(flagName, detail) {
toggleDisabled = true toggleDisabled = true
try { try {
@ -104,6 +149,7 @@
toggleDisabled = false toggleDisabled = false
} }
async function toggleBuilderAccess({ detail }) { async function toggleBuilderAccess({ detail }) {
return toggleFlag("builder", detail) return toggleFlag("builder", detail)
} }
@ -116,38 +162,56 @@
selectedApp = detail selectedApp = detail
editRolesModal.show() editRolesModal.show()
} }
*/
onMount(async () => {
try {
await groups.actions.init()
} catch (error) {
notifications.error("Error getting User groups")
}
})
</script> </script>
<Layout noPadding> <Layout gap="L" noPadding>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<div> <div>
<ActionButton <ActionButton on:click={() => $goto("./")} size="S" icon="ArrowLeft">
on:click={() => $goto("./")} Back
quiet
size="S"
icon="BackAndroid"
>
Back to users
</ActionButton> </ActionButton>
</div> </div>
<Heading>User: {$userFetch?.data?.email}</Heading>
<Body>
Change user settings and update their app roles. Also contains the ability
to delete the user as well as force reset their password.
</Body>
</Layout> </Layout>
<Divider size="S" /> <Layout gap="XS" noPadding>
<div class="title">
<div>
<div style="display: flex;">
<Avatar size="XXL" initials="PC" />
<div class="subtitle">
<Heading size="S"
>{$userFetch?.data?.firstName +
" " +
$userFetch?.data?.lastName}</Heading
>
<Body size="XS">{$userFetch?.data?.email}</Body>
</div>
</div>
</div>
<div>
<ActionMenu align="right">
<span slot="control">
<Icon hoverable name="More" />
</span>
<MenuItem on:click={resetPasswordModal.show} icon="Refresh"
>Force Password Reset</MenuItem
>
<MenuItem on:click={deleteUserModal.show} icon="Delete"
>Delete</MenuItem
>
</ActionMenu>
</div>
</div>
</Layout>
<Layout gap="S" noPadding> <Layout gap="S" noPadding>
<Heading size="S">General</Heading>
<div class="fields"> <div class="fields">
<div class="field">
<Label size="L">Email</Label>
<Input disabled thin value={$userFetch?.data?.email} />
</div>
<div class="field">
<Label size="L">Group(s)</Label>
<Select disabled options={["All users"]} value="All users" />
</div>
<div class="field"> <div class="field">
<Label size="L">First name</Label> <Label size="L">First name</Label>
<Input <Input
@ -167,71 +231,91 @@
<!-- don't let a user remove the privileges that let them be here --> <!-- don't let a user remove the privileges that let them be here -->
{#if userId !== $auth.user._id} {#if userId !== $auth.user._id}
<div class="field"> <div class="field">
<Label size="L">Development access</Label> <Label size="L">Role</Label>
<Toggle <Select options={Constants.BbRoles} on:blur={updateUserRole} />
text=""
value={$userFetch?.data?.builder?.global}
on:change={toggleBuilderAccess}
disabled={toggleDisabled}
/>
</div>
<div class="field">
<Label size="L">Administration access</Label>
<Toggle
text=""
value={$userFetch?.data?.admin?.global}
on:change={toggleAdminAccess}
disabled={toggleDisabled}
/>
</div> </div>
{/if} {/if}
</div> </div>
<div class="regenerate">
<ActionButton
size="S"
icon="Refresh"
quiet
on:click={resetPasswordModal.show}>Force password reset</ActionButton
>
</div>
</Layout> </Layout>
<Divider size="S" />
<Layout gap="S" noPadding> <!-- User groups -->
<Heading size="S">Configure roles</Heading>
<Body>Specify a role to grant access to an app.</Body>
<Table
on:click={openUpdateRolesModal}
schema={roleSchema}
data={appList}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[{ column: "role", component: TagsRenderer }]}
/>
</Layout>
<Layout gap="S" noPadding>
<Heading size="XS">No Access</Heading>
<Body
>Apps do not appear in the users portal. Public pages may still be viewed
if visited directly.</Body
>
<Table
on:click={openUpdateRolesModal}
schema={noRoleSchema}
data={noRoleAppList}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
/>
</Layout>
<Divider size="S" />
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="S">Delete user</Heading> <div class="tableTitle">
<Body>Deleting a user completely removes them from your account.</Body> <div>
<Heading size="XS">User groups</Heading>
<Body size="S"
>Manage apps that this User group has been assigned to</Body
>
</div>
<div bind:this={popoverAnchor}>
<Button on:click={popover.show()} icon="UserGroup" cta
>Add User Group</Button
>
</div>
<Popover align="right" bind:this={popover} anchor={popoverAnchor}>
<UserGroupPicker
key={"name"}
title={"Group"}
bind:searchTerm
bind:selected={selectedGroups}
bind:filtered={filteredGroups}
{addAll}
select={addGroup}
/>
</Popover>
</div>
<List>
{#if userGroups.length}
{#each userGroups as group}
<ListItem
title={group.name}
icon={group.icon}
iconBackground={group.color}
><Icon
on:click={removeGroup(group._id)}
hoverable
size="L"
name="Close"
/></ListItem
>
{/each}
{:else}
<ListItem icon="UserGroup" title="No groups" />
{/if}
</List>
</Layout>
<!-- User Apps -->
<Layout gap="S" noPadding>
<div class="appsTitle">
<Heading weight="light" size="XS">Apps</Heading>
<div style="margin-top: var(--spacing-xs)">
<Body size="S"
>Manage apps that this User group has been assigned to</Body
>
</div>
</div>
<List>
{#if appList.length}
{#each appList as app}
<ListItem title={app.name} icon="Apps">
<div class="title ">
<StatusLight
color={RoleUtils.getRoleColour(getHighestRole(app.role)._id)}
/>
<div style="margin-left: var(--spacing-s);">
<Body size="XS">{getHighestRole(app.role).name}</Body>
</div>
</div>
</ListItem>
{/each}
{:else}
<ListItem icon="Apps" title="No apps" />
{/if}
</List>
</Layout> </Layout>
<div class="delete-button">
<Button warning on:click={deleteUserModal.show}>Delete user</Button>
</div>
</Layout> </Layout>
<Modal bind:this={deleteUserModal}> <Modal bind:this={deleteUserModal}>
@ -248,13 +332,6 @@
</Body> </Body>
</ModalContent> </ModalContent>
</Modal> </Modal>
<Modal bind:this={editRolesModal}>
<UpdateRolesModal
app={selectedApp}
user={$userFetch.data}
on:update={userFetch.refresh}
/>
</Modal>
<Modal bind:this={resetPasswordModal}> <Modal bind:this={resetPasswordModal}>
<ForceResetPasswordModal <ForceResetPasswordModal
user={$userFetch.data} user={$userFetch.data}
@ -272,9 +349,26 @@
grid-template-columns: 32% 1fr; grid-template-columns: 32% 1fr;
align-items: center; align-items: center;
} }
.regenerate {
position: absolute; .title {
top: 0; display: flex;
right: 0; align-items: center;
justify-content: space-between;
}
.tableTitle {
display: flex;
justify-content: space-between;
margin-bottom: var(--spacing-m);
}
.subtitle {
padding: 0 0 0 var(--spacing-m);
display: inline-block;
}
.appsTitle {
display: flex;
flex-direction: column;
} }
</style> </style>

View file

@ -1,82 +1,78 @@
<script> <script>
import { import {
Body, ActionButton,
Input, Layout,
Select,
ModalContent,
notifications,
Toggle,
Label, Label,
ModalContent,
Multiselect,
notifications,
InputDropdown,
} from "@budibase/bbui" } from "@budibase/bbui"
import { createValidationStore, emailValidator } from "helpers/validation" import { users, groups } from "stores/portal"
import { users } from "stores/portal"
import analytics, { Events } from "analytics" import analytics, { Events } from "analytics"
import { Constants } from "@budibase/frontend-core"
export let disabled export let disabled
export let showOnboardingTypeModal
const options = ["Email onboarding", "Basic onboarding"] const options = ["Email onboarding", "Basic onboarding"]
let selected = options[0] let selected = options[0]
let builder, admin let builder, admin
const [email, error, touched] = createValidationStore("", emailValidator) $: userData = [{ email: "", role: "", error: null }]
/*
async function createUserFlow() { async function createUserFlow() {
try { try {
const res = await users.invite({ email: $email, builder, admin }) const res = await users.invite({ email: "", builder, admin })
notifications.success(res.message) notifications.success(res.message)
analytics.captureEvent(Events.USER.INVITE, { type: selected }) analytics.captureEvent(Events.USER.INVITE, { type: selected })
} catch (error) { } catch (error) {
notifications.error("Error inviting user") notifications.error("Error inviting user")
} }
} }
*/
function addNewInput() {
userData = [...userData, { email: "", role: "" }]
}
</script> </script>
<ModalContent <ModalContent
onConfirm={createUserFlow} onConfirm={showOnboardingTypeModal}
size="M" size="M"
title="Add new user" title="Add new user"
confirmText="Add user" confirmText="Add user"
confirmDisabled={disabled} confirmDisabled={disabled}
cancelText="Cancel" cancelText="Cancel"
disabled={$error}
showCloseIcon={false} showCloseIcon={false}
> >
<Body size="S"> <Layout noPadding gap="XS">
If you have SMTP configured and an email for the new user, you can use the <Label>Email Address</Label>
automated email onboarding flow. Otherwise, use our basic onboarding process
with autogenerated passwords. {#each userData as input, index}
</Body> <InputDropdown
<Select inputType="email"
placeholder={null} bind:inputValue={input.email}
bind:value={selected} bind:dropdownValue={input.role}
on:change options={Constants.BbRoles}
{options} error={input.error}
label="Add new user via:" />
/> {/each}
<Input <div>
type="email" <ActionButton on:click={addNewInput} icon="Add">Add email</ActionButton>
bind:value={$email}
error={$touched && $error}
placeholder="john@doe.com"
label="Email"
/>
<div>
<div class="toggle">
<Label size="L">Development access</Label>
<Toggle text="" bind:value={builder} />
</div> </div>
<div class="toggle"> </Layout>
<Label size="L">Administration access</Label>
<Toggle text="" bind:value={admin} /> <Multiselect
</div> placeholder="Select User Groups"
</div> label="User Groups"
options={$groups}
getOptionLabel={option => option.name}
getOptionValue={option => option.name}
/>
</ModalContent> </ModalContent>
<style> <style>
.toggle { :global(.spectrum-Picker) {
display: grid; border-top-left-radius: 0px;
grid-template-columns: 78% 1fr;
align-items: center;
width: 50%;
} }
</style> </style>

View file

@ -21,6 +21,7 @@
<style> <style>
.align { .align {
display: flex; display: flex;
overflow: hidden;
} }
.spacing { .spacing {

View file

@ -21,6 +21,7 @@
<style> <style>
.align { .align {
display: flex; display: flex;
overflow: hidden;
} }
.opacity { .opacity {

View file

@ -0,0 +1,52 @@
<script>
import { Body, ModalContent, RadioGroup, Multiselect } from "@budibase/bbui"
import { groups } from "stores/portal"
import { Constants } from "@budibase/frontend-core"
</script>
<ModalContent
size="M"
title="Import users"
confirmText="Done"
showCancelButton={false}
cancelText="Cancel"
showCloseIcon={false}
>
<Body size="S">Import your users email addrresses from a CSV</Body>
<div class="container">
<div class="inner">
<Body size="S">Upload</Body>
</div>
</div>
<RadioGroup options={Constants.BuilderRoleDescriptions} />
<Multiselect
placeholder="Select User Groups"
label="User Groups"
options={$groups}
getOptionLabel={option => option.name}
getOptionValue={option => option.name}
/>
</ModalContent>
<style>
.inner {
display: flex;
}
:global(.spectrum-Picker) {
border-top-left-radius: 0px;
}
.container {
width: 100%;
height: var(--spectrum-alias-item-height-l);
background: var(--spectrum-global-color-gray-200);
display: flex;
justify-content: center;
align-items: center;
}
</style>

View file

@ -25,6 +25,7 @@
.align { .align {
display: flex; display: flex;
align-items: center; align-items: center;
overflow: hidden;
} }
.spacing { .spacing {

View file

@ -0,0 +1,107 @@
<script>
import { ModalContent, Body, Layout, Icon } from "@budibase/bbui"
export let showConfirmationModal
let emailOnboardingKey = "emailOnboarding"
let basicOnboaridngKey = "basicOnboarding"
let selectedOnboardingType
</script>
<ModalContent
size="M"
title="Choose your onboarding"
confirmText="Done"
cancelText="Cancel"
showCloseIcon={false}
onConfirm={() => showConfirmationModal(selectedOnboardingType)}
disabled={!selectedOnboardingType}
>
<Layout noPadding gap="S">
<div
class="onboarding-type item"
class:selected={selectedOnboardingType == emailOnboardingKey}
on:click={() => {
selectedOnboardingType = emailOnboardingKey
}}
>
<div class="content onboarding-type-wrap">
<Icon name="WebPage" />
<div class="onboarding-type-text">
<Body size="S">Send email invites</Body>
</div>
</div>
<div style="color: var(--spectrum-global-color-green-600); float: right">
{#if selectedOnboardingType == emailOnboardingKey}
<div class="checkmark-spacing">
<Icon size="S" name="CheckmarkCircle" />
</div>
{/if}
</div>
</div>
<div
class="onboarding-type item"
class:selected={selectedOnboardingType == basicOnboaridngKey}
on:click={() => {
selectedOnboardingType = basicOnboaridngKey
}}
>
<div class="content onboarding-type-wrap">
<Icon name="Key" />
<div class="onboarding-type-text">
<Body size="S">Generate passwords for each user</Body>
</div>
</div>
<div style="color: var(--spectrum-global-color-green-600); float: right">
{#if selectedOnboardingType == basicOnboaridngKey}
<div class="checkmark-spacing">
<Icon size="S" name="CheckmarkCircle" />
</div>
{/if}
</div>
</div>
</Layout>
</ModalContent>
<style>
.onboarding-type.item {
padding: var(--spectrum-alias-item-padding-xl);
}
.onboarding-type-wrap {
display: flex;
flex-direction: row;
align-items: center;
}
.checkmark-spacing {
margin-right: var(--spacing-m);
}
.content {
letter-spacing: 0px;
}
.item {
cursor: pointer;
grid-gap: var(--spectrum-alias-grid-margin-xsmall);
padding: var(--spectrum-alias-item-padding-s);
background: var(--spectrum-alias-background-color-primary);
transition: 0.3s all;
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px;
border-width: 1px;
display: flex;
justify-content: space-between;
align-items: center;
}
.item:hover,
.selected {
background: var(--spectrum-alias-background-color-tertiary);
}
.onboarding-type-wrap .onboarding-type-text {
padding-left: var(--spectrum-alias-item-padding-xl);
}
.onboarding-type-wrap :global(.spectrum-Icon) {
min-width: var(--spectrum-icon-size-m);
}
.onboarding-type-wrap :global(.spectrum-Heading) {
padding-bottom: var(--spectrum-alias-item-padding-s);
}
</style>

View file

@ -0,0 +1,15 @@
<script>
import { InternalRenderer } from "@budibase/bbui"
export let value
</script>
<div style="display: flex; ">
{value}
<div style="margin-left: 1.5rem;">
<InternalRenderer {value} />
</div>
</div>
<style>
</style>

View file

@ -0,0 +1,61 @@
<script>
import { Body, ModalContent, Table, Icon } from "@budibase/bbui"
import PasswordCopyRenderer from "./PasswordCopyRenderer.svelte"
const schema = {
email: {},
password: {},
}
</script>
<ModalContent
size="S"
title="Accounts created!"
confirmText="Done"
showCancelButton={false}
cancelText="Cancel"
showCloseIcon={false}
>
<Body size="XS"
>All your new users can be accessed through the autogenerated passwords.
Make not of these passwords or download the csv</Body
>
<div class="container">
<div class="inner">
<Icon name="Download" />
<div style="margin-left: var(--spacing-m)">
<Body size="XS">Passwords CSV</Body>
</div>
</div>
</div>
<Table
{schema}
data={[{ email: "test", password: "§xz§§zvzxvxzv" }]}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[{ column: "password", component: PasswordCopyRenderer }]}
/>
</ModalContent>
<style>
.inner {
display: flex;
}
:global(.spectrum-Picker) {
border-top-left-radius: 0px;
}
.container {
width: 100%;
height: var(--spectrum-alias-item-height-l);
background: #009562;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View file

@ -8,19 +8,16 @@
] ]
</script> </script>
<div class="align"> <div>
<Select <Select value={"appUser"} {options} placeholder="Admin" autoWidth quiet />
on:click={e => e.stopPropagation()}
value={"Admin"}
quiet
{options}
placeholder="Admin"
/>
</div> </div>
<style> <style>
.align { div {
display: flex; overflow: visible;
text-overflow: ellipsis;
white-space: nowrap;
z-index: 1000; z-index: 1000;
position: relative;
} }
</style> </style>

View file

@ -2,7 +2,10 @@
import { Icon, ActionMenu, MenuItem } from "@budibase/bbui" import { Icon, ActionMenu, MenuItem } from "@budibase/bbui"
</script> </script>
<div> <div
style=" overflow: hidden;
"
>
<ActionMenu align="right"> <ActionMenu align="right">
<span slot="control"> <span slot="control">
<Icon hoverable name="More" /> <Icon hoverable name="More" />

View file

@ -7,6 +7,7 @@
Table, Table,
Layout, Layout,
Modal, Modal,
ModalContent,
Icon, Icon,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
@ -20,11 +21,17 @@
import SettingsTableRenderer from "./_components/SettingsTableRenderer.svelte" import SettingsTableRenderer from "./_components/SettingsTableRenderer.svelte"
import RoleTableRenderer from "./_components/RoleTableRenderer.svelte" import RoleTableRenderer from "./_components/RoleTableRenderer.svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import OnboardingTypeModal from "./_components/OnboardingTypeModal.svelte"
import PasswordModal from "./_components/PasswordModal.svelte"
import ImportUsersModal from "./_components/ImportUsersModal.svelte"
const schema = { const schema = {
name: {}, name: {},
email: {}, email: {},
role: { noPropagation: true, sortable: false }, role: {
noPropagation: true,
sortable: false,
},
userGroups: { sortable: false, displayName: "User groups" }, userGroups: { sortable: false, displayName: "User groups" },
apps: {}, apps: {},
settings: { settings: {
@ -52,6 +59,12 @@
let search let search
let email let email
let createUserModal,
basicOnboardingModal,
inviteConfirmationModal,
onboardingTypeModal,
passwordModal,
importUsersModal
$: filteredUsers = $users $: filteredUsers = $users
.filter(user => user.email.includes(search || "")) .filter(user => user.email.includes(search || ""))
@ -80,10 +93,16 @@
} }
}) })
let createUserModal function showOnboardingTypeModal() {
let basicOnboardingModal = function openBasicOnboardingModal() { onboardingTypeModal.show()
createUserModal.hide() }
basicOnboardingModal.show()
function showConfirmationModal(onboardingType) {
if (onboardingType === "emailOnboarding") {
inviteConfirmationModal.show()
} else {
passwordModal.show()
}
} }
onMount(async () => { onMount(async () => {
@ -120,7 +139,9 @@
icon="UserAdd" icon="UserAdd"
cta>Add Users</Button cta>Add Users</Button
> >
<Button icon="Import" primary>Import Users</Button> <Button on:click={importUsersModal.show} icon="Import" primary
>Import Users</Button
>
</ButtonGroup> </ButtonGroup>
<Table <Table
on:click={({ detail }) => $goto(`./${detail._id}`)} on:click={({ detail }) => $goto(`./${detail._id}`)}
@ -142,8 +163,34 @@
</Layout> </Layout>
<Modal bind:this={createUserModal}> <Modal bind:this={createUserModal}>
<AddUserModal /> <AddUserModal {showOnboardingTypeModal} />
</Modal> </Modal>
<Modal bind:this={inviteConfirmationModal}>
<ModalContent
showCancelButton={false}
title="Invites sent!"
confirmText="Done"
>
<Body size="S"
>Your users should now recieve an email invite to get access to their
Budibase account</Body
></ModalContent
>
</Modal>
<Modal bind:this={onboardingTypeModal}>
<OnboardingTypeModal {showConfirmationModal} />
</Modal>
<Modal bind:this={passwordModal}>
<PasswordModal />
</Modal>
<Modal bind:this={importUsersModal}>
<ImportUsersModal />
</Modal>
<Modal bind:this={basicOnboardingModal}><BasicOnboardingModal {email} /></Modal> <Modal bind:this={basicOnboardingModal}><BasicOnboardingModal {email} /></Modal>
<style> <style>

View file

@ -56,6 +56,30 @@ export const TableNames = {
USERS: "ta_users", USERS: "ta_users",
} }
export const BbRoles = [
{ label: "App User", value: "appUser" },
{ label: "Developer", value: "developer" },
{ label: "Admin", value: "admin" },
]
export const BuilderRoleDescriptions = [
{
value: "appUser",
icon: "User",
label: "App user - Only has access to published apps",
},
{
value: "developer",
icon: "Hammer",
label: "Developer - Access to the app builder",
},
{
value: "admin",
icon: "Draw",
label: "Admin - Full access",
},
]
/** /**
* API version header attached to all requests. * API version header attached to all requests.
* Version changelog: * Version changelog: