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 = {
LICENSING: "LICENSING",
GOOGLE_SHEETS: "GOOGLE_SHEETS",
USER_GROUPS: "USER_GROUPS",
}

View File

@ -115,6 +115,16 @@
class:is-disabled={disabled}
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
{id}
on:click

View File

@ -8,6 +8,7 @@
import Icon from "../../Icon/Icon.svelte"
import StatusLight from "../../StatusLight/StatusLight.svelte"
import Detail from "../../Typography/Detail.svelte"
import Search from "./Search.svelte"
export let primaryLabel = ""
export let primaryValue = null
@ -22,7 +23,6 @@
export let secondaryFieldText = ""
export let secondaryFieldIcon = ""
export let secondaryFieldColour = ""
export let getPrimaryOptionLabel = option => option
export let getPrimaryOptionValue = option => option
export let getPrimaryOptionColour = () => null
export let getPrimaryOptionIcon = () => null
@ -43,17 +43,12 @@
let searchTerm = null
$: groupTitles = Object.keys(primaryOptions)
$: filteredOptions = getFilteredOptions(
primaryOptions,
searchTerm,
getPrimaryOptionLabel
)
let iconData
/*
$: iconData = primaryOptions?.find(x => {
return x.name === primaryFieldText
})
*/
const updateSearch = e => {
dispatch("search", e.detail)
}
const updateValue = newValue => {
if (readonly) {
return
@ -107,16 +102,6 @@
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>
<div
@ -183,6 +168,15 @@
class:auto-width={autoWidth}
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">
{#if placeholderOption}
<li

View File

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

View File

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

View File

@ -45,6 +45,7 @@
},
])
}
if (admin) {
menu = menu.concat([
{
@ -52,11 +53,6 @@
href: "/builder/portal/manage/users",
heading: "Manage",
},
{
title: "User Groups",
href: "/builder/portal/manage/groups",
},
{ title: "Auth", href: "/builder/portal/manage/auth" },
{ 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) {
menu = menu.concat([
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@
export let app
export let deployments
export let navigateTab
let userCount
const dispatch = createEventDispatcher()
const unpublishApp = () => {
@ -40,7 +40,9 @@
}
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>
@ -155,7 +157,8 @@
</div>
<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>
</Layout>
{:else}

View File

@ -61,6 +61,7 @@ export function createUsersStore() {
break
case "admin":
body.admin = { global: true }
body.builder = { global: true }
break
}
@ -77,6 +78,10 @@ export function createUsersStore() {
update(users => users.filter(user => user._id !== id))
}
async function getUserCountByApp({ appId }) {
return await API.getUserCountByApp({ appId })
}
async function bulkDelete(userIds) {
await API.deleteUsers(userIds)
}
@ -99,6 +104,7 @@ export function createUsersStore() {
create,
save,
bulkDelete,
getUserCountByApp,
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 (!user.roleId && user.builder && user.builder.global) {
user.roleId = BUILTIN_ROLE_IDS.ADMIN
} else if (!user.roleId) {
} else if (!user.roleId && !user?.userGroups?.length) {
user.roleId = BUILTIN_ROLE_IDS.PUBLIC
} else if (user?.userGroups?.length) {
user.roleId = null
}
delete user.roles
@ -41,10 +43,8 @@ exports.updateAppRole = (user, { appId } = {}) => {
}
async function checkGroupRoles(user, { appId } = {}) {
if (!user.roleId) {
let roleId = await groups.getGroupRoleId(user, appId)
user.roleId = roleId
}
let roleId = await groups.getGroupRoleId(user, appId)
user.roleId = roleId
return user
}
@ -54,7 +54,7 @@ async function processUser(user, { appId } = {}) {
delete user.password
}
user = await exports.updateAppRole(user, { appId })
if (user?.userGroups?.length) {
if (!user.roleId && user?.userGroups?.length) {
user = await checkGroupRoles(user, { appId })
}

View File

@ -3,7 +3,7 @@ import { checkInviteCode } from "../../../utilities/redis"
import { sendEmail } from "../../../utilities/email"
import { users } from "../../../sdk"
import env from "../../../environment"
import { User, CloudAccount, UserGroup } from "@budibase/types"
import { User, CloudAccount } from "@budibase/types"
import {
events,
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) => {
const id = ctx.params.id

View File

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

View File

@ -20,7 +20,7 @@ import { groups as groupUtils } from "@budibase/pro"
const PAGE_LIMIT = 8
export const allUsers = async (newDb?: any) => {
export const allUsers = async () => {
const db = tenancy.getGlobalDB()
const response = await db.allDocs(
dbUtils.getGlobalUserParams(null, {
@ -30,6 +30,13 @@ export const allUsers = async (newDb?: any) => {
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 ({
page,
email,