Merge pull request #6989 from Budibase/pc/fixes

User Management UI fixes
This commit is contained in:
Peter Clement 2022-08-01 09:39:55 +01:00 committed by GitHub
commit c3a4941119
20 changed files with 215 additions and 77 deletions

View File

@ -50,4 +50,5 @@ exports.getTenantFeatureFlags = tenantId => {
exports.FeatureFlag = { exports.FeatureFlag = {
LICENSING: "LICENSING", LICENSING: "LICENSING",
GOOGLE_SHEETS: "GOOGLE_SHEETS", GOOGLE_SHEETS: "GOOGLE_SHEETS",
USER_GROUPS: "USER_GROUPS",
} }

View File

@ -115,6 +115,16 @@
class:is-disabled={disabled} class:is-disabled={disabled}
class:is-focused={focus} class:is-focused={focus}
> >
{#if error}
<svg
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Textfield-validationIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-icon-18-Alert" />
</svg>
{/if}
<input <input
{id} {id}
on:click on:click

View File

@ -8,6 +8,7 @@
import Icon from "../../Icon/Icon.svelte" import Icon from "../../Icon/Icon.svelte"
import StatusLight from "../../StatusLight/StatusLight.svelte" import StatusLight from "../../StatusLight/StatusLight.svelte"
import Detail from "../../Typography/Detail.svelte" import Detail from "../../Typography/Detail.svelte"
import Search from "./Search.svelte"
export let primaryLabel = "" export let primaryLabel = ""
export let primaryValue = null export let primaryValue = null
@ -22,7 +23,6 @@
export let secondaryFieldText = "" export let secondaryFieldText = ""
export let secondaryFieldIcon = "" export let secondaryFieldIcon = ""
export let secondaryFieldColour = "" export let secondaryFieldColour = ""
export let getPrimaryOptionLabel = option => option
export let getPrimaryOptionValue = option => option export let getPrimaryOptionValue = option => option
export let getPrimaryOptionColour = () => null export let getPrimaryOptionColour = () => null
export let getPrimaryOptionIcon = () => null export let getPrimaryOptionIcon = () => null
@ -43,17 +43,12 @@
let searchTerm = null let searchTerm = null
$: groupTitles = Object.keys(primaryOptions) $: groupTitles = Object.keys(primaryOptions)
$: filteredOptions = getFilteredOptions(
primaryOptions,
searchTerm,
getPrimaryOptionLabel
)
let iconData let iconData
/*
$: iconData = primaryOptions?.find(x => { const updateSearch = e => {
return x.name === primaryFieldText dispatch("search", e.detail)
}) }
*/
const updateValue = newValue => { const updateValue = newValue => {
if (readonly) { if (readonly) {
return return
@ -107,16 +102,6 @@
updateValue(event.target.value) updateValue(event.target.value)
} }
} }
const getFilteredOptions = (options, term, getLabel) => {
if (autocomplete && term) {
const lowerCaseTerm = term.toLowerCase()
return options.filter(option => {
return `${getLabel(option)}`.toLowerCase().includes(lowerCaseTerm)
})
}
return options
}
</script> </script>
<div <div
@ -183,6 +168,15 @@
class:auto-width={autoWidth} class:auto-width={autoWidth}
class:is-full-width={!secondaryOptions.length} class:is-full-width={!secondaryOptions.length}
> >
{#if autocomplete}
<Search
value={searchTerm}
on:change={event => updateSearch(event)}
{disabled}
placeholder="Search"
/>
{/if}
<ul class="spectrum-Menu" role="listbox"> <ul class="spectrum-Menu" role="listbox">
{#if placeholderOption} {#if placeholderOption}
<li <li

View File

@ -26,6 +26,7 @@
export let autofocus export let autofocus
export let primaryOptions = [] export let primaryOptions = []
export let secondaryOptions = [] export let secondaryOptions = []
export let searchTerm
let primaryLabel let primaryLabel
let secondaryLabel let secondaryLabel
@ -87,10 +88,15 @@
} }
return value return value
} }
const updateSearchTerm = e => {
searchTerm = e.detail
}
</script> </script>
<Field {label} {labelPosition} {error}> <Field {label} {labelPosition} {error}>
<PickerDropdown <PickerDropdown
{searchTerm}
{autocomplete} {autocomplete}
{dataCy} {dataCy}
{updateOnChange} {updateOnChange}
@ -116,6 +122,7 @@
{secondaryLabel} {secondaryLabel}
on:pickprimary={onPickPrimary} on:pickprimary={onPickPrimary}
on:picksecondary={onPickSecondary} on:picksecondary={onPickSecondary}
on:search={updateSearchTerm}
on:click on:click
on:input on:input
on:blur on:blur

View File

@ -3,6 +3,7 @@ import { get } from "svelte/store"
export const FEATURE_FLAGS = { export const FEATURE_FLAGS = {
LICENSING: "LICENSING", LICENSING: "LICENSING",
USER_GROUPS: "USER_GROUPS",
} }
export const isEnabled = featureFlag => { export const isEnabled = featureFlag => {

View File

@ -45,6 +45,7 @@
}, },
]) ])
} }
if (admin) { if (admin) {
menu = menu.concat([ menu = menu.concat([
{ {
@ -52,11 +53,6 @@
href: "/builder/portal/manage/users", href: "/builder/portal/manage/users",
heading: "Manage", heading: "Manage",
}, },
{
title: "User Groups",
href: "/builder/portal/manage/groups",
},
{ title: "Auth", href: "/builder/portal/manage/auth" }, { title: "Auth", href: "/builder/portal/manage/auth" },
{ title: "Email", href: "/builder/portal/manage/email" }, { title: "Email", href: "/builder/portal/manage/email" },
{ {
@ -70,6 +66,15 @@
}, },
]) ])
if (isEnabled(FEATURE_FLAGS.USER_GROUPS)) {
let item = {
title: "User Groups",
href: "/builder/portal/manage/groups",
}
menu.splice(1, 0, item)
}
if (!$adminStore.cloud) { if (!$adminStore.cloud) {
menu = menu.concat([ menu = menu.concat([
{ {

View File

@ -41,6 +41,11 @@
let allAppList = [] let allAppList = []
let user let user
$: fetchUser(userId) $: fetchUser(userId)
$: fullName = $userFetch?.data?.firstName
? $userFetch?.data?.firstName + " " + $userFetch?.data?.lastName
: ""
$: hasGroupsLicense = $auth.user?.license.features.includes( $: hasGroupsLicense = $auth.user?.license.features.includes(
Constants.Features.USER_GROUPS Constants.Features.USER_GROUPS
) )
@ -127,7 +132,7 @@
if (detail === "developer") { if (detail === "developer") {
toggleFlags({ admin: { global: false }, builder: { global: true } }) toggleFlags({ admin: { global: false }, builder: { global: true } })
} else if (detail === "admin") { } else if (detail === "admin") {
toggleFlags({ admin: { global: true }, builder: { global: false } }) toggleFlags({ admin: { global: true }, builder: { global: true } })
} else if (detail === "appUser") { } else if (detail === "appUser") {
toggleFlags({ admin: { global: false }, builder: { global: false } }) toggleFlags({ admin: { global: false }, builder: { global: false } })
} }
@ -186,15 +191,25 @@
<div class="title"> <div class="title">
<div> <div>
<div style="display: flex;"> <div style="display: flex;">
<Avatar size="XXL" initials="PC" /> <Avatar
size="XXL"
initials={user?.email
.split(" ")
.map(x => x[0])
.join("")}
/>
{#if fullName}
<div class="subtitle"> <div class="subtitle">
<Heading size="S" <Heading size="S">{fullName}</Heading>
>{$userFetch?.data?.firstName +
" " +
$userFetch?.data?.lastName}</Heading
>
<Body size="XS">{$userFetch?.data?.email}</Body> <Body size="XS">{$userFetch?.data?.email}</Body>
</div> </div>
{:else}
<div class="alignEmail">
<Heading size="S">{$userFetch?.data?.email}</Heading>
</div>
{/if}
</div> </div>
</div> </div>
<div> <div>
@ -372,4 +387,10 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.alignEmail {
display: flex;
align-items: center;
margin-left: var(--spacing-m);
}
</style> </style>

View File

@ -6,15 +6,17 @@
Multiselect, Multiselect,
InputDropdown, InputDropdown,
Layout, Layout,
Icon,
} from "@budibase/bbui" } from "@budibase/bbui"
import { groups, auth } from "stores/portal" import { groups, auth } from "stores/portal"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
import { emailValidator } from "helpers/validation"
export let showOnboardingTypeModal export let showOnboardingTypeModal
const password = Math.random().toString(36).substring(2, 22) const password = Math.random().toString(36).substring(2, 22)
let disabled let disabled
let userGroups = [] let userGroups = []
$: errors = []
$: hasGroupsLicense = $auth.user?.license.features.includes( $: hasGroupsLicense = $auth.user?.license.features.includes(
Constants.Features.USER_GROUPS Constants.Features.USER_GROUPS
) )
@ -27,6 +29,10 @@
forceResetPassword: true, forceResetPassword: true,
}, },
] ]
function removeInput(idx) {
userData = userData.filter((e, i) => i !== idx)
}
function addNewInput() { function addNewInput() {
userData = [ userData = [
...userData, ...userData,
@ -38,6 +44,18 @@
}, },
] ]
} }
function validateInput(email, index) {
if (email) {
if (emailValidator(email) === true) {
errors[index] = true
return null
} else {
errors[index] = false
return emailValidator(email)
}
}
}
</script> </script>
<ModalContent <ModalContent
@ -49,18 +67,40 @@
confirmDisabled={disabled} confirmDisabled={disabled}
cancelText="Cancel" cancelText="Cancel"
showCloseIcon={false} showCloseIcon={false}
disabled={errors.some(x => x === false) ||
userData.some(x => x.email === "" || x.email === null)}
> >
<Layout noPadding gap="XS"> <Layout noPadding gap="XS">
<Label>Email Address</Label> <Label>Email Address</Label>
{#each userData as input, index} {#each userData as input, index}
<div
style="display: flex;
align-items: center;
flex-direction: row;"
>
<div style="width: 90%">
<InputDropdown <InputDropdown
inputType="email" inputType="email"
bind:inputValue={input.email} bind:inputValue={input.email}
bind:dropdownValue={input.role} bind:dropdownValue={input.role}
options={Constants.BbRoles} options={Constants.BbRoles}
error={input.error} error={validateInput(input.email, index)}
/> />
</div>
<div
class:fix-height={errors.length && !errors[index]}
class:normal-height={errors.length && !!errors[index]}
style="width: 10% "
>
<Icon
name="Close"
hoverable
size="S"
on:click={() => removeInput(index)}
/>
</div>
</div>
{/each} {/each}
<div> <div>
<ActionButton on:click={addNewInput} icon="Add">Add email</ActionButton> <ActionButton on:click={addNewInput} icon="Add">Add email</ActionButton>
@ -80,6 +120,14 @@
</ModalContent> </ModalContent>
<style> <style>
.fix-height {
margin-bottom: 5%;
}
.normal-height {
margin-bottom: 0%;
}
:global(.spectrum-Picker) { :global(.spectrum-Picker) {
border-top-left-radius: 0px; border-top-left-radius: 0px;
} }

View File

@ -17,7 +17,7 @@
</div> </div>
{value} {value}
{:else} {:else}
<div class="text">Not Available</div> <div class="text">-</div>
{/if} {/if}
</div> </div>

View File

@ -72,19 +72,12 @@
name: {}, name: {},
email: {}, email: {},
role: { role: {
noPropagation: true,
sortable: false, sortable: false,
}, },
...(hasGroupsLicense && { ...(hasGroupsLicense && {
userGroups: { sortable: false, displayName: "User groups" }, userGroups: { sortable: false, displayName: "User groups" },
}), }),
apps: { width: "120px" }, apps: {},
settings: {
sortable: false,
width: "60px",
displayName: "",
align: "Right",
},
} }
$: userData = [] $: userData = []
@ -323,6 +316,13 @@
</Modal> </Modal>
<style> <style>
.pagination {
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: var(--spacing-xl);
}
.field { .field {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -66,7 +66,7 @@
selectedApp?.status === AppStatus.DEPLOYED && latestDeployments?.length > 0 selectedApp?.status === AppStatus.DEPLOYED && latestDeployments?.length > 0
$: appUrl = `${window.origin}/app${selectedApp?.url}` $: appUrl = `${window.origin}/app${selectedApp?.url}`
$: tabs = ["Overview", "Automation History", "Backups", "Settings"] $: tabs = ["Overview", "Automation History", "Backups", "Settings", "Access"]
$: selectedTab = "Overview" $: selectedTab = "Overview"
const backToAppList = () => { const backToAppList = () => {

View File

@ -29,7 +29,6 @@
let pageInfo = createPaginationStore() let pageInfo = createPaginationStore()
let fixedAppId let fixedAppId
$: page = $pageInfo.page $: page = $pageInfo.page
$: fetchUsers(page, search)
$: hasGroupsLicense = $auth.user?.license.features.includes( $: hasGroupsLicense = $auth.user?.license.features.includes(
Constants.Features.USER_GROUPS Constants.Features.USER_GROUPS
@ -37,12 +36,6 @@
$: fixedAppId = apps.getProdAppID(app.devId) $: fixedAppId = apps.getProdAppID(app.devId)
$: appUsers =
$users.data?.filter(x => {
return Object.keys(x.roles).find(y => {
return y === fixedAppId
})
}) || []
$: appGroups = $groups.filter(x => { $: appGroups = $groups.filter(x => {
return x.apps.includes(app.appId) return x.apps.includes(app.appId)
}) })
@ -130,6 +123,12 @@
pageInfo.loading() pageInfo.loading()
await users.search({ page, appId: fixedAppId }) await users.search({ page, appId: fixedAppId })
pageInfo.fetched($users.hasNextPage, $users.nextPage) pageInfo.fetched($users.hasNextPage, $users.nextPage)
appUsers =
$users.data?.filter(x => {
return Object.keys(x.roles).find(y => {
return y === fixedAppId
})
}) || []
} catch (error) { } catch (error) {
notifications.error("Error getting user list") notifications.error("Error getting user list")
} }
@ -137,6 +136,8 @@
onMount(async () => { onMount(async () => {
try { try {
await fetchUsers(page, search)
await groups.actions.init() await groups.actions.init()
await apps.load() await apps.load()
await roles.fetch() await roles.fetch()
@ -212,8 +213,14 @@
page={$pageInfo.pageNumber} page={$pageInfo.pageNumber}
hasPrevPage={$pageInfo.loading ? false : $pageInfo.hasPrevPage} hasPrevPage={$pageInfo.loading ? false : $pageInfo.hasPrevPage}
hasNextPage={$pageInfo.loading ? false : $pageInfo.hasNextPage} hasNextPage={$pageInfo.loading ? false : $pageInfo.hasNextPage}
goToPrevPage={pageInfo.prevPage} goToPrevPage={async () => {
goToNextPage={pageInfo.nextPage} await pageInfo.prevPage()
fetchUsers(page, search)
}}
goToNextPage={async () => {
await pageInfo.nextPage()
fetchUsers(page, search)
}}
/> />
</div> </div>
{/if} {/if}
@ -264,4 +271,11 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.pagination {
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: var(--spacing-xl);
}
</style> </style>

View File

@ -13,7 +13,6 @@
export let app export let app
export let addData export let addData
export let appUsers = [] export let appUsers = []
let prevSearch = undefined, let prevSearch = undefined,
search = undefined search = undefined
let pageInfo = createPaginationStore() let pageInfo = createPaginationStore()
@ -32,7 +31,7 @@
prevSearch = search prevSearch = search
try { try {
pageInfo.loading() pageInfo.loading()
await users.search({ page, search }) await users.search({ page, email: search })
pageInfo.fetched($users.hasNextPage, $users.nextPage) pageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) { } catch (error) {
notifications.error("Error getting user list") notifications.error("Error getting user list")
@ -83,10 +82,10 @@
<PickerDropdown <PickerDropdown
autocomplete autocomplete
primaryOptions={optionSections} primaryOptions={optionSections}
placeholder={"Search Users"}
secondaryOptions={$roles} secondaryOptions={$roles}
bind:primaryValue={input.id} bind:primaryValue={input.id}
bind:secondaryValue={input.role} bind:secondaryValue={input.role}
bind:searchTerm={search}
getPrimaryOptionLabel={group => group.name} getPrimaryOptionLabel={group => group.name}
getPrimaryOptionValue={group => group.name} getPrimaryOptionValue={group => group.name}
getPrimaryOptionIcon={group => group.icon} getPrimaryOptionIcon={group => group.icon}

View File

@ -11,7 +11,7 @@
export let app export let app
export let deployments export let deployments
export let navigateTab export let navigateTab
let userCount
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const unpublishApp = () => { const unpublishApp = () => {
@ -40,7 +40,9 @@
} }
onMount(async () => { onMount(async () => {
await users.search({ page: undefined, appId: "app_" + app.appId }) let resp = await users.getUserCountByApp({ appId: "app_" + app.appId })
userCount = resp.userCount
await users.search({ appId: "app_" + app.appId, limit: 4 })
}) })
</script> </script>
@ -155,7 +157,8 @@
</div> </div>
<div class="users-text"> <div class="users-text">
{$users?.data.length} users have access to this app {userCount}
{userCount > 1 ? `users have` : `user has`} access to this app
</div> </div>
</Layout> </Layout>
{:else} {:else}

View File

@ -61,6 +61,7 @@ export function createUsersStore() {
break break
case "admin": case "admin":
body.admin = { global: true } body.admin = { global: true }
body.builder = { global: true }
break break
} }
@ -77,6 +78,10 @@ export function createUsersStore() {
update(users => users.filter(user => user._id !== id)) update(users => users.filter(user => user._id !== id))
} }
async function getUserCountByApp({ appId }) {
return await API.getUserCountByApp({ appId })
}
async function bulkDelete(userIds) { async function bulkDelete(userIds) {
await API.deleteUsers(userIds) await API.deleteUsers(userIds)
} }
@ -99,6 +104,7 @@ export function createUsersStore() {
create, create,
save, save,
bulkDelete, bulkDelete,
getUserCountByApp,
delete: del, delete: del,
} }
} }

View File

@ -172,4 +172,15 @@ export const buildUserEndpoints = API => ({
}, },
}) })
}, },
/**
* Accepts an invite to join the platform and creates a user.
* @param inviteCode the invite code sent in the email
* @param password the password for the newly created user
*/
getUserCountByApp: async ({ appId }) => {
return await API.get({
url: `/api/global/users/count/${appId}`,
})
},
}) })

View File

@ -32,8 +32,10 @@ exports.updateAppRole = (user, { appId } = {}) => {
// if a role wasn't found then either set as admin (builder) or public (everyone else) // if a role wasn't found then either set as admin (builder) or public (everyone else)
if (!user.roleId && user.builder && user.builder.global) { if (!user.roleId && user.builder && user.builder.global) {
user.roleId = BUILTIN_ROLE_IDS.ADMIN user.roleId = BUILTIN_ROLE_IDS.ADMIN
} else if (!user.roleId) { } else if (!user.roleId && !user?.userGroups?.length) {
user.roleId = BUILTIN_ROLE_IDS.PUBLIC user.roleId = BUILTIN_ROLE_IDS.PUBLIC
} else if (user?.userGroups?.length) {
user.roleId = null
} }
delete user.roles delete user.roles
@ -41,10 +43,8 @@ exports.updateAppRole = (user, { appId } = {}) => {
} }
async function checkGroupRoles(user, { appId } = {}) { async function checkGroupRoles(user, { appId } = {}) {
if (!user.roleId) {
let roleId = await groups.getGroupRoleId(user, appId) let roleId = await groups.getGroupRoleId(user, appId)
user.roleId = roleId user.roleId = roleId
}
return user return user
} }
@ -54,7 +54,7 @@ async function processUser(user, { appId } = {}) {
delete user.password delete user.password
} }
user = await exports.updateAppRole(user, { appId }) user = await exports.updateAppRole(user, { appId })
if (user?.userGroups?.length) { if (!user.roleId && user?.userGroups?.length) {
user = await checkGroupRoles(user, { appId }) user = await checkGroupRoles(user, { appId })
} }

View File

@ -3,7 +3,7 @@ import { checkInviteCode } from "../../../utilities/redis"
import { sendEmail } from "../../../utilities/email" import { sendEmail } from "../../../utilities/email"
import { users } from "../../../sdk" import { users } from "../../../sdk"
import env from "../../../environment" import env from "../../../environment"
import { User, CloudAccount, UserGroup } from "@budibase/types" import { User, CloudAccount } from "@budibase/types"
import { import {
events, events,
errors, errors,
@ -114,6 +114,16 @@ export const adminUser = async (ctx: any) => {
}) })
} }
export const countByApp = async (ctx: any) => {
const appId = ctx.params.appId
try {
const response = await users.countUsersByApp(appId)
ctx.body = response
} catch (err: any) {
ctx.throw(err.status || 400, err)
}
}
export const destroy = async (ctx: any) => { export const destroy = async (ctx: any) => {
const id = ctx.params.id const id = ctx.params.id

View File

@ -64,6 +64,7 @@ router
.post("/api/global/users/search", builderOrAdmin, controller.search) .post("/api/global/users/search", builderOrAdmin, controller.search)
.delete("/api/global/users/:id", adminOnly, controller.destroy) .delete("/api/global/users/:id", adminOnly, controller.destroy)
.post("/api/global/users/bulkDelete", adminOnly, controller.bulkDelete) .post("/api/global/users/bulkDelete", adminOnly, controller.bulkDelete)
.get("/api/global/users/count/:appId", adminOnly, controller.countByApp)
.get("/api/global/roles/:appId") .get("/api/global/roles/:appId")
.post( .post(
"/api/global/users/invite", "/api/global/users/invite",

View File

@ -20,7 +20,7 @@ import { groups as groupUtils } from "@budibase/pro"
const PAGE_LIMIT = 8 const PAGE_LIMIT = 8
export const allUsers = async (newDb?: any) => { export const allUsers = async () => {
const db = tenancy.getGlobalDB() const db = tenancy.getGlobalDB()
const response = await db.allDocs( const response = await db.allDocs(
dbUtils.getGlobalUserParams(null, { dbUtils.getGlobalUserParams(null, {
@ -30,6 +30,13 @@ export const allUsers = async (newDb?: any) => {
return response.rows.map((row: any) => row.doc) return response.rows.map((row: any) => row.doc)
} }
export const countUsersByApp = async (appId: string) => {
let response: any = await usersCore.searchGlobalUsersByApp(appId, {})
return {
userCount: response.length,
}
}
export const paginatedUsers = async ({ export const paginatedUsers = async ({
page, page,
email, email,