group / user app assignment

This commit is contained in:
Peter Clement 2022-07-05 09:21:59 +01:00
parent 5ce3861ef2
commit 3de2123dc4
23 changed files with 501 additions and 119 deletions

View File

@ -5,6 +5,7 @@
import { fly } from "svelte/transition" import { fly } from "svelte/transition"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import clickOutside from "../../Actions/click_outside" import clickOutside from "../../Actions/click_outside"
import StatusLight from "../../StatusLight/StatusLight.svelte"
export let inputValue export let inputValue
export let dropdownValue export let dropdownValue
@ -18,6 +19,8 @@
export let options = [] export let options = []
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
export let getOptionColour = option => extractProperty(option, "colour")
export let isOptionSelected = () => false export let isOptionSelected = () => false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()

View File

@ -17,7 +17,6 @@
export let autoWidth = false export let autoWidth = false
export let autocomplete = false export let autocomplete = false
export let sort = false export let sort = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let open = false let open = false
$: fieldText = getFieldText(value, options, placeholder) $: fieldText = getFieldText(value, options, placeholder)

View File

@ -6,7 +6,7 @@
import Icon from "../Icon/Icon.svelte" import Icon from "../Icon/Icon.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let value = "Anchor" export let value
export let size = "M" export let size = "M"
export let alignRight = false export let alignRight = false
@ -59,7 +59,7 @@
style={value ? `background: ${value};` : ""} style={value ? `background: ${value};` : ""}
class:placeholder={!value} class:placeholder={!value}
> >
<Icon name={value} /> <Icon name={value || "UserGroup"} />
</div> </div>
</div> </div>
{#if open} {#if open}

View File

@ -24,6 +24,7 @@ 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 InputDropdown } from "./Form/InputDropdown.svelte"
export { default as PickerDropdown } from "./Form/PickerDropdown.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"

View File

@ -22,23 +22,15 @@
export let groupId export let groupId
let popoverAnchor let popoverAnchor
let popover let popover
$: group = $groups.find(x => x._id === groupId)
let searchTerm = "" let searchTerm = ""
let selectedUsers = [] let selectedUsers = []
$: group = $groups.find(x => x._id === groupId)
$: filteredUsers = $users.filter( $: filteredUsers = $users.filter(
user => user =>
selectedUsers && selectedUsers &&
user?.email?.toLowerCase().includes(searchTerm.toLowerCase()) user?.email?.toLowerCase().includes(searchTerm.toLowerCase())
) )
let app_list = [ $: console.log(group)
{
access: "ADMIN",
name: "test app",
icon: "Anchor",
color: "blue",
},
]
async function addAll() { async function addAll() {
selectedUsers = [...selectedUsers, ...filteredUsers] selectedUsers = [...selectedUsers, ...filteredUsers]
group.users = selectedUsers group.users = selectedUsers
@ -138,9 +130,13 @@
</div> </div>
<List> <List>
{#if app_list.length} {#if group?.apps}
{#each app_list as app} {#each group.apps as app}
<ListItem title={app.name} icon={app.icon} iconBackground={app.color}> <ListItem
title={app.name}
icon={app?.icon?.name || "Apps"}
iconBackground={app?.icon?.color || ""}
>
<div class="title "> <div class="title ">
<StatusLight color={RoleUtils.getRoleColour(app.access)} /> <StatusLight color={RoleUtils.getRoleColour(app.access)} />
<div style="margin-left: var(--spacing-s);"> <div style="margin-left: var(--spacing-s);">

View File

@ -45,7 +45,7 @@
<Icon name="WebPage" /> <Icon name="WebPage" />
<div style="margin-left: var(--spacing-l)"> <div style="margin-left: var(--spacing-l)">
{parseInt(group.appCount) || 0} app{parseInt(group.appCount) === 1 {parseInt(group?.apps?.length) || 0} app{parseInt(group?.apps?.length) === 1
? "" ? ""
: "s"} : "s"}
</div> </div>

View File

@ -21,6 +21,7 @@
icon: "", icon: "",
color: "", color: "",
users: [], users: [],
apps: [],
} }
let proPlan = true let proPlan = true
@ -82,13 +83,15 @@
{/if} {/if}
</div> </div>
<div class="groupTable"> {#if proPlan}
{#each $groups as group} <div class="groupTable">
<div> {#each $groups as group}
<UserGroupsRow {saveGroup} {deleteGroup} {group} /> <div>
</div> <UserGroupsRow {saveGroup} {deleteGroup} {group} />
{/each} </div>
</div> {/each}
</div>
{/if}
</Layout> </Layout>
<Modal bind:this={modal}> <Modal bind:this={modal}>

View File

@ -24,7 +24,8 @@
import { onMount } from "svelte" import { onMount } from "svelte"
import { fetchData } from "helpers" import { fetchData } from "helpers"
import { users, auth, groups } from "stores/portal" import { users, auth, groups, apps } from "stores/portal"
import { roles } from "stores/backend"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
import ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte" import ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte"
@ -38,19 +39,28 @@
let searchTerm = "" let searchTerm = ""
let popover let popover
let selectedGroups = [] let selectedGroups = []
$: defaultRoleId = $userFetch?.data?.builder?.global ? "ADMIN" : "" let allAppList = []
$: console.log($apps)
// Merge the Apps list and the roles response to get something that makes sense for the table $: console.log($userFetch.data)
$: allAppList = Object.keys($apps?.data).map(id => {
const roleId = $userFetch?.data?.roles?.[id] || defaultRoleId
const role = $apps?.data?.[id].roles.find(role => role._id === roleId)
return {
...$apps?.data?.[id],
_id: id,
role: [role],
}
})
$: allAppList = $apps
.filter(x => {
if ($userFetch.data?.roles) {
return Object.keys($userFetch.data.roles).find(y => {
return x.appId === y
})
}
})
.map(app => {
let roles = Object.keys($userFetch.data.roles).filter(id => {
return id === app.appId
})
return {
...app,
roles,
}
})
$: console.log(allAppList)
// Used for searching through groups in the add group popover // Used for searching through groups in the add group popover
$: filteredGroups = $groups.filter( $: filteredGroups = $groups.filter(
group => group =>
@ -58,16 +68,13 @@
group?.name?.toLowerCase().includes(searchTerm.toLowerCase()) group?.name?.toLowerCase().includes(searchTerm.toLowerCase())
) )
$: appList = allAppList.filter(app => !!app.role[0])
$: userGroups = $groups.filter(x => { $: userGroups = $groups.filter(x => {
return x.users?.some(y => { return x.users?.find(y => {
return y._id === userId return y._id === userId
}) })
}) })
const userFetch = fetchData(`/api/global/users/${userId}`) const userFetch = fetchData(`/api/global/users/${userId}`)
const apps = fetchData(`/api/global/roles`)
async function deleteUser() { async function deleteUser() {
try { try {
await users.delete(userId) await users.delete(userId)
@ -166,6 +173,7 @@
onMount(async () => { onMount(async () => {
try { try {
await groups.actions.init() await groups.actions.init()
await apps.load()
} catch (error) { } catch (error) {
notifications.error("Error getting User groups") notifications.error("Error getting User groups")
} }
@ -243,9 +251,7 @@
<div class="tableTitle"> <div class="tableTitle">
<div> <div>
<Heading size="XS">User groups</Heading> <Heading size="XS">User groups</Heading>
<Body size="S" <Body size="S">Add or remove this user from user groups</Body>
>Manage apps that this User group has been assigned to</Body
>
</div> </div>
<div bind:this={popoverAnchor}> <div bind:this={popoverAnchor}>
<Button on:click={popover.show()} icon="UserGroup" cta <Button on:click={popover.show()} icon="UserGroup" cta
@ -291,25 +297,27 @@
<div class="appsTitle"> <div class="appsTitle">
<Heading weight="light" size="XS">Apps</Heading> <Heading weight="light" size="XS">Apps</Heading>
<div style="margin-top: var(--spacing-xs)"> <div style="margin-top: var(--spacing-xs)">
<Body size="S" <Body size="S">Manage apps that this user has been assigned to</Body>
>Manage apps that this User group has been assigned to</Body
>
</div> </div>
</div> </div>
<List> <List>
{#if appList.length} {#if allAppList.length}
{#each appList as app} {#each allAppList as app}
<ListItem title={app.name} icon="Apps"> <div on:click={$goto(`../../overview/${app.devId}`)}>
<div class="title "> <ListItem
<StatusLight title={app.name}
color={RoleUtils.getRoleColour(getHighestRole(app.role)._id)} iconBackground={app?.icon?.color || ""}
/> icon={app?.icon?.name || "Apps"}
<div style="margin-left: var(--spacing-s);"> >
<Body size="XS">{getHighestRole(app.role).name}</Body> <div class="title ">
<StatusLight />
<div style="margin-left: var(--spacing-s);">
<Body size="XS">d</Body>
</div>
</div> </div>
</div> </ListItem>
</ListItem> </div>
{/each} {/each}
{:else} {:else}
<ListItem icon="Apps" title="No apps" /> <ListItem icon="Apps" title="No apps" />

View File

@ -5,39 +5,33 @@
Label, Label,
ModalContent, ModalContent,
Multiselect, Multiselect,
notifications,
InputDropdown, InputDropdown,
} from "@budibase/bbui" } from "@budibase/bbui"
import { users, groups } from "stores/portal" import { createEventDispatcher } from "svelte"
import analytics, { Events } from "analytics" import { groups } from "stores/portal"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
export let disabled export let disabled
export let showOnboardingTypeModal export let showOnboardingTypeModal
const options = ["Email onboarding", "Basic onboarding"]
let selected = options[0]
let builder, admin
$: userData = [{ email: "", role: "", error: null }] const dispatch = createEventDispatcher()
$: userData = [{ email: "", role: "", groups: [], error: null }]
/*
async function createUserFlow() {
try {
const res = await users.invite({ email: "", builder, admin })
notifications.success(res.message)
analytics.captureEvent(Events.USER.INVITE, { type: selected })
} catch (error) {
notifications.error("Error inviting user")
}
}
*/
function addNewInput() { function addNewInput() {
userData = [...userData, { email: "", role: "" }] userData = [...userData, { email: "", role: "" }]
} }
function setValue(e) {
userData.groups = e.detail
}
</script> </script>
<ModalContent <ModalContent
onConfirm={showOnboardingTypeModal} onConfirm={() => {
showOnboardingTypeModal()
dispatch("change", userData)
}}
size="M" size="M"
title="Add new user" title="Add new user"
confirmText="Add user" confirmText="Add user"
@ -64,6 +58,7 @@
<Multiselect <Multiselect
placeholder="Select User Groups" placeholder="Select User Groups"
on:change={e => setValue(e)}
label="User Groups" label="User Groups"
options={$groups} options={$groups}
getOptionLabel={option => option.name} getOptionLabel={option => option.name}

View File

@ -1,8 +1,44 @@
<script> <script>
import { Body, ModalContent, RadioGroup, Multiselect } from "@budibase/bbui" import {
Body,
ModalContent,
RadioGroup,
Multiselect,
notifications,
} from "@budibase/bbui"
import { groups } from "stores/portal" import { groups } from "stores/portal"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
const BYTES_IN_MB = 1000000
const FILE_SIZE_LIMIT = BYTES_IN_MB * 5
export let showOnboardingTypeModal
let files = []
let csvString = undefined
function parseCsv() {}
async function handleFile(evt) {
const fileArray = Array.from(evt.target.files)
if (fileArray.some(file => file.size >= FILE_SIZE_LIMIT)) {
notifications.error(
`Files cannot exceed ${
FILE_SIZE_LIMIT / BYTES_IN_MB
}MB. Please try again with smaller files.`
)
return
}
// Read CSV as plain text to upload alongside schema
let reader = new FileReader()
reader.addEventListener("load", function (e) {
csvString = e.target.result
files = fileArray
})
reader.readAsText(fileArray[0])
}
</script> </script>
<ModalContent <ModalContent
@ -12,13 +48,16 @@
showCancelButton={false} showCancelButton={false}
cancelText="Cancel" cancelText="Cancel"
showCloseIcon={false} showCloseIcon={false}
onConfirm={showOnboardingTypeModal}
disabled={!files.length}
> >
<Body size="S">Import your users email addrresses from a CSV</Body> <Body size="S">Import your users email addrresses from a CSV</Body>
<div class="container"> <div class="dropzone">
<div class="inner"> <input id="file-upload" accept=".csv" type="file" on:change={handleFile} />
<Body size="S">Upload</Body> <label for="file-upload" class:uploaded={files[0]}>
</div> {#if files[0]}{files[0].name}{:else}Upload{/if}
</label>
</div> </div>
<RadioGroup options={Constants.BuilderRoleDescriptions} /> <RadioGroup options={Constants.BuilderRoleDescriptions} />
@ -49,4 +88,51 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.error {
color: var(--red);
}
.dropzone {
text-align: center;
display: flex;
align-items: center;
flex-direction: column;
border-radius: 10px;
transition: all 0.3s;
}
.uploaded {
color: var(--blue);
}
label {
font-family: var(--font-sans);
cursor: pointer;
font-weight: 600;
box-sizing: border-box;
overflow: hidden;
border-radius: var(--border-radius-s);
color: var(--ink);
padding: var(--spacing-m) var(--spacing-l);
transition: all 0.2s ease 0s;
display: inline-flex;
text-rendering: optimizeLegibility;
min-width: auto;
outline: none;
font-feature-settings: "case" 1, "rlig" 1, "calt" 0;
-webkit-box-align: center;
user-select: none;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 100%;
background-color: var(--grey-2);
font-size: var(--font-size-xs);
line-height: normal;
border: var(--border-transparent);
}
input[type="file"] {
display: none;
}
</style> </style>

View File

@ -1,6 +1,7 @@
<script> <script>
import { ModalContent, Body, Layout, Icon } from "@budibase/bbui" import { ModalContent, Body, Layout, Icon } from "@budibase/bbui"
export let showConfirmationModal
export let chooseCreationType
let emailOnboardingKey = "emailOnboarding" let emailOnboardingKey = "emailOnboarding"
let basicOnboaridngKey = "basicOnboarding" let basicOnboaridngKey = "basicOnboarding"
@ -13,7 +14,7 @@
confirmText="Done" confirmText="Done"
cancelText="Cancel" cancelText="Cancel"
showCloseIcon={false} showCloseIcon={false}
onConfirm={() => showConfirmationModal(selectedOnboardingType)} onConfirm={() => chooseCreationType(selectedOnboardingType)}
disabled={!selectedOnboardingType} disabled={!selectedOnboardingType}
> >
<Layout noPadding gap="S"> <Layout noPadding gap="S">

View File

@ -33,7 +33,9 @@
<Table <Table
{schema} {schema}
data={[{ email: "test", password: "§xz§§zvzxvxzv" }]} data={[
{ email: "test", password: Math.random().toString(36).slice(2, 20) },
]}
allowEditColumns={false} allowEditColumns={false}
allowEditRows={false} allowEditRows={false}
allowSelectRows={false} allowSelectRows={false}

View File

@ -24,6 +24,8 @@
import OnboardingTypeModal from "./_components/OnboardingTypeModal.svelte" import OnboardingTypeModal from "./_components/OnboardingTypeModal.svelte"
import PasswordModal from "./_components/PasswordModal.svelte" import PasswordModal from "./_components/PasswordModal.svelte"
import ImportUsersModal from "./_components/ImportUsersModal.svelte" import ImportUsersModal from "./_components/ImportUsersModal.svelte"
import analytics, { Events } from "analytics"
import TriggerAutomation from "../../../../../components/design/PropertiesPanel/PropertyControls/ButtonActionEditor/actions/TriggerAutomation.svelte"
const schema = { const schema = {
name: {}, name: {},
@ -42,6 +44,8 @@
}, },
} }
$: userData = []
const accessTypes = [ const accessTypes = [
{ {
icon: "User", icon: "User",
@ -97,10 +101,51 @@
onboardingTypeModal.show() onboardingTypeModal.show()
} }
function showConfirmationModal(onboardingType) { async function createUserFlow() {
if (onboardingType === "emailOnboarding") { let emails = userData.map(x => x.email)
try {
const res = await users.invite({
emails: emails,
builder: true,
admin: true,
})
notifications.success(res.message)
analytics.captureEvent(Events.USER.INVITE, { type: "Email onboarding" })
inviteConfirmationModal.show() inviteConfirmationModal.show()
} catch (error) {
console.log(error)
notifications.error("Error inviting user")
}
}
async function createUser() {
try {
await users.create({
email: $email,
password,
builder,
admin,
forceResetPassword: true,
})
notifications.success("Successfully created user")
} catch (error) {
notifications.error("Error creating user")
}
}
async function chooseCreationType(onboardingType) {
if (onboardingType === "emailOnboarding") {
createUserFlow()
} else { } else {
let newUser = await users.create({
email: "auser5@test.com",
password: Math.random().toString(36).slice(2, 20),
builder: true,
admin: true,
forceResetPassword: true,
})
console.log(newUser)
passwordModal.show() passwordModal.show()
} }
} }
@ -163,7 +208,10 @@
</Layout> </Layout>
<Modal bind:this={createUserModal}> <Modal bind:this={createUserModal}>
<AddUserModal {showOnboardingTypeModal} /> <AddUserModal
on:change={e => (userData = e.detail)}
{showOnboardingTypeModal}
/>
</Modal> </Modal>
<Modal bind:this={inviteConfirmationModal}> <Modal bind:this={inviteConfirmationModal}>
@ -180,7 +228,7 @@
</Modal> </Modal>
<Modal bind:this={onboardingTypeModal}> <Modal bind:this={onboardingTypeModal}>
<OnboardingTypeModal {showConfirmationModal} /> <OnboardingTypeModal {chooseCreationType} />
</Modal> </Modal>
<Modal bind:this={passwordModal}> <Modal bind:this={passwordModal}>
@ -188,7 +236,7 @@
</Modal> </Modal>
<Modal bind:this={importUsersModal}> <Modal bind:this={importUsersModal}>
<ImportUsersModal /> <ImportUsersModal {showOnboardingTypeModal} />
</Modal> </Modal>
<Modal bind:this={basicOnboardingModal}><BasicOnboardingModal {email} /></Modal> <Modal bind:this={basicOnboardingModal}><BasicOnboardingModal {email} /></Modal>

View File

@ -1,6 +1,75 @@
<script> <script>
import { Layout, Heading, Body, Button, List, ListItem } from "@budibase/bbui" import {
Layout,
Heading,
Body,
Button,
List,
ListItem,
Modal,
notifications,
} from "@budibase/bbui"
import { onMount } from "svelte"
import RoleSelect from "components/common/RoleSelect.svelte" import RoleSelect from "components/common/RoleSelect.svelte"
import { users, groups, apps } from "stores/portal"
import AssignmentModal from "./AssignmentModal.svelte"
export let app
let assignmentModal
let appGroups = []
let appUsers = []
$: appUsers = $users.filter(x => {
return Object.keys(x.roles).some(y => {
return extractAppId(y) === extractAppId(app.appId)
})
})
$: appGroups = $groups.filter(x => {
return x.apps.find(y => {
return y.appId === app.appId
})
})
function extractAppId(id) {
const split = id?.split("_") || []
return split.length ? split[split.length - 1] : null
}
async function addData(appData) {
let gr_prefix = "gr"
let us_prefix = "us"
appData.forEach(async data => {
if (data.id.startsWith(gr_prefix)) {
let matchedGroup = $groups.find(group => {
return group._id === data.id
})
matchedGroup.apps.push(app)
groups.actions.save(matchedGroup)
} else if (data.id.startsWith(us_prefix)) {
let matchedUser = $users.find(user => {
return user._id === data.id
})
let newUser = {
...matchedUser,
roles: { [app.appId]: data.role },
}
await users.save(newUser)
}
})
}
onMount(async () => {
try {
await users.init()
await groups.actions.init()
await apps.load()
} catch (error) {
notifications.error("Error")
}
})
</script> </script>
<div class="access-tab"> <div class="access-tab">
@ -11,31 +80,44 @@
<Body size="S"> <Body size="S">
Assign users to your app and define their access here</Body Assign users to your app and define their access here</Body
> >
<Button icon="User" cta>Assign users</Button> <Button on:click={assignmentModal.show} icon="User" cta
>Assign users</Button
>
</div> </div>
</div> </div>
<List title="User Groups"> <List title="User Groups">
<ListItem title="Design Team" icon="Brush" iconBackground="#348c6f"> {#each appGroups as group}
<RoleSelect autoWidth quiet value="POWER" /> <ListItem
</ListItem> title={group.name}
<ListItem title="Admin Team" icon="UserAdmin" iconBackground="#843c6f"> icon={group.icon}
<RoleSelect autoWidth quiet value="ADMIN" /> iconBackground={group.color}
</ListItem> >
<RoleSelect autoWidth quiet value={group.role} />
</ListItem>
{/each}
</List> </List>
<List title="Users"> <List title="Users">
<ListItem title="andy@gmail.com" avatar> {#each appUsers as user}
<RoleSelect autoWidth quiet value="BASIC" /> <ListItem title={user.email} avatar>
</ListItem> <RoleSelect
<ListItem title="jeff@gmail.com" avatar> autoWidth
<RoleSelect autoWidth quiet value="BASIC" /> quiet
</ListItem> value={user.roles[
<ListItem title="tom@gmail.com" avatar> Object.keys(user.roles).find(
<RoleSelect autoWidth quiet value="BASIC" /> x => extractAppId(x) === extractAppId(app.appId)
</ListItem> )
]}
/>
</ListItem>
{/each}
</List> </List>
</Layout> </Layout>
</div> </div>
<Modal bind:this={assignmentModal}>
<AssignmentModal {addData} />
</Modal>
<style> <style>
.access-tab { .access-tab {
max-width: 600px; max-width: 600px;

View File

@ -0,0 +1,63 @@
<script>
import { ModalContent, PickerDropdown, ActionButton } from "@budibase/bbui"
import { users, groups, apps } from "stores/portal"
import { roles } from "stores/backend"
import { RoleUtils } from "@budibase/frontend-core"
export let addData
$: optionSections = {
groups: {
data: $groups,
getLabel: group => group.name,
getValue: group => group._id,
getIcon: group => group.icon,
getColour: group => group.color,
},
users: {
data: $users,
getLabel: user => user.email,
getValue: user => user._id,
getIcon: user => user.icon,
getColour: user => user.color,
},
}
$: appData = [{ id: "", role: "" }]
function addNewInput() {
appData = [...appData, { id: "", role: "" }]
}
$: console.log(appData)
</script>
<ModalContent
size="M"
title="Assign users to your app"
confirmText="Done"
cancelText="Cancel"
onConfirm={() => addData(appData)}
showCloseIcon={false}
>
{#each appData as input, index}
<PickerDropdown
autocomplete
primaryOptions={optionSections}
primaryPlaceholder={"Search Users"}
secondaryOptions={$roles}
bind:primaryValue={input.id}
bind:secondaryValue={input.role}
getPrimaryOptionLabel={group => group.name}
getPrimaryOptionValue={group => group.name}
getPrimaryOptionIcon={group => group.icon}
getPrimaryOptionColour={group => group.colour}
getSecondaryOptionLabel={role => role.name}
getSecondaryOptionValue={role => role._id}
getSecondaryOptionColour={role => RoleUtils.getRoleColour(role._id)}
/>
{/each}
<div>
<ActionButton on:click={addNewInput} icon="Add">Add email</ActionButton>
</div>
</ModalContent>

View File

@ -78,6 +78,7 @@ export function createAppStore() {
subscribe: store.subscribe, subscribe: store.subscribe,
load, load,
update, update,
extractAppId,
} }
} }

View File

@ -7,6 +7,7 @@ export function createGroupsStore() {
icon: "", icon: "",
color: "", color: "",
users: [], users: [],
apps: [],
} }
const store = writable([DEFAULT_CONFIG]) const store = writable([DEFAULT_CONFIG])

View File

@ -3,21 +3,20 @@ import { API } from "api"
import { update } from "lodash" import { update } from "lodash"
export function createUsersStore() { export function createUsersStore() {
const { subscribe, set } = writable([]) const store = writable([])
async function init() { async function init() {
const users = await API.getUsers() const users = await API.getUsers()
set(users) store.set(users)
} }
async function invite({ email, builder, admin }) { async function invite({ emails, builder, admin }) {
return API.inviteUser({ return API.inviteUsers({
email, emails,
builder, builder,
admin, admin,
}) })
} }
async function acceptInvite(inviteCode, password) { async function acceptInvite(inviteCode, password) {
return API.acceptInvite({ return API.acceptInvite({
inviteCode, inviteCode,
@ -55,12 +54,23 @@ export function createUsersStore() {
update(users => users.filter(user => user._id !== id)) update(users => users.filter(user => user._id !== id))
} }
async function save(data) { async function save(user) {
await API.saveUser(data) const response = await API.saveUser(user)
user._id = response._id
user._rev = response._rev
store.update(state => {
const currentIdx = state.findIndex(user => user._id === user._id)
if (currentIdx >= 0) {
state.splice(currentIdx, 1, user)
} else {
state.push(user)
}
return state
})
} }
return { return {
subscribe, subscribe: store.subscribe,
init, init,
invite, invite,
acceptInvite, acceptInvite,

View File

@ -81,6 +81,25 @@ export const buildUserEndpoints = API => ({
}) })
}, },
/**
* Invites multiple users to the current tenant.
* @param email An array of email addresses
* @param builder whether the user should be a global builder
* @param admin whether the user should be a global admin
*/
inviteUsers: async ({ emails, builder, admin }) => {
return await API.post({
url: "/api/global/users/inviteMultiple",
body: {
emails,
userInfo: {
admin: admin ? { global: true } : undefined,
builder: builder ? { global: true } : undefined,
},
},
})
},
/** /**
* Accepts an invite to join the platform and creates a user. * Accepts an invite to join the platform and creates a user.
* @param inviteCode the invite code sent in the email * @param inviteCode the invite code sent in the email

View File

@ -187,6 +187,35 @@ export const invite = async (ctx: any) => {
} }
} }
export const inviteMultiple = async (ctx: any) => {
let { emails, userInfo } = ctx.request.body
let existing = false
let existingEmail
for (let email of emails) {
if (await getGlobalUserByEmail(email)) {
existing = true
existingEmail = email
break
}
}
if (existing) {
ctx.throw(400, `${existingEmail} already exists`)
}
if (!userInfo) {
userInfo = {}
}
userInfo.tenantId = getTenantId()
const opts: any = {
subject: "{{ company }} platform invitation",
info: userInfo,
}
await sendEmail(emails, EmailTemplatePurpose.INVITATION, opts)
ctx.body = {
message: "Invitations have been sent.",
}
}
export const inviteAccept = async (ctx: any) => { export const inviteAccept = async (ctx: any) => {
const { inviteCode, password, firstName, lastName } = ctx.request.body const { inviteCode, password, firstName, lastName } = ctx.request.body
try { try {

View File

@ -14,7 +14,8 @@ function buildGroupSaveValidation() {
color: Joi.string().required(), color: Joi.string().required(),
icon: Joi.string().required(), icon: Joi.string().required(),
name: Joi.string().required(), name: Joi.string().required(),
users: Joi.array().optional() users: Joi.array().optional(),
apps: Joi.array().optional()
}).required()) }).required())
} }

View File

@ -29,6 +29,14 @@ function buildInviteValidation() {
}).required()) }).required())
} }
function buildInviteMultipleValidation() {
// prettier-ignore
return joiValidator.body(Joi.object({
emails: Joi.array().required(),
userInfo: Joi.object().optional(),
}).required())
}
function buildInviteAcceptValidation() { function buildInviteAcceptValidation() {
// prettier-ignore // prettier-ignore
return joiValidator.body(Joi.object({ return joiValidator.body(Joi.object({
@ -53,6 +61,19 @@ router
buildInviteValidation(), buildInviteValidation(),
controller.invite controller.invite
) )
.post(
"/api/global/users/invite",
adminOnly,
buildInviteValidation(),
controller.invite
)
.post(
"/api/global/users/inviteMultiple",
adminOnly,
buildInviteMultipleValidation(),
controller.inviteMultiple
)
// non-global endpoints // non-global endpoints
.post( .post(
"/api/global/users/invite/accept", "/api/global/users/invite/accept",

View File

@ -185,14 +185,27 @@ exports.sendEmail = async (
// if there is a link code needed this will retrieve it // if there is a link code needed this will retrieve it
const code = await getLinkCode(purpose, email, user, info) const code = await getLinkCode(purpose, email, user, info)
const context = await getSettingsTemplateContext(purpose, code) const context = await getSettingsTemplateContext(purpose, code)
const message = {
let message = {
from: from || config.from, from: from || config.from,
to: email,
html: await buildEmail(purpose, email, context, { html: await buildEmail(purpose, email, context, {
user, user,
contents, contents,
}), }),
} }
if (email.length > 1) {
message = {
...message,
bcc: email,
}
} else {
message = {
...message,
to: email,
}
}
if (subject || config.subject) { if (subject || config.subject) {
message.subject = await processString(subject || config.subject, context) message.subject = await processString(subject || config.subject, context)
} }