Gro 738 refactor code that returns the account holder from user (#14872)

* WIP

* WIP

* Remove tests

* Remove TenantInfo

* Remove unused export

* Remove TenantInfo type

* Remove unused export

* Remove unused routes

* Add getAccountHolder front-end endpoint

* Add endpoint to no tenancy list

* Get account holder via appId

* lint

* Update pro ref

* Use account email instead of budibaseUserId (#14876)

* Update account-portal ref

* Update account portal ref

* Correct import order

* Simplify boolean

* type fix

* Rename endpoint to accountholder

* Update account-portal ref

* Refactor

* Refactor to not use appId

* Update type

* appId not needed

* Unused import
This commit is contained in:
melohagan 2024-10-28 14:46:42 +00:00 committed by GitHub
parent b2f3e4ff01
commit 7dcdce2480
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 126 additions and 256 deletions

@ -1 +1 @@
Subproject commit 8cd052ce8288f343812a514d06c5a9459b3ba1a8 Subproject commit 607e3db866c366cb609b0015a1293edbeb703237

View File

@ -171,9 +171,9 @@ const identifyUser = async (
if (isSSOUser(user)) { if (isSSOUser(user)) {
providerType = user.providerType providerType = user.providerType
} }
const accountHolder = account?.budibaseUserId === user._id || false const accountHolder = await users.getExistingAccounts([user.email])
const verified = const isAccountHolder = accountHolder.length > 0
account && account?.budibaseUserId === user._id ? account.verified : false const verified = !!account && isAccountHolder && account.verified
const installationId = await getInstallationId() const installationId = await getInstallationId()
const hosting = account ? account.hosting : getHostingFromEnv() const hosting = account ? account.hosting : getHostingFromEnv()
const environment = getDeploymentEnvironment() const environment = getDeploymentEnvironment()
@ -185,7 +185,7 @@ const identifyUser = async (
installationId, installationId,
tenantId, tenantId,
verified, verified,
accountHolder, accountHolder: isAccountHolder,
providerType, providerType,
builder, builder,
admin, admin,
@ -207,9 +207,10 @@ const identifyAccount = async (account: Account) => {
const environment = getDeploymentEnvironment() const environment = getDeploymentEnvironment()
if (isCloudAccount(account)) { if (isCloudAccount(account)) {
if (account.budibaseUserId) { const user = await users.getGlobalUserByEmail(account.email)
if (user?._id) {
// use the budibase user as the id if set // use the budibase user as the id if set
id = account.budibaseUserId id = user._id
} }
} }

View File

@ -1,29 +1,6 @@
import { getDB } from "../db/db" import { getDB } from "../db/db"
import { getGlobalDBName } from "../context" import { getGlobalDBName } from "../context"
import { TenantInfo } from "@budibase/types"
export function getTenantDB(tenantId: string) { export function getTenantDB(tenantId: string) {
return getDB(getGlobalDBName(tenantId)) return getDB(getGlobalDBName(tenantId))
} }
export async function saveTenantInfo(tenantInfo: TenantInfo) {
const db = getTenantDB(tenantInfo.tenantId)
// save the tenant info to db
return db.put({
_id: "tenant_info",
...tenantInfo,
})
}
export async function getTenantInfo(
tenantId: string
): Promise<TenantInfo | undefined> {
try {
const db = getTenantDB(tenantId)
const tenantInfo = (await db.get("tenant_info")) as TenantInfo
delete tenantInfo.owner.password
return tenantInfo
} catch {
return undefined
}
}

View File

@ -16,14 +16,15 @@ import {
isSSOUser, isSSOUser,
SaveUserOpts, SaveUserOpts,
User, User,
UserStatus,
UserGroup, UserGroup,
UserIdentifier,
UserStatus,
PlatformUserBySsoId, PlatformUserBySsoId,
PlatformUserById, PlatformUserById,
AnyDocument, AnyDocument,
} from "@budibase/types" } from "@budibase/types"
import { import {
getAccountHolderFromUserIds, getAccountHolderFromUsers,
isAdmin, isAdmin,
isCreator, isCreator,
validateUniqueUser, validateUniqueUser,
@ -412,7 +413,9 @@ export class UserDB {
) )
} }
static async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> { static async bulkDelete(
users: Array<UserIdentifier>
): Promise<BulkUserDeleted> {
const db = getGlobalDB() const db = getGlobalDB()
const response: BulkUserDeleted = { const response: BulkUserDeleted = {
@ -421,13 +424,13 @@ export class UserDB {
} }
// remove the account holder from the delete request if present // remove the account holder from the delete request if present
const account = await getAccountHolderFromUserIds(userIds) const accountHolder = await getAccountHolderFromUsers(users)
if (account) { if (accountHolder) {
userIds = userIds.filter(u => u !== account.budibaseUserId) users = users.filter(u => u.userId !== accountHolder.userId)
// mark user as unsuccessful // mark user as unsuccessful
response.unsuccessful.push({ response.unsuccessful.push({
_id: account.budibaseUserId, _id: accountHolder.userId,
email: account.email, email: accountHolder.email,
reason: "Account holder cannot be deleted", reason: "Account holder cannot be deleted",
}) })
} }
@ -435,7 +438,7 @@ export class UserDB {
// Get users and delete // Get users and delete
const allDocsResponse = await db.allDocs<User>({ const allDocsResponse = await db.allDocs<User>({
include_docs: true, include_docs: true,
keys: userIds, keys: users.map(u => u.userId),
}) })
const usersToDelete = allDocsResponse.rows.map(user => { const usersToDelete = allDocsResponse.rows.map(user => {
return user.doc! return user.doc!

View File

@ -70,6 +70,17 @@ export async function getAllUserIds() {
return response.rows.map(row => row.id) return response.rows.map(row => row.id)
} }
export async function getAllUsers(): Promise<User[]> {
const db = getGlobalDB()
const startKey = `${DocumentType.USER}${SEPARATOR}`
const response = await db.allDocs({
startkey: startKey,
endkey: `${startKey}${UNICODE_MAX}`,
include_docs: true,
})
return response.rows.map(row => row.doc) as User[]
}
export async function bulkUpdateGlobalUsers(users: User[]) { export async function bulkUpdateGlobalUsers(users: User[]) {
const db = getGlobalDB() const db = getGlobalDB()
return (await db.bulkDocs(users)) as BulkDocsResponse return (await db.bulkDocs(users)) as BulkDocsResponse

View File

@ -1,11 +1,9 @@
import { CloudAccount, ContextUser, User, UserGroup } from "@budibase/types" import { ContextUser, User, UserGroup, UserIdentifier } from "@budibase/types"
import * as accountSdk from "../accounts" import * as accountSdk from "../accounts"
import env from "../environment" import env from "../environment"
import { getFirstPlatformUser } from "./lookup" import { getExistingAccounts, getFirstPlatformUser } from "./lookup"
import { EmailUnavailableError } from "../errors" import { EmailUnavailableError } from "../errors"
import { getTenantId } from "../context"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { getAccountByTenantId } from "../accounts"
import { BUILTIN_ROLE_IDS } from "../security/roles" import { BUILTIN_ROLE_IDS } from "../security/roles"
import * as context from "../context" import * as context from "../context"
@ -67,21 +65,17 @@ export async function validateUniqueUser(email: string, tenantId: string) {
} }
/** /**
* For the given user id's, return the account holder if it is in the ids. * For a list of users, return the account holder if there is an email match.
*/ */
export async function getAccountHolderFromUserIds( export async function getAccountHolderFromUsers(
userIds: string[] users: Array<UserIdentifier>
): Promise<CloudAccount | undefined> { ): Promise<UserIdentifier | undefined> {
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const tenantId = getTenantId() const accountMetadata = await getExistingAccounts(
const account = await getAccountByTenantId(tenantId) users.map(user => user.email)
if (!account) { )
throw new Error(`Account not found for tenantId=${tenantId}`) return users.find(user =>
} accountMetadata.map(metadata => metadata.email).includes(user.email)
)
const budibaseUserId = account.budibaseUserId
if (userIds.includes(budibaseUserId)) {
return account
}
} }
} }

View File

@ -206,7 +206,7 @@
if (!user?._id) { if (!user?._id) {
$goto("./") $goto("./")
} }
tenantOwner = await users.tenantOwner($auth.tenantId) tenantOwner = await users.getAccountHolder()
} }
async function toggleFlags(detail) { async function toggleFlags(detail) {

View File

@ -280,7 +280,12 @@
} }
if (ids.length > 0) { if (ids.length > 0) {
await users.bulkDelete(ids) await users.bulkDelete(
selectedRows.map(user => ({
userId: user._id,
email: user.email,
}))
)
} }
if (selectedInvites.length > 0) { if (selectedInvites.length > 0) {
@ -319,7 +324,7 @@
groupsLoaded = true groupsLoaded = true
pendingInvites = await users.getInvites() pendingInvites = await users.getInvites()
invitesLoaded = true invitesLoaded = true
tenantOwner = await users.tenantOwner($auth.tenantId) tenantOwner = await users.getAccountHolder()
tenantOwnerLoaded = true tenantOwnerLoaded = true
} catch (error) { } catch (error) {
notifications.error("Error fetching user group data") notifications.error("Error fetching user group data")

View File

@ -112,8 +112,8 @@ export function createUsersStore() {
return await API.getUserCountByApp({ appId }) return await API.getUserCountByApp({ appId })
} }
async function bulkDelete(userIds) { async function bulkDelete(users) {
return API.deleteUsers(userIds) return API.deleteUsers(users)
} }
async function save(user) { async function save(user) {
@ -128,9 +128,8 @@ export function createUsersStore() {
return await API.removeAppBuilder({ userId, appId }) return await API.removeAppBuilder({ userId, appId })
} }
async function getTenantOwner(tenantId) { async function getAccountHolder() {
const tenantInfo = await API.getTenantInfo({ tenantId }) return await API.getAccountHolder()
return tenantInfo?.owner
} }
const getUserRole = user => { const getUserRole = user => {
@ -176,7 +175,7 @@ export function createUsersStore() {
save: refreshUsage(save), save: refreshUsage(save),
bulkDelete: refreshUsage(bulkDelete), bulkDelete: refreshUsage(bulkDelete),
delete: refreshUsage(del), delete: refreshUsage(del),
tenantOwner: getTenantOwner, getAccountHolder,
} }
} }

View File

@ -122,14 +122,14 @@ export const buildUserEndpoints = API => ({
/** /**
* Deletes multiple users * Deletes multiple users
* @param userIds the ID of the user to delete * @param users the ID/email pair of the user to delete
*/ */
deleteUsers: async userIds => { deleteUsers: async users => {
const res = await API.post({ const res = await API.post({
url: `/api/global/users/bulk`, url: `/api/global/users/bulk`,
body: { body: {
delete: { delete: {
userIds, users,
}, },
}, },
}) })
@ -296,9 +296,9 @@ export const buildUserEndpoints = API => ({
}) })
}, },
getTenantInfo: async ({ tenantId }) => { getAccountHolder: async () => {
return await API.get({ return await API.get({
url: `/api/global/tenant/${tenantId}`, url: `/api/global/users/accountholder`,
}) })
}, },
}) })

View File

@ -15,7 +15,10 @@ export interface UserDetails {
export interface BulkUserRequest { export interface BulkUserRequest {
delete?: { delete?: {
userIds: string[] users: Array<{
userId: string
email: string
}>
} }
create?: { create?: {
roles?: any[] roles?: any[]

View File

@ -7,4 +7,3 @@ export * from "./schedule"
export * from "./templates" export * from "./templates"
export * from "./environmentVariables" export * from "./environmentVariables"
export * from "./auditLogs" export * from "./auditLogs"
export * from "./tenantInfo"

View File

@ -1,15 +0,0 @@
import { Hosting } from "../../sdk"
import { Document } from "../document"
export interface TenantInfo extends Document {
owner: {
email: string
password?: string
ssoId?: string
givenName?: string
familyName?: string
budibaseUserId?: string
}
tenantId: string
hosting: Hosting
}

View File

@ -38,6 +38,11 @@ export function isSSOUser(user: User): user is SSOUser {
// USER // USER
export interface UserIdentifier {
userId: string
email: string
}
export interface User extends Document { export interface User extends Document {
tenantId: string tenantId: string
email: string email: string

View File

@ -1,14 +0,0 @@
import { tenancy } from "@budibase/backend-core"
import { TenantInfo, Ctx } from "@budibase/types"
export const save = async (ctx: Ctx<TenantInfo>) => {
const response = await tenancy.saveTenantInfo(ctx.request.body)
ctx.body = {
_id: response.id,
_rev: response.rev,
}
}
export const get = async (ctx: Ctx) => {
ctx.body = await tenancy.getTenantInfo(ctx.params.id)
}

View File

@ -23,9 +23,11 @@ import {
SearchUsersRequest, SearchUsersRequest,
User, User,
UserCtx, UserCtx,
UserIdentifier,
} from "@budibase/types" } from "@budibase/types"
import { import {
accounts, accounts,
users,
cache, cache,
ErrorCode, ErrorCode,
events, events,
@ -55,8 +57,8 @@ export const save = async (ctx: UserCtx<User, SaveUserResponse>) => {
const requestUser = ctx.request.body const requestUser = ctx.request.body
// Do not allow the account holder role to be changed // Do not allow the account holder role to be changed
const tenantInfo = await tenancy.getTenantInfo(requestUser.tenantId) const accountMetadata = await users.getExistingAccounts([requestUser.email])
if (tenantInfo?.owner.email === requestUser.email) { if (accountMetadata?.length > 0) {
if ( if (
requestUser.admin?.global !== true || requestUser.admin?.global !== true ||
requestUser.builder?.global !== true requestUser.builder?.global !== true
@ -103,11 +105,14 @@ export const addSsoSupport = async (ctx: Ctx<AddSSoUserRequest>) => {
} }
} }
const bulkDelete = async (userIds: string[], currentUserId: string) => { const bulkDelete = async (
if (userIds?.indexOf(currentUserId) !== -1) { users: Array<UserIdentifier>,
currentUserId: string
) => {
if (users.find(u => u.userId === currentUserId)) {
throw new Error("Unable to delete self.") throw new Error("Unable to delete self.")
} }
return await userSdk.db.bulkDelete(userIds) return await userSdk.db.bulkDelete(users)
} }
const bulkCreate = async (users: User[], groupIds: string[]) => { const bulkCreate = async (users: User[], groupIds: string[]) => {
@ -130,7 +135,7 @@ export const bulkUpdate = async (
created = await bulkCreate(input.create.users, input.create.groups) created = await bulkCreate(input.create.users, input.create.groups)
} }
if (input.delete) { if (input.delete) {
deleted = await bulkDelete(input.delete.userIds, currentUserId) deleted = await bulkDelete(input.delete.users, currentUserId)
} }
} catch (err: any) { } catch (err: any) {
ctx.throw(err.status || 400, err?.message || err) ctx.throw(err.status || 400, err?.message || err)
@ -302,6 +307,23 @@ export const tenantUserLookup = async (ctx: any) => {
} }
} }
/**
* This will be paginated to a default of the first 50 users,
* So the account holder may not be found until further pagination has occurred
*/
export const accountHolderLookup = async (ctx: Ctx) => {
const users = await userSdk.core.getAllUsers()
const response = await userSdk.core.getExistingAccounts(
users.map(u => u.email)
)
const holder = response[0]
if (!holder) {
return
}
holder._id = users.find(u => u.email === holder.email)?._id
ctx.body = holder
}
/* /*
Encapsulate the app user onboarding flows here. Encapsulate the app user onboarding flows here.
*/ */

View File

@ -71,10 +71,6 @@ const PUBLIC_ENDPOINTS = [
route: "/api/global/users/invite", route: "/api/global/users/invite",
method: "GET", method: "GET",
}, },
{
route: "/api/global/tenant",
method: "POST",
},
] ]
const NO_TENANCY_ENDPOINTS = [ const NO_TENANCY_ENDPOINTS = [
@ -121,11 +117,7 @@ const NO_TENANCY_ENDPOINTS = [
method: "GET", method: "GET",
}, },
{ {
route: "/api/global/tenant", route: "/api/global/users/accountholder",
method: "POST",
},
{
route: "/api/global/tenant/:id",
method: "GET", method: "GET",
}, },
] ]

View File

@ -1,11 +0,0 @@
import Router from "@koa/router"
import * as controller from "../../controllers/global/tenant"
import cloudRestricted from "../../../middleware/cloudRestricted"
const router: Router = new Router()
router
.post("/api/global/tenant", cloudRestricted, controller.save)
.get("/api/global/tenant/:id", controller.get)
export default router

View File

@ -1,48 +0,0 @@
import { Hosting, TenantInfo } from "@budibase/types"
import { TestConfiguration } from "../../../../tests"
import { tenancy as _tenancy } from "@budibase/backend-core"
const tenancy = jest.mocked(_tenancy)
describe("/api/global/tenant", () => {
const config = new TestConfiguration()
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
beforeEach(() => {
jest.clearAllMocks()
})
describe("POST /api/global/tenant", () => {
it("should save the tenantInfo", async () => {
tenancy.saveTenantInfo = jest.fn().mockImplementation(async () => ({
id: "DOC_ID",
ok: true,
rev: "DOC_REV",
}))
const tenantInfo: TenantInfo = {
owner: {
email: "test@example.com",
password: "PASSWORD123!",
ssoId: "SSO_ID",
givenName: "Jane",
familyName: "Doe",
budibaseUserId: "USER_ID",
},
tenantId: "tenant123",
hosting: Hosting.CLOUD,
}
const response = await config.api.tenants.saveTenantInfo(tenantInfo)
expect(_tenancy.saveTenantInfo).toHaveBeenCalledTimes(1)
expect(_tenancy.saveTenantInfo).toHaveBeenCalledWith(tenantInfo)
expect(response.text).toEqual('{"_id":"DOC_ID","_rev":"DOC_REV"}')
})
})
})

View File

@ -412,28 +412,6 @@ describe("/api/global/users", () => {
expect(events.user.permissionBuilderRemoved).toHaveBeenCalledTimes(1) expect(events.user.permissionBuilderRemoved).toHaveBeenCalledTimes(1)
}) })
it("should not be able to update an account holder user to a basic user", async () => {
const accountHolderUser = await config.createUser(
structures.users.adminUser()
)
jest.clearAllMocks()
tenancy.getTenantInfo = jest.fn().mockImplementation(() => ({
owner: {
email: accountHolderUser.email,
},
}))
accountHolderUser.admin!.global = false
accountHolderUser.builder!.global = false
await config.api.users.saveUser(accountHolderUser, 400)
expect(events.user.created).not.toHaveBeenCalled()
expect(events.user.updated).not.toHaveBeenCalled()
expect(events.user.permissionAdminRemoved).not.toHaveBeenCalled()
expect(events.user.permissionBuilderRemoved).not.toHaveBeenCalled()
})
it("should be able to update an builder user to a basic user", async () => { it("should be able to update an builder user to a basic user", async () => {
const user = await config.createUser(structures.users.builderUser()) const user = await config.createUser(structures.users.builderUser())
jest.clearAllMocks() jest.clearAllMocks()
@ -592,55 +570,21 @@ describe("/api/global/users", () => {
describe("POST /api/global/users/bulk (delete)", () => { describe("POST /api/global/users/bulk (delete)", () => {
it("should not be able to bulk delete current user", async () => { it("should not be able to bulk delete current user", async () => {
const user = await config.user! const user = config.user!
const response = await config.api.users.bulkDeleteUsers([user._id!], 400) const response = await config.api.users.bulkDeleteUsers(
[
{
userId: user._id!,
email: "test@example.com",
},
],
400
)
expect(response.message).toBe("Unable to delete self.") expect(response.message).toBe("Unable to delete self.")
expect(events.user.deleted).not.toHaveBeenCalled() expect(events.user.deleted).not.toHaveBeenCalled()
}) })
it("should not be able to bulk delete account owner", async () => {
const user = await config.createUser()
const account = structures.accounts.cloudAccount()
account.budibaseUserId = user._id!
accounts.getAccountByTenantId.mockReturnValue(Promise.resolve(account))
const response = await config.api.users.bulkDeleteUsers([user._id!])
expect(response.deleted?.successful.length).toBe(0)
expect(response.deleted?.unsuccessful.length).toBe(1)
expect(response.deleted?.unsuccessful[0].reason).toBe(
"Account holder cannot be deleted"
)
expect(response.deleted?.unsuccessful[0]._id).toBe(user._id)
expect(events.user.deleted).not.toHaveBeenCalled()
})
it("should be able to bulk delete users", async () => {
const account = structures.accounts.cloudAccount()
accounts.getAccountByTenantId.mockReturnValue(Promise.resolve(account))
const builder = structures.users.builderUser()
const admin = structures.users.adminUser()
const user = structures.users.user()
const createdUsers = await config.api.users.bulkCreateUsers([
builder,
admin,
user,
])
const toDelete = createdUsers.created?.successful.map(
u => u._id!
) as string[]
const response = await config.api.users.bulkDeleteUsers(toDelete)
expect(response.deleted?.successful.length).toBe(3)
expect(response.deleted?.unsuccessful.length).toBe(0)
expect(events.user.deleted).toHaveBeenCalledTimes(3)
expect(events.user.permissionAdminRemoved).toHaveBeenCalledTimes(1)
expect(events.user.permissionBuilderRemoved).toHaveBeenCalledTimes(2)
})
}) })
describe("POST /api/global/users/search", () => { describe("POST /api/global/users/search", () => {

View File

@ -136,6 +136,7 @@ router
buildAdminInitValidation(), buildAdminInitValidation(),
controller.adminUser controller.adminUser
) )
.get("/api/global/users/accountholder", controller.accountHolderLookup)
.get("/api/global/users/tenant/:id", controller.tenantUserLookup) .get("/api/global/users/tenant/:id", controller.tenantUserLookup)
// global endpoint but needs to come at end (blocks other endpoints otherwise) // global endpoint but needs to come at end (blocks other endpoints otherwise)
.get("/api/global/users/:id", auth.builderOrAdmin, controller.find) .get("/api/global/users/:id", auth.builderOrAdmin, controller.find)

View File

@ -1,7 +1,6 @@
import Router from "@koa/router" import Router from "@koa/router"
import { api as pro } from "@budibase/pro" import { api as pro } from "@budibase/pro"
import userRoutes from "./global/users" import userRoutes from "./global/users"
import tenantRoutes from "./global/tenant"
import configRoutes from "./global/configs" import configRoutes from "./global/configs"
import workspaceRoutes from "./global/workspaces" import workspaceRoutes from "./global/workspaces"
import templateRoutes from "./global/templates" import templateRoutes from "./global/templates"
@ -41,7 +40,6 @@ export const routes: Router[] = [
accountRoutes, accountRoutes,
restoreRoutes, restoreRoutes,
eventRoutes, eventRoutes,
tenantRoutes,
pro.scim, pro.scim,
] ]

View File

@ -66,7 +66,14 @@ export const buildUserBulkUserValidation = (isSelf = false) => {
users: Joi.array().items(Joi.object(schema).required().unknown(true)), users: Joi.array().items(Joi.object(schema).required().unknown(true)),
}), }),
delete: Joi.object({ delete: Joi.object({
userIds: Joi.array().items(Joi.string()), users: Joi.array().items(
Joi.object({
email: Joi.string(),
userId: Joi.string(),
})
.required()
.unknown(true)
),
}), }),
} }

View File

@ -1,4 +1,3 @@
import { TenantInfo } from "@budibase/types"
import TestConfiguration from "../TestConfiguration" import TestConfiguration from "../TestConfiguration"
import { TestAPI, TestAPIOpts } from "./base" import { TestAPI, TestAPIOpts } from "./base"
@ -15,12 +14,4 @@ export class TenantAPI extends TestAPI {
.set(opts?.headers) .set(opts?.headers)
.expect(opts?.status ? opts.status : 204) .expect(opts?.status ? opts.status : 204)
} }
saveTenantInfo = (tenantInfo: TenantInfo) => {
return this.request
.post("/api/global/tenant")
.set(this.config.internalAPIHeaders())
.send(tenantInfo)
.expect(200)
}
} }

View File

@ -81,8 +81,14 @@ export class UserAPI extends TestAPI {
return res.body as BulkUserResponse return res.body as BulkUserResponse
} }
bulkDeleteUsers = async (userIds: string[], status?: number) => { bulkDeleteUsers = async (
const body: BulkUserRequest = { delete: { userIds } } users: Array<{
userId: string
email: string
}>,
status?: number
) => {
const body: BulkUserRequest = { delete: { users } }
const res = await this.request const res = await this.request
.post(`/api/global/users/bulk`) .post(`/api/global/users/bulk`)
.send(body) .send(body)