Merge branch 'v3-ui' into feature/automation-branching-ux

This commit is contained in:
Adria Navarro 2024-10-28 16:39:07 +01:00
commit 392dc087c1
29 changed files with 477 additions and 287 deletions

View File

@ -3,7 +3,7 @@ name: Deploy QA
on: on:
push: push:
branches: branches:
- v3-ui - feature/automation-branching-ux
workflow_dispatch: workflow_dispatch:
jobs: jobs:

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "2.33.3", "version": "2.33.4",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

@ -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

@ -11,11 +11,13 @@ import {
IncludeRelationship, IncludeRelationship,
InternalSearchFilterOperator, InternalSearchFilterOperator,
isManyToOne, isManyToOne,
isOneToMany,
OneToManyRelationshipFieldMetadata, OneToManyRelationshipFieldMetadata,
Operation, Operation,
PaginationJson, PaginationJson,
QueryJson, QueryJson,
RelationshipFieldMetadata, RelationshipFieldMetadata,
RelationshipType,
Row, Row,
SearchFilters, SearchFilters,
SortJson, SortJson,
@ -50,13 +52,15 @@ import sdk from "../../../sdk"
import env from "../../../environment" import env from "../../../environment"
import { makeExternalQuery } from "../../../integrations/base/query" import { makeExternalQuery } from "../../../integrations/base/query"
import { dataFilters, helpers } from "@budibase/shared-core" import { dataFilters, helpers } from "@budibase/shared-core"
import { isRelationshipColumn } from "../../../db/utils"
export interface ManyRelationship { interface ManyRelationship {
tableId?: string tableId?: string
id?: string id?: string
isUpdate?: boolean isUpdate?: boolean
key: string key: string
[key: string]: any [key: string]: any
relationshipType: RelationshipType
} }
export interface RunConfig { export interface RunConfig {
@ -384,6 +388,7 @@ export class ExternalRequest<T extends Operation> {
[otherKey]: breakRowIdField(relationship)[0], [otherKey]: breakRowIdField(relationship)[0],
// leave the ID for enrichment later // leave the ID for enrichment later
[thisKey]: `{{ literal ${tablePrimary} }}`, [thisKey]: `{{ literal ${tablePrimary} }}`,
relationshipType: RelationshipType.MANY_TO_MANY,
}) })
} }
} }
@ -400,6 +405,7 @@ export class ExternalRequest<T extends Operation> {
[thisKey]: breakRowIdField(relationship)[0], [thisKey]: breakRowIdField(relationship)[0],
// leave the ID for enrichment later // leave the ID for enrichment later
[otherKey]: `{{ literal ${tablePrimary} }}`, [otherKey]: `{{ literal ${tablePrimary} }}`,
relationshipType: RelationshipType.MANY_TO_ONE,
}) })
} }
} }
@ -420,14 +426,30 @@ export class ExternalRequest<T extends Operation> {
return { row: newRow as T, manyRelationships } return { row: newRow as T, manyRelationships }
} }
private getLookupRelationsKey(relationship: {
relationshipType: RelationshipType
fieldName: string
through?: string
}) {
if (relationship.relationshipType === RelationshipType.MANY_TO_MANY) {
return `${relationship.through}_${relationship.fieldName}`
}
return relationship.fieldName
}
/** /**
* This is a cached lookup, of relationship records, this is mainly for creating/deleting junction * This is a cached lookup, of relationship records, this is mainly for creating/deleting junction
* information. * information.
*/ */
async lookupRelations(tableId: string, row: Row) { private async lookupRelations(tableId: string, row: Row) {
const related: { const related: Record<
[key: string]: { rows: Row[]; isMany: boolean; tableId: string } string,
} = {} {
rows: Row[]
isMany: boolean
tableId: string
}
> = {}
const { tableName } = breakExternalTableId(tableId) const { tableName } = breakExternalTableId(tableId)
const table = this.tables[tableName] const table = this.tables[tableName]
// @ts-ignore // @ts-ignore
@ -459,11 +481,8 @@ export class ExternalRequest<T extends Operation> {
"Unable to lookup relationships - undefined column properties." "Unable to lookup relationships - undefined column properties."
) )
} }
const { tableName: relatedTableName } =
breakExternalTableId(relatedTableId) if (!lookupField || !row?.[lookupField]) {
// @ts-ignore
const linkPrimaryKey = this.tables[relatedTableName].primary[0]
if (!lookupField || !row?.[lookupField] == null) {
continue continue
} }
const endpoint = getEndpoint(relatedTableId, Operation.READ) const endpoint = getEndpoint(relatedTableId, Operation.READ)
@ -487,10 +506,8 @@ export class ExternalRequest<T extends Operation> {
!Array.isArray(response) || isKnexEmptyReadResponse(response) !Array.isArray(response) || isKnexEmptyReadResponse(response)
? [] ? []
: response : response
const storeTo = isManyToMany(field)
? field.throughFrom || linkPrimaryKey related[this.getLookupRelationsKey(field)] = {
: fieldName
related[storeTo] = {
rows, rows,
isMany: isManyToMany(field), isMany: isManyToMany(field),
tableId: relatedTableId, tableId: relatedTableId,
@ -518,7 +535,8 @@ export class ExternalRequest<T extends Operation> {
const promises = [] const promises = []
const related = await this.lookupRelations(mainTableId, row) const related = await this.lookupRelations(mainTableId, row)
for (let relationship of relationships) { for (let relationship of relationships) {
const { key, tableId, isUpdate, id, ...rest } = relationship const { key, tableId, isUpdate, id, relationshipType, ...rest } =
relationship
const body: { [key: string]: any } = processObjectSync(rest, row, {}) const body: { [key: string]: any } = processObjectSync(rest, row, {})
const linkTable = this.getTable(tableId) const linkTable = this.getTable(tableId)
const relationshipPrimary = linkTable?.primary || [] const relationshipPrimary = linkTable?.primary || []
@ -529,7 +547,14 @@ export class ExternalRequest<T extends Operation> {
const linkSecondary = relationshipPrimary[1] const linkSecondary = relationshipPrimary[1]
const rows = related[key]?.rows || [] const rows =
related[
this.getLookupRelationsKey({
relationshipType,
fieldName: key,
through: relationship.tableId,
})
]?.rows || []
const relationshipMatchPredicate = ({ const relationshipMatchPredicate = ({
row, row,
@ -574,12 +599,12 @@ export class ExternalRequest<T extends Operation> {
} }
} }
// finally cleanup anything that needs to be removed // finally cleanup anything that needs to be removed
for (let [colName, { isMany, rows, tableId }] of Object.entries(related)) { for (const [field, { isMany, rows, tableId }] of Object.entries(related)) {
const table: Table | undefined = this.getTable(tableId) const table: Table | undefined = this.getTable(tableId)
// if it's not the foreign key skip it, nothing to do // if it's not the foreign key skip it, nothing to do
if ( if (
!table || !table ||
(!isMany && table.primary && table.primary.indexOf(colName) !== -1) (!isMany && table.primary && table.primary.indexOf(field) !== -1)
) { ) {
continue continue
} }
@ -587,7 +612,7 @@ export class ExternalRequest<T extends Operation> {
const rowId = generateIdForRow(row, table) const rowId = generateIdForRow(row, table)
const promise: Promise<any> = isMany const promise: Promise<any> = isMany
? this.removeManyToManyRelationships(rowId, table) ? this.removeManyToManyRelationships(rowId, table)
: this.removeOneToManyRelationships(rowId, table, colName) : this.removeOneToManyRelationships(rowId, table, field)
if (promise) { if (promise) {
promises.push(promise) promises.push(promise)
} }
@ -599,23 +624,24 @@ export class ExternalRequest<T extends Operation> {
async removeRelationshipsToRow(table: Table, rowId: string) { async removeRelationshipsToRow(table: Table, rowId: string) {
const row = await this.getRow(table, rowId) const row = await this.getRow(table, rowId)
const related = await this.lookupRelations(table._id!, row) const related = await this.lookupRelations(table._id!, row)
for (let column of Object.values(table.schema)) { for (const column of Object.values(table.schema)) {
const relationshipColumn = column as RelationshipFieldMetadata if (!isRelationshipColumn(column) || isOneToMany(column)) {
if (!isManyToOne(relationshipColumn)) {
continue continue
} }
const { rows, isMany, tableId } = related[relationshipColumn.fieldName]
const relatedByTable = related[this.getLookupRelationsKey(column)]
if (!relatedByTable) {
continue
}
const { rows, isMany, tableId } = relatedByTable
const table = this.getTable(tableId)! const table = this.getTable(tableId)!
await Promise.all( await Promise.all(
rows.map(row => { rows.map(row => {
const rowId = generateIdForRow(row, table) const rowId = generateIdForRow(row, table)
return isMany return isMany
? this.removeManyToManyRelationships(rowId, table) ? this.removeManyToManyRelationships(rowId, table)
: this.removeOneToManyRelationships( : this.removeOneToManyRelationships(rowId, table, column.fieldName)
rowId,
table,
relationshipColumn.fieldName
)
}) })
) )
} }

View File

@ -952,6 +952,105 @@ describe.each([
}) })
}) })
}) })
!isLucene &&
describe("relations to same table", () => {
let relatedRows: Row[]
beforeAll(async () => {
const relatedTable = await config.api.table.save(
defaultTable({
schema: {
name: { name: "name", type: FieldType.STRING },
},
})
)
const relatedTableId = relatedTable._id!
table = await config.api.table.save(
defaultTable({
schema: {
name: { name: "name", type: FieldType.STRING },
related1: {
type: FieldType.LINK,
name: "related1",
fieldName: "main1",
tableId: relatedTableId,
relationshipType: RelationshipType.MANY_TO_MANY,
},
related2: {
type: FieldType.LINK,
name: "related2",
fieldName: "main2",
tableId: relatedTableId,
relationshipType: RelationshipType.MANY_TO_MANY,
},
},
})
)
relatedRows = await Promise.all([
config.api.row.save(relatedTableId, { name: "foo" }),
config.api.row.save(relatedTableId, { name: "bar" }),
config.api.row.save(relatedTableId, { name: "baz" }),
config.api.row.save(relatedTableId, { name: "boo" }),
])
})
it("can create rows with both relationships", async () => {
const row = await config.api.row.save(table._id!, {
name: "test",
related1: [relatedRows[0]._id!],
related2: [relatedRows[1]._id!],
})
expect(row).toEqual(
expect.objectContaining({
name: "test",
related1: [
{
_id: relatedRows[0]._id,
primaryDisplay: relatedRows[0].name,
},
],
related2: [
{
_id: relatedRows[1]._id,
primaryDisplay: relatedRows[1].name,
},
],
})
)
})
it("can create rows with no relationships", async () => {
const row = await config.api.row.save(table._id!, {
name: "test",
})
expect(row.related1).toBeUndefined()
expect(row.related2).toBeUndefined()
})
it("can create rows with only one relationships field", async () => {
const row = await config.api.row.save(table._id!, {
name: "test",
related1: [],
related2: [relatedRows[1]._id!],
})
expect(row).toEqual(
expect.objectContaining({
name: "test",
related2: [
{
_id: relatedRows[1]._id,
primaryDisplay: relatedRows[1].name,
},
],
})
)
expect(row.related1).toBeUndefined()
})
})
}) })
describe("get", () => { describe("get", () => {
@ -1054,6 +1153,134 @@ describe.each([
const rows = await config.api.row.fetch(table._id!) const rows = await config.api.row.fetch(table._id!)
expect(rows).toHaveLength(1) expect(rows).toHaveLength(1)
}) })
!isLucene &&
describe("relations to same table", () => {
let relatedRows: Row[]
beforeAll(async () => {
const relatedTable = await config.api.table.save(
defaultTable({
schema: {
name: { name: "name", type: FieldType.STRING },
},
})
)
const relatedTableId = relatedTable._id!
table = await config.api.table.save(
defaultTable({
schema: {
name: { name: "name", type: FieldType.STRING },
related1: {
type: FieldType.LINK,
name: "related1",
fieldName: "main1",
tableId: relatedTableId,
relationshipType: RelationshipType.MANY_TO_MANY,
},
related2: {
type: FieldType.LINK,
name: "related2",
fieldName: "main2",
tableId: relatedTableId,
relationshipType: RelationshipType.MANY_TO_MANY,
},
},
})
)
relatedRows = await Promise.all([
config.api.row.save(relatedTableId, { name: "foo" }),
config.api.row.save(relatedTableId, { name: "bar" }),
config.api.row.save(relatedTableId, { name: "baz" }),
config.api.row.save(relatedTableId, { name: "boo" }),
])
})
it("can edit rows with both relationships", async () => {
let row = await config.api.row.save(table._id!, {
name: "test",
related1: [relatedRows[0]._id!],
related2: [relatedRows[1]._id!],
})
row = await config.api.row.save(table._id!, {
...row,
related1: [relatedRows[0]._id!, relatedRows[1]._id!],
related2: [relatedRows[2]._id!],
})
expect(row).toEqual(
expect.objectContaining({
name: "test",
related1: expect.arrayContaining([
{
_id: relatedRows[0]._id,
primaryDisplay: relatedRows[0].name,
},
{
_id: relatedRows[1]._id,
primaryDisplay: relatedRows[1].name,
},
]),
related2: [
{
_id: relatedRows[2]._id,
primaryDisplay: relatedRows[2].name,
},
],
})
)
})
it("can drop existing relationship", async () => {
let row = await config.api.row.save(table._id!, {
name: "test",
related1: [relatedRows[0]._id!],
related2: [relatedRows[1]._id!],
})
row = await config.api.row.save(table._id!, {
...row,
related1: [],
related2: [relatedRows[2]._id!],
})
expect(row).toEqual(
expect.objectContaining({
name: "test",
related2: [
{
_id: relatedRows[2]._id,
primaryDisplay: relatedRows[2].name,
},
],
})
)
expect(row.related1).toBeUndefined()
})
it("can drop both relationships", async () => {
let row = await config.api.row.save(table._id!, {
name: "test",
related1: [relatedRows[0]._id!],
related2: [relatedRows[1]._id!],
})
row = await config.api.row.save(table._id!, {
...row,
related1: [],
related2: [],
})
expect(row).toEqual(
expect.objectContaining({
name: "test",
})
)
expect(row.related1).toBeUndefined()
expect(row.related2).toBeUndefined()
})
})
}) })
describe("patch", () => { describe("patch", () => {
@ -1330,6 +1557,73 @@ describe.each([
) )
expect(res.length).toEqual(2) expect(res.length).toEqual(2)
}) })
!isLucene &&
describe("relations to same table", () => {
let relatedRows: Row[]
beforeAll(async () => {
const relatedTable = await config.api.table.save(
defaultTable({
schema: {
name: { name: "name", type: FieldType.STRING },
},
})
)
const relatedTableId = relatedTable._id!
table = await config.api.table.save(
defaultTable({
schema: {
name: { name: "name", type: FieldType.STRING },
related1: {
type: FieldType.LINK,
name: "related1",
fieldName: "main1",
tableId: relatedTableId,
relationshipType: RelationshipType.MANY_TO_MANY,
},
related2: {
type: FieldType.LINK,
name: "related2",
fieldName: "main2",
tableId: relatedTableId,
relationshipType: RelationshipType.MANY_TO_MANY,
},
},
})
)
relatedRows = await Promise.all([
config.api.row.save(relatedTableId, { name: "foo" }),
config.api.row.save(relatedTableId, { name: "bar" }),
config.api.row.save(relatedTableId, { name: "baz" }),
config.api.row.save(relatedTableId, { name: "boo" }),
])
})
it("can delete rows with both relationships", async () => {
const row = await config.api.row.save(table._id!, {
name: "test",
related1: [relatedRows[0]._id!],
related2: [relatedRows[1]._id!],
})
await config.api.row.delete(table._id!, { _id: row._id! })
await config.api.row.get(table._id!, row._id!, { status: 404 })
})
it("can delete rows with empty relationships", async () => {
const row = await config.api.row.save(table._id!, {
name: "test",
related1: [],
related2: [],
})
await config.api.row.delete(table._id!, { _id: row._id! })
await config.api.row.get(table._id!, row._id!, { status: 404 })
})
})
}) })
describe("validate", () => { describe("validate", () => {

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)