1
0
Fork 0
mirror of synced 2024-06-28 11:00:55 +12:00

group / user app assignment

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@
import Icon from "../Icon/Icon.svelte"
import { createEventDispatcher } from "svelte"
export let value = "Anchor"
export let value
export let size = "M"
export let alignRight = false
@ -59,7 +59,7 @@
style={value ? `background: ${value};` : ""}
class:placeholder={!value}
>
<Icon name={value} />
<Icon name={value || "UserGroup"} />
</div>
</div>
{#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 Checkbox } from "./Form/Checkbox.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 Popover } from "./Popover/Popover.svelte"
export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte"

View file

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

View file

@ -45,7 +45,7 @@
<Icon name="WebPage" />
<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"}
</div>

View file

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

View file

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

View file

@ -5,39 +5,33 @@
Label,
ModalContent,
Multiselect,
notifications,
InputDropdown,
} from "@budibase/bbui"
import { users, groups } from "stores/portal"
import analytics, { Events } from "analytics"
import { createEventDispatcher } from "svelte"
import { groups } from "stores/portal"
import { Constants } from "@budibase/frontend-core"
export let disabled
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() {
userData = [...userData, { email: "", role: "" }]
}
function setValue(e) {
userData.groups = e.detail
}
</script>
<ModalContent
onConfirm={showOnboardingTypeModal}
onConfirm={() => {
showOnboardingTypeModal()
dispatch("change", userData)
}}
size="M"
title="Add new user"
confirmText="Add user"
@ -64,6 +58,7 @@
<Multiselect
placeholder="Select User Groups"
on:change={e => setValue(e)}
label="User Groups"
options={$groups}
getOptionLabel={option => option.name}

View file

@ -1,8 +1,44 @@
<script>
import { Body, ModalContent, RadioGroup, Multiselect } from "@budibase/bbui"
import {
Body,
ModalContent,
RadioGroup,
Multiselect,
notifications,
} from "@budibase/bbui"
import { groups } from "stores/portal"
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>
<ModalContent
@ -12,13 +48,16 @@
showCancelButton={false}
cancelText="Cancel"
showCloseIcon={false}
onConfirm={showOnboardingTypeModal}
disabled={!files.length}
>
<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 class="dropzone">
<input id="file-upload" accept=".csv" type="file" on:change={handleFile} />
<label for="file-upload" class:uploaded={files[0]}>
{#if files[0]}{files[0].name}{:else}Upload{/if}
</label>
</div>
<RadioGroup options={Constants.BuilderRoleDescriptions} />
@ -49,4 +88,51 @@
justify-content: 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>

View file

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

View file

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

View file

@ -24,6 +24,8 @@
import OnboardingTypeModal from "./_components/OnboardingTypeModal.svelte"
import PasswordModal from "./_components/PasswordModal.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 = {
name: {},
@ -42,6 +44,8 @@
},
}
$: userData = []
const accessTypes = [
{
icon: "User",
@ -97,10 +101,51 @@
onboardingTypeModal.show()
}
function showConfirmationModal(onboardingType) {
if (onboardingType === "emailOnboarding") {
async function createUserFlow() {
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()
} 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 {
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()
}
}
@ -163,7 +208,10 @@
</Layout>
<Modal bind:this={createUserModal}>
<AddUserModal {showOnboardingTypeModal} />
<AddUserModal
on:change={e => (userData = e.detail)}
{showOnboardingTypeModal}
/>
</Modal>
<Modal bind:this={inviteConfirmationModal}>
@ -180,7 +228,7 @@
</Modal>
<Modal bind:this={onboardingTypeModal}>
<OnboardingTypeModal {showConfirmationModal} />
<OnboardingTypeModal {chooseCreationType} />
</Modal>
<Modal bind:this={passwordModal}>
@ -188,7 +236,7 @@
</Modal>
<Modal bind:this={importUsersModal}>
<ImportUsersModal />
<ImportUsersModal {showOnboardingTypeModal} />
</Modal>
<Modal bind:this={basicOnboardingModal}><BasicOnboardingModal {email} /></Modal>

View file

@ -1,6 +1,75 @@
<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 { 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>
<div class="access-tab">
@ -11,31 +80,44 @@
<Body size="S">
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>
<List title="User Groups">
<ListItem title="Design Team" icon="Brush" iconBackground="#348c6f">
<RoleSelect autoWidth quiet value="POWER" />
</ListItem>
<ListItem title="Admin Team" icon="UserAdmin" iconBackground="#843c6f">
<RoleSelect autoWidth quiet value="ADMIN" />
</ListItem>
{#each appGroups as group}
<ListItem
title={group.name}
icon={group.icon}
iconBackground={group.color}
>
<RoleSelect autoWidth quiet value={group.role} />
</ListItem>
{/each}
</List>
<List title="Users">
<ListItem title="andy@gmail.com" avatar>
<RoleSelect autoWidth quiet value="BASIC" />
</ListItem>
<ListItem title="jeff@gmail.com" avatar>
<RoleSelect autoWidth quiet value="BASIC" />
</ListItem>
<ListItem title="tom@gmail.com" avatar>
<RoleSelect autoWidth quiet value="BASIC" />
</ListItem>
{#each appUsers as user}
<ListItem title={user.email} avatar>
<RoleSelect
autoWidth
quiet
value={user.roles[
Object.keys(user.roles).find(
x => extractAppId(x) === extractAppId(app.appId)
)
]}
/>
</ListItem>
{/each}
</List>
</Layout>
</div>
<Modal bind:this={assignmentModal}>
<AssignmentModal {addData} />
</Modal>
<style>
.access-tab {
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,
load,
update,
extractAppId,
}
}

View file

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

View file

@ -3,21 +3,20 @@ import { API } from "api"
import { update } from "lodash"
export function createUsersStore() {
const { subscribe, set } = writable([])
const store = writable([])
async function init() {
const users = await API.getUsers()
set(users)
store.set(users)
}
async function invite({ email, builder, admin }) {
return API.inviteUser({
email,
async function invite({ emails, builder, admin }) {
return API.inviteUsers({
emails,
builder,
admin,
})
}
async function acceptInvite(inviteCode, password) {
return API.acceptInvite({
inviteCode,
@ -55,12 +54,23 @@ export function createUsersStore() {
update(users => users.filter(user => user._id !== id))
}
async function save(data) {
await API.saveUser(data)
async function save(user) {
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 {
subscribe,
subscribe: store.subscribe,
init,
invite,
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.
* @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) => {
const { inviteCode, password, firstName, lastName } = ctx.request.body
try {

View file

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

View file

@ -29,6 +29,14 @@ function buildInviteValidation() {
}).required())
}
function buildInviteMultipleValidation() {
// prettier-ignore
return joiValidator.body(Joi.object({
emails: Joi.array().required(),
userInfo: Joi.object().optional(),
}).required())
}
function buildInviteAcceptValidation() {
// prettier-ignore
return joiValidator.body(Joi.object({
@ -53,6 +61,19 @@ router
buildInviteValidation(),
controller.invite
)
.post(
"/api/global/users/invite",
adminOnly,
buildInviteValidation(),
controller.invite
)
.post(
"/api/global/users/inviteMultiple",
adminOnly,
buildInviteMultipleValidation(),
controller.inviteMultiple
)
// non-global endpoints
.post(
"/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
const code = await getLinkCode(purpose, email, user, info)
const context = await getSettingsTemplateContext(purpose, code)
const message = {
let message = {
from: from || config.from,
to: email,
html: await buildEmail(purpose, email, context, {
user,
contents,
}),
}
if (email.length > 1) {
message = {
...message,
bcc: email,
}
} else {
message = {
...message,
to: email,
}
}
if (subject || config.subject) {
message.subject = await processString(subject || config.subject, context)
}