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)) {
providerType = user.providerType
}
const accountHolder = account?.budibaseUserId === user._id || false
const verified =
account && account?.budibaseUserId === user._id ? account.verified : false
const accountHolder = await users.getExistingAccounts([user.email])
const isAccountHolder = accountHolder.length > 0
const verified = !!account && isAccountHolder && account.verified
const installationId = await getInstallationId()
const hosting = account ? account.hosting : getHostingFromEnv()
const environment = getDeploymentEnvironment()
@ -185,7 +185,7 @@ const identifyUser = async (
installationId,
tenantId,
verified,
accountHolder,
accountHolder: isAccountHolder,
providerType,
builder,
admin,
@ -207,9 +207,10 @@ const identifyAccount = async (account: Account) => {
const environment = getDeploymentEnvironment()
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
id = account.budibaseUserId
id = user._id
}
}

View File

@ -1,29 +1,6 @@
import { getDB } from "../db/db"
import { getGlobalDBName } from "../context"
import { TenantInfo } from "@budibase/types"
export function getTenantDB(tenantId: string) {
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,
SaveUserOpts,
User,
UserStatus,
UserGroup,
UserIdentifier,
UserStatus,
PlatformUserBySsoId,
PlatformUserById,
AnyDocument,
} from "@budibase/types"
import {
getAccountHolderFromUserIds,
getAccountHolderFromUsers,
isAdmin,
isCreator,
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 response: BulkUserDeleted = {
@ -421,13 +424,13 @@ export class UserDB {
}
// remove the account holder from the delete request if present
const account = await getAccountHolderFromUserIds(userIds)
if (account) {
userIds = userIds.filter(u => u !== account.budibaseUserId)
const accountHolder = await getAccountHolderFromUsers(users)
if (accountHolder) {
users = users.filter(u => u.userId !== accountHolder.userId)
// mark user as unsuccessful
response.unsuccessful.push({
_id: account.budibaseUserId,
email: account.email,
_id: accountHolder.userId,
email: accountHolder.email,
reason: "Account holder cannot be deleted",
})
}
@ -435,7 +438,7 @@ export class UserDB {
// Get users and delete
const allDocsResponse = await db.allDocs<User>({
include_docs: true,
keys: userIds,
keys: users.map(u => u.userId),
})
const usersToDelete = allDocsResponse.rows.map(user => {
return user.doc!

View File

@ -70,6 +70,17 @@ export async function getAllUserIds() {
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[]) {
const db = getGlobalDB()
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 env from "../environment"
import { getFirstPlatformUser } from "./lookup"
import { getExistingAccounts, getFirstPlatformUser } from "./lookup"
import { EmailUnavailableError } from "../errors"
import { getTenantId } from "../context"
import { sdk } from "@budibase/shared-core"
import { getAccountByTenantId } from "../accounts"
import { BUILTIN_ROLE_IDS } from "../security/roles"
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(
userIds: string[]
): Promise<CloudAccount | undefined> {
export async function getAccountHolderFromUsers(
users: Array<UserIdentifier>
): Promise<UserIdentifier | undefined> {
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const tenantId = getTenantId()
const account = await getAccountByTenantId(tenantId)
if (!account) {
throw new Error(`Account not found for tenantId=${tenantId}`)
}
const budibaseUserId = account.budibaseUserId
if (userIds.includes(budibaseUserId)) {
return account
}
const accountMetadata = await getExistingAccounts(
users.map(user => user.email)
)
return users.find(user =>
accountMetadata.map(metadata => metadata.email).includes(user.email)
)
}
}

View File

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

View File

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

View File

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

View File

@ -122,14 +122,14 @@ export const buildUserEndpoints = API => ({
/**
* 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({
url: `/api/global/users/bulk`,
body: {
delete: {
userIds,
users,
},
},
})
@ -296,9 +296,9 @@ export const buildUserEndpoints = API => ({
})
},
getTenantInfo: async ({ tenantId }) => {
getAccountHolder: async () => {
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 {
delete?: {
userIds: string[]
users: Array<{
userId: string
email: string
}>
}
create?: {
roles?: any[]

View File

@ -7,4 +7,3 @@ export * from "./schedule"
export * from "./templates"
export * from "./environmentVariables"
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
export interface UserIdentifier {
userId: string
email: string
}
export interface User extends Document {
tenantId: 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,
User,
UserCtx,
UserIdentifier,
} from "@budibase/types"
import {
accounts,
users,
cache,
ErrorCode,
events,
@ -55,8 +57,8 @@ export const save = async (ctx: UserCtx<User, SaveUserResponse>) => {
const requestUser = ctx.request.body
// Do not allow the account holder role to be changed
const tenantInfo = await tenancy.getTenantInfo(requestUser.tenantId)
if (tenantInfo?.owner.email === requestUser.email) {
const accountMetadata = await users.getExistingAccounts([requestUser.email])
if (accountMetadata?.length > 0) {
if (
requestUser.admin?.global !== true ||
requestUser.builder?.global !== true
@ -103,11 +105,14 @@ export const addSsoSupport = async (ctx: Ctx<AddSSoUserRequest>) => {
}
}
const bulkDelete = async (userIds: string[], currentUserId: string) => {
if (userIds?.indexOf(currentUserId) !== -1) {
const bulkDelete = async (
users: Array<UserIdentifier>,
currentUserId: string
) => {
if (users.find(u => u.userId === currentUserId)) {
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[]) => {
@ -130,7 +135,7 @@ export const bulkUpdate = async (
created = await bulkCreate(input.create.users, input.create.groups)
}
if (input.delete) {
deleted = await bulkDelete(input.delete.userIds, currentUserId)
deleted = await bulkDelete(input.delete.users, currentUserId)
}
} catch (err: any) {
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.
*/

View File

@ -71,10 +71,6 @@ const PUBLIC_ENDPOINTS = [
route: "/api/global/users/invite",
method: "GET",
},
{
route: "/api/global/tenant",
method: "POST",
},
]
const NO_TENANCY_ENDPOINTS = [
@ -121,11 +117,7 @@ const NO_TENANCY_ENDPOINTS = [
method: "GET",
},
{
route: "/api/global/tenant",
method: "POST",
},
{
route: "/api/global/tenant/:id",
route: "/api/global/users/accountholder",
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)
})
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 () => {
const user = await config.createUser(structures.users.builderUser())
jest.clearAllMocks()
@ -592,55 +570,21 @@ describe("/api/global/users", () => {
describe("POST /api/global/users/bulk (delete)", () => {
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(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", () => {

View File

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

View File

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

View File

@ -66,7 +66,14 @@ export const buildUserBulkUserValidation = (isSelf = false) => {
users: Joi.array().items(Joi.object(schema).required().unknown(true)),
}),
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 { TestAPI, TestAPIOpts } from "./base"
@ -15,12 +14,4 @@ export class TenantAPI extends TestAPI {
.set(opts?.headers)
.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
}
bulkDeleteUsers = async (userIds: string[], status?: number) => {
const body: BulkUserRequest = { delete: { userIds } }
bulkDeleteUsers = async (
users: Array<{
userId: string
email: string
}>,
status?: number
) => {
const body: BulkUserRequest = { delete: { users } }
const res = await this.request
.post(`/api/global/users/bulk`)
.send(body)