Merge branch 'develop' into budi-6158/prevent_duplicated_group_names

This commit is contained in:
Adria Navarro 2023-05-10 10:58:41 +02:00
commit 9b6cb5d09d
11 changed files with 305 additions and 125 deletions

View File

@ -1,47 +1,28 @@
<script> <script>
import { url, goto } from "@roxi/routify"
import { import {
Button, ActionMenu,
Layout,
Heading, Heading,
Icon, Icon,
Popover, Layout,
notifications,
Table,
ActionMenu,
MenuItem, MenuItem,
Modal, Modal,
Table,
notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import UserGroupPicker from "components/settings/UserGroupPicker.svelte" import { goto, url } from "@roxi/routify"
import { createPaginationStore } from "helpers/pagination"
import { users, apps, groups, auth, features } from "stores/portal"
import { onMount, setContext } from "svelte"
import { roles } from "stores/backend"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { Breadcrumb, Breadcrumbs } from "components/portal/page"
import { roles } from "stores/backend"
import { apps, auth, features, groups } from "stores/portal"
import { onMount, setContext } from "svelte"
import AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte"
import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte"
import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte" import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte"
import GroupIcon from "./_components/GroupIcon.svelte" import GroupIcon from "./_components/GroupIcon.svelte"
import { Breadcrumbs, Breadcrumb } from "components/portal/page" import GroupUsers from "./_components/GroupUsers.svelte"
import AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte"
import RemoveUserTableRenderer from "./_components/RemoveUserTableRenderer.svelte"
import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte"
import ScimBanner from "../_components/SCIMBanner.svelte"
export let groupId export let groupId
$: userSchema = {
email: {
width: "1fr",
},
...(readonly
? {}
: {
_id: {
displayName: "",
width: "auto",
borderLeft: true,
},
}),
}
const appSchema = { const appSchema = {
name: { name: {
width: "2fr", width: "2fr",
@ -50,12 +31,6 @@
width: "1fr", width: "1fr",
}, },
} }
const customUserTableRenderers = [
{
column: "_id",
component: RemoveUserTableRenderer,
},
]
const customAppTableRenderers = [ const customAppTableRenderers = [
{ {
column: "name", column: "name",
@ -67,20 +42,12 @@
}, },
] ]
let popoverAnchor
let popover
let searchTerm = ""
let prevSearch = undefined
let pageInfo = createPaginationStore()
let loaded = false let loaded = false
let editModal, deleteModal let editModal, deleteModal
$: scimEnabled = $features.isScimEnabled $: scimEnabled = $features.isScimEnabled
$: readonly = !$auth.isAdmin || scimEnabled $: readonly = !$auth.isAdmin || scimEnabled
$: page = $pageInfo.page
$: fetchUsers(page, searchTerm)
$: group = $groups.find(x => x._id === groupId) $: group = $groups.find(x => x._id === groupId)
$: filtered = $users.data
$: groupApps = $apps $: groupApps = $apps
.filter(app => .filter(app =>
groups.actions groups.actions
@ -97,25 +64,6 @@
} }
} }
async function fetchUsers(page, search) {
if ($pageInfo.loading) {
return
}
// need to remove the page if they've started searching
if (search && !prevSearch) {
pageInfo.reset()
page = undefined
}
prevSearch = search
try {
pageInfo.loading()
await users.search({ page, email: search })
pageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) {
notifications.error("Error getting user list")
}
}
async function deleteGroup() { async function deleteGroup() {
try { try {
await groups.actions.delete(group) await groups.actions.delete(group)
@ -138,17 +86,9 @@
} }
} }
const removeUser = async id => {
await groups.actions.removeUser(groupId, id)
}
const removeApp = async app => { const removeApp = async app => {
await groups.actions.removeApp(groupId, apps.getProdAppID(app.devId)) await groups.actions.removeApp(groupId, apps.getProdAppID(app.devId))
} }
setContext("users", {
removeUser,
})
setContext("roles", { setContext("roles", {
updateRole: () => {}, updateRole: () => {},
removeRole: removeApp, removeRole: removeApp,
@ -190,41 +130,7 @@
</div> </div>
<Layout noPadding gap="S"> <Layout noPadding gap="S">
<div class="header"> <GroupUsers {groupId} />
<Heading size="S">Users</Heading>
{#if !scimEnabled}
<div bind:this={popoverAnchor}>
<Button disabled={readonly} on:click={popover.show()} cta
>Add user</Button
>
</div>
{:else}
<ScimBanner />
{/if}
<Popover align="right" bind:this={popover} anchor={popoverAnchor}>
<UserGroupPicker
bind:searchTerm
labelKey="email"
selected={group.users?.map(user => user._id)}
list={$users.data}
on:select={e => groups.actions.addUser(groupId, e.detail)}
on:deselect={e => groups.actions.removeUser(groupId, e.detail)}
/>
</Popover>
</div>
<Table
schema={userSchema}
data={group?.users}
allowEditRows={false}
customPlaceholder
customRenderers={customUserTableRenderers}
on:click={e => $goto(`../users/${e.detail._id}`)}
>
<div class="placeholder" slot="placeholder">
<Heading size="S">This user group doesn't have any users</Heading>
</div>
</Table>
</Layout> </Layout>
<Layout noPadding gap="S"> <Layout noPadding gap="S">

View File

@ -0,0 +1,59 @@
<script>
import { Button, Popover, notifications } from "@budibase/bbui"
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
import { createPaginationStore } from "helpers/pagination"
import { auth, groups, users } from "stores/portal"
export let groupId
export let onUsersUpdated
let popoverAnchor
let popover
let searchTerm = ""
let prevSearch = undefined
let pageInfo = createPaginationStore()
$: readonly = !$auth.isAdmin
$: page = $pageInfo.page
$: searchUsers(page, searchTerm)
$: group = $groups.find(x => x._id === groupId)
async function searchUsers(page, search) {
if ($pageInfo.loading) {
return
}
// need to remove the page if they've started searching
if (search && !prevSearch) {
pageInfo.reset()
page = undefined
}
prevSearch = search
try {
pageInfo.loading()
await users.search({ page, email: search })
pageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) {
notifications.error("Error getting user list")
}
}
</script>
<div bind:this={popoverAnchor}>
<Button disabled={readonly} on:click={popover.show()} cta>Add user</Button>
</div>
<Popover align="right" bind:this={popover} anchor={popoverAnchor}>
<UserGroupPicker
bind:searchTerm
labelKey="email"
selected={group.users?.map(user => user._id)}
list={$users.data}
on:select={async e => {
await groups.actions.addUser(groupId, e.detail)
onUsersUpdated()
}}
on:deselect={async e => {
await groups.actions.removeUser(groupId, e.detail)
onUsersUpdated()
}}
/>
</Popover>

View File

@ -0,0 +1,112 @@
<script>
import EditUserPicker from "./EditUserPicker.svelte"
import { Heading, Pagination, Table } from "@budibase/bbui"
import { fetchData } from "@budibase/frontend-core"
import { goto } from "@roxi/routify"
import { API } from "api"
import { auth, features, groups } from "stores/portal"
import { setContext } from "svelte"
import ScimBanner from "../../_components/SCIMBanner.svelte"
import RemoveUserTableRenderer from "../_components/RemoveUserTableRenderer.svelte"
export let groupId
const fetchGroupUsers = fetchData({
API,
datasource: {
type: "groupUser",
},
options: {
query: {
groupId,
},
},
})
$: userSchema = {
email: {
width: "1fr",
},
...(readonly
? {}
: {
_id: {
displayName: "",
width: "auto",
borderLeft: true,
},
}),
}
const customUserTableRenderers = [
{
column: "_id",
component: RemoveUserTableRenderer,
},
]
$: scimEnabled = $features.isScimEnabled
$: readonly = !$auth.isAdmin || scimEnabled
const removeUser = async id => {
await groups.actions.removeUser(groupId, id)
fetchGroupUsers.refresh()
}
setContext("users", {
removeUser,
})
</script>
<div class="header">
<Heading size="S">Users</Heading>
{#if !scimEnabled}
<EditUserPicker {groupId} onUsersUpdated={fetchGroupUsers.getInitialData} />
{:else}
<ScimBanner />
{/if}
</div>
<Table
schema={userSchema}
data={$fetchGroupUsers?.rows}
allowEditRows={false}
customPlaceholder
customRenderers={customUserTableRenderers}
on:click={e => $goto(`../users/${e.detail._id}`)}
>
<div class="placeholder" slot="placeholder">
<Heading size="S">This user group doesn't have any users</Heading>
</div>
</Table>
<div class="pagination">
<Pagination
page={$fetchGroupUsers.pageNumber + 1}
hasPrevPage={$fetchGroupUsers.loading
? false
: $fetchGroupUsers.hasPrevPage}
hasNextPage={$fetchGroupUsers.loading
? false
: $fetchGroupUsers.hasNextPage}
goToPrevPage={$fetchGroupUsers.loading ? null : fetchGroupUsers.prevPage}
goToNextPage={$fetchGroupUsers.loading ? null : fetchGroupUsers.nextPage}
/>
</div>
<style>
.header {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-l);
}
.header :global(.spectrum-Heading) {
flex: 1 1 auto;
}
.placeholder {
width: 100%;
text-align: center;
}
</style>

View File

@ -28,7 +28,7 @@ export function createGroupsStore() {
// on the backend anyway // on the backend anyway
if (get(licensing).groupsEnabled) { if (get(licensing).groupsEnabled) {
const groups = await API.getGroups() const groups = await API.getGroups()
store.set(groups) store.set(groups.data)
} }
}, },

View File

@ -52,6 +52,20 @@ export const buildGroupsEndpoints = API => {
}) })
}, },
/**
* Gets a group users by the group id
*/
getGroupUsers: async ({ id, bookmark }) => {
let url = `/api/global/groups/${id}/users?`
if (bookmark) {
url += `bookmark=${bookmark}`
}
return await API.get({
url,
})
},
/** /**
* Adds users to a group * Adds users to a group
* @param groupId The group to update * @param groupId The group to update

View File

@ -362,13 +362,35 @@ export default class DataFetch {
return return
} }
this.store.update($store => ({ ...$store, loading: true })) this.store.update($store => ({ ...$store, loading: true }))
const { rows, info, error } = await this.getPage() const { rows, info, error, cursor } = await this.getPage()
let { cursors } = get(this.store)
const { pageNumber } = get(this.store)
if (!rows.length && pageNumber > 0) {
// If the full page is gone but we have previous pages, navigate to the previous page
this.store.update($store => ({
...$store,
loading: false,
cursors: cursors.slice(0, pageNumber),
}))
return await this.prevPage()
}
const currentNextCursor = cursors[pageNumber + 1]
if (currentNextCursor != cursor) {
// If the current cursor changed, all the next pages need to be updated, so we mark them as stale
cursors = cursors.slice(0, pageNumber + 1)
cursors[pageNumber + 1] = cursor
}
this.store.update($store => ({ this.store.update($store => ({
...$store, ...$store,
rows, rows,
info, info,
loading: false, loading: false,
error, error,
cursors,
})) }))
} }

View File

@ -0,0 +1,50 @@
import { get } from "svelte/store"
import DataFetch from "./DataFetch.js"
import { TableNames } from "../constants"
export default class GroupUserFetch extends DataFetch {
constructor(opts) {
super({
...opts,
datasource: {
tableId: TableNames.USERS,
},
})
}
determineFeatureFlags() {
return {
supportsSearch: true,
supportsSort: false,
supportsPagination: true,
}
}
async getDefinition() {
return {
schema: {},
}
}
async getData() {
const { query, cursor } = get(this.store)
try {
const res = await this.API.getGroupUsers({
id: query.groupId,
bookmark: cursor,
})
return {
rows: res?.users || [],
hasNextPage: res?.hasNextPage || false,
cursor: res?.bookmark || null,
}
} catch (error) {
return {
rows: [],
hasNextPage: false,
error,
}
}
}
}

View File

@ -6,6 +6,7 @@ import NestedProviderFetch from "./NestedProviderFetch.js"
import FieldFetch from "./FieldFetch.js" import FieldFetch from "./FieldFetch.js"
import JSONArrayFetch from "./JSONArrayFetch.js" import JSONArrayFetch from "./JSONArrayFetch.js"
import UserFetch from "./UserFetch.js" import UserFetch from "./UserFetch.js"
import GroupUserFetch from "./GroupUserFetch.js"
const DataFetchMap = { const DataFetchMap = {
table: TableFetch, table: TableFetch,
@ -13,6 +14,7 @@ const DataFetchMap = {
query: QueryFetch, query: QueryFetch,
link: RelationshipFetch, link: RelationshipFetch,
user: UserFetch, user: UserFetch,
groupUser: GroupUserFetch,
// Client specific datasource types // Client specific datasource types
provider: NestedProviderFetch, provider: NestedProviderFetch,

View File

@ -1,3 +1,4 @@
import { PaginationResponse } from "../../api"
import { Document } from "../document" import { Document } from "../document"
export interface UserGroup extends Document { export interface UserGroup extends Document {
@ -21,3 +22,15 @@ export interface GroupUser {
export interface UserGroupRoles { export interface UserGroupRoles {
[key: string]: string [key: string]: string
} }
export interface SearchGroupRequest {}
export interface SearchGroupResponse {
data: UserGroup[]
}
export interface SearchUserGroupResponse extends PaginationResponse {
users: {
_id: any
email: any
}[]
}

View File

@ -65,6 +65,7 @@ export type DatabaseQueryOpts = {
key?: string key?: string
keys?: string[] keys?: string[]
group?: boolean group?: boolean
startkey_docid?: string
} }
export const isDocument = (doc: any): doc is Document => { export const isDocument = (doc: any): doc is Document => {

View File

@ -69,9 +69,11 @@ const bulkCreate = async (users: User[], groupIds: string[]) => {
return await userSdk.bulkCreate(users, groupIds) return await userSdk.bulkCreate(users, groupIds)
} }
export const bulkUpdate = async (ctx: any) => { export const bulkUpdate = async (
ctx: Ctx<BulkUserRequest, BulkUserResponse>
) => {
const currentUserId = ctx.user._id const currentUserId = ctx.user._id
const input = ctx.request.body as BulkUserRequest const input = ctx.request.body
let created, deleted let created, deleted
try { try {
if (input.create) { if (input.create) {
@ -83,7 +85,7 @@ export const bulkUpdate = async (ctx: any) => {
} catch (err: any) { } catch (err: any) {
ctx.throw(err.status || 400, err?.message || err) ctx.throw(err.status || 400, err?.message || err)
} }
ctx.body = { created, deleted } as BulkUserResponse ctx.body = { created, deleted }
} }
const parseBooleanParam = (param: any) => { const parseBooleanParam = (param: any) => {
@ -184,15 +186,15 @@ export const destroy = async (ctx: any) => {
} }
} }
export const getAppUsers = async (ctx: any) => { export const getAppUsers = async (ctx: Ctx<SearchUsersRequest>) => {
const body = ctx.request.body as SearchUsersRequest const body = ctx.request.body
const users = await userSdk.getUsersByAppAccess(body?.appId) const users = await userSdk.getUsersByAppAccess(body?.appId)
ctx.body = { data: users } ctx.body = { data: users }
} }
export const search = async (ctx: any) => { export const search = async (ctx: Ctx<SearchUsersRequest>) => {
const body = ctx.request.body as SearchUsersRequest const body = ctx.request.body
if (body.paginated === false) { if (body.paginated === false) {
await getAppUsers(ctx) await getAppUsers(ctx)
@ -238,8 +240,8 @@ export const tenantUserLookup = async (ctx: any) => {
/* /*
Encapsulate the app user onboarding flows here. Encapsulate the app user onboarding flows here.
*/ */
export const onboardUsers = async (ctx: any) => { export const onboardUsers = async (ctx: Ctx<InviteUsersRequest>) => {
const request = ctx.request.body as InviteUsersRequest | BulkUserRequest const request = ctx.request.body
const isBulkCreate = "create" in request const isBulkCreate = "create" in request
const emailConfigured = await isEmailConfigured() const emailConfigured = await isEmailConfigured()
@ -255,7 +257,7 @@ export const onboardUsers = async (ctx: any) => {
} else if (emailConfigured) { } else if (emailConfigured) {
onboardingResponse = await inviteMultiple(ctx) onboardingResponse = await inviteMultiple(ctx)
} else if (!emailConfigured) { } else if (!emailConfigured) {
const inviteRequest = ctx.request.body as InviteUsersRequest const inviteRequest = ctx.request.body
let createdPasswords: any = {} let createdPasswords: any = {}
@ -295,10 +297,10 @@ export const onboardUsers = async (ctx: any) => {
} }
} }
export const invite = async (ctx: any) => { export const invite = async (ctx: Ctx<InviteUserRequest>) => {
const request = ctx.request.body as InviteUserRequest const request = ctx.request.body
let multiRequest = [request] as InviteUsersRequest let multiRequest = [request]
const response = await userSdk.invite(multiRequest) const response = await userSdk.invite(multiRequest)
// explicitly throw for single user invite // explicitly throw for single user invite
@ -318,8 +320,8 @@ export const invite = async (ctx: any) => {
} }
} }
export const inviteMultiple = async (ctx: any) => { export const inviteMultiple = async (ctx: Ctx<InviteUsersRequest>) => {
const request = ctx.request.body as InviteUsersRequest const request = ctx.request.body
ctx.body = await userSdk.invite(request) ctx.body = await userSdk.invite(request)
} }
@ -424,7 +426,6 @@ export const inviteAccept = async (
if (err.code === ErrorCode.USAGE_LIMIT_EXCEEDED) { if (err.code === ErrorCode.USAGE_LIMIT_EXCEEDED) {
// explicitly re-throw limit exceeded errors // explicitly re-throw limit exceeded errors
ctx.throw(400, err) ctx.throw(400, err)
return
} }
console.warn("Error inviting user", err) console.warn("Error inviting user", err)
ctx.throw(400, "Unable to create new user, invitation invalid.") ctx.throw(400, "Unable to create new user, invitation invalid.")