Merge pull request #6989 from Budibase/pc/fixes
User Management UI fixes
This commit is contained in:
commit
dbaebf0df3
|
@ -50,4 +50,5 @@ exports.getTenantFeatureFlags = tenantId => {
|
|||
exports.FeatureFlag = {
|
||||
LICENSING: "LICENSING",
|
||||
GOOGLE_SHEETS: "GOOGLE_SHEETS",
|
||||
USER_GROUPS: "USER_GROUPS",
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -3,6 +3,7 @@ import { get } from "svelte/store"
|
|||
|
||||
export const FEATURE_FLAGS = {
|
||||
LICENSING: "LICENSING",
|
||||
USER_GROUPS: "USER_GROUPS",
|
||||
}
|
||||
|
||||
export const isEnabled = featureFlag => {
|
||||
|
|
|
@ -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([
|
||||
{
|
||||
|
|
|
@ -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" />
|
||||
<Avatar
|
||||
size="XXL"
|
||||
initials={user?.email
|
||||
.split(" ")
|
||||
.map(x => x[0])
|
||||
.join("")}
|
||||
/>
|
||||
|
||||
{#if fullName}
|
||||
<div class="subtitle">
|
||||
<Heading size="S"
|
||||
>{$userFetch?.data?.firstName +
|
||||
" " +
|
||||
$userFetch?.data?.lastName}</Heading
|
||||
>
|
||||
<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>
|
||||
|
|
|
@ -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}
|
||||
<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={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}
|
||||
<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;
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
</div>
|
||||
{value}
|
||||
{:else}
|
||||
<div class="text">Not Available</div>
|
||||
<div class="text">-</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue