diff --git a/.github/workflows/deploy-qa.yml b/.github/workflows/deploy-qa.yml index 2a30e44def..92c957acbd 100644 --- a/.github/workflows/deploy-qa.yml +++ b/.github/workflows/deploy-qa.yml @@ -3,7 +3,7 @@ name: Deploy QA on: push: branches: - - v3-ui + - feature/automation-branching-ux workflow_dispatch: jobs: diff --git a/lerna.json b/lerna.json index df50828b30..530c51d20b 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "2.33.2", + "version": "2.33.4", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/packages/account-portal b/packages/account-portal index 8cd052ce82..607e3db866 160000 --- a/packages/account-portal +++ b/packages/account-portal @@ -1 +1 @@ -Subproject commit 8cd052ce8288f343812a514d06c5a9459b3ba1a8 +Subproject commit 607e3db866c366cb609b0015a1293edbeb703237 diff --git a/packages/backend-core/src/events/identification.ts b/packages/backend-core/src/events/identification.ts index c7bc1c817b..69bf7009b2 100644 --- a/packages/backend-core/src/events/identification.ts +++ b/packages/backend-core/src/events/identification.ts @@ -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 } } diff --git a/packages/backend-core/src/tenancy/db.ts b/packages/backend-core/src/tenancy/db.ts index 332ecbca48..10477a8579 100644 --- a/packages/backend-core/src/tenancy/db.ts +++ b/packages/backend-core/src/tenancy/db.ts @@ -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 { - try { - const db = getTenantDB(tenantId) - const tenantInfo = (await db.get("tenant_info")) as TenantInfo - delete tenantInfo.owner.password - return tenantInfo - } catch { - return undefined - } -} diff --git a/packages/backend-core/src/users/db.ts b/packages/backend-core/src/users/db.ts index c96c615f4b..cbc0019303 100644 --- a/packages/backend-core/src/users/db.ts +++ b/packages/backend-core/src/users/db.ts @@ -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 { + static async bulkDelete( + users: Array + ): Promise { 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({ include_docs: true, - keys: userIds, + keys: users.map(u => u.userId), }) const usersToDelete = allDocsResponse.rows.map(user => { return user.doc! diff --git a/packages/backend-core/src/users/users.ts b/packages/backend-core/src/users/users.ts index f4838597b6..0bff428fa9 100644 --- a/packages/backend-core/src/users/users.ts +++ b/packages/backend-core/src/users/users.ts @@ -70,6 +70,17 @@ export async function getAllUserIds() { return response.rows.map(row => row.id) } +export async function getAllUsers(): Promise { + 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 diff --git a/packages/backend-core/src/users/utils.ts b/packages/backend-core/src/users/utils.ts index e1e3da181d..91b667ce17 100644 --- a/packages/backend-core/src/users/utils.ts +++ b/packages/backend-core/src/users/utils.ts @@ -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 { +export async function getAccountHolderFromUsers( + users: Array +): Promise { 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) + ) } } diff --git a/packages/builder/src/pages/builder/app/[application]/settings/automations/index.svelte b/packages/builder/src/pages/builder/app/[application]/settings/automations/index.svelte index 6518b6cf58..dd332c92a9 100644 --- a/packages/builder/src/pages/builder/app/[application]/settings/automations/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/settings/automations/index.svelte @@ -38,6 +38,10 @@ let loaded = false $: app = $appsStore.apps.find(app => $appStore.appId?.includes(app.appId)) $: licensePlan = $auth.user?.license?.plan + + // Reset the page every time that a filter gets updated + $: pageInfo.reset(), automationId, status, timeRange + $: page = $pageInfo.page $: fetchLogs(automationId, status, page, timeRange) $: isCloud = $admin.cloud diff --git a/packages/builder/src/pages/builder/portal/users/users/[userId].svelte b/packages/builder/src/pages/builder/portal/users/users/[userId].svelte index 458c9a3f79..3547a4876e 100644 --- a/packages/builder/src/pages/builder/portal/users/users/[userId].svelte +++ b/packages/builder/src/pages/builder/portal/users/users/[userId].svelte @@ -206,7 +206,7 @@ if (!user?._id) { $goto("./") } - tenantOwner = await users.tenantOwner($auth.tenantId) + tenantOwner = await users.getAccountHolder() } async function toggleFlags(detail) { diff --git a/packages/builder/src/pages/builder/portal/users/users/index.svelte b/packages/builder/src/pages/builder/portal/users/users/index.svelte index a1d5496ff6..5a7e334c9c 100644 --- a/packages/builder/src/pages/builder/portal/users/users/index.svelte +++ b/packages/builder/src/pages/builder/portal/users/users/index.svelte @@ -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") diff --git a/packages/builder/src/stores/portal/users.js b/packages/builder/src/stores/portal/users.js index e9e65c3803..747dc5144d 100644 --- a/packages/builder/src/stores/portal/users.js +++ b/packages/builder/src/stores/portal/users.js @@ -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, } } diff --git a/packages/frontend-core/src/api/user.js b/packages/frontend-core/src/api/user.js index a2846c1e7b..45d481183a 100644 --- a/packages/frontend-core/src/api/user.js +++ b/packages/frontend-core/src/api/user.js @@ -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`, }) }, }) diff --git a/packages/pro b/packages/pro index f6aebba944..2ab8536b60 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit f6aebba94451ce47bba551926e5ad72bd75f71c6 +Subproject commit 2ab8536b6005576684810d774f1ac22239218546 diff --git a/packages/server/src/api/controllers/row/ExternalRequest.ts b/packages/server/src/api/controllers/row/ExternalRequest.ts index a8229463ea..62aa4d8d2a 100644 --- a/packages/server/src/api/controllers/row/ExternalRequest.ts +++ b/packages/server/src/api/controllers/row/ExternalRequest.ts @@ -11,11 +11,13 @@ import { IncludeRelationship, InternalSearchFilterOperator, isManyToOne, + isOneToMany, OneToManyRelationshipFieldMetadata, Operation, PaginationJson, QueryJson, RelationshipFieldMetadata, + RelationshipType, Row, SearchFilters, SortJson, @@ -50,13 +52,15 @@ import sdk from "../../../sdk" import env from "../../../environment" import { makeExternalQuery } from "../../../integrations/base/query" import { dataFilters, helpers } from "@budibase/shared-core" +import { isRelationshipColumn } from "../../../db/utils" -export interface ManyRelationship { +interface ManyRelationship { tableId?: string id?: string isUpdate?: boolean key: string [key: string]: any + relationshipType: RelationshipType } export interface RunConfig { @@ -384,6 +388,7 @@ export class ExternalRequest { [otherKey]: breakRowIdField(relationship)[0], // leave the ID for enrichment later [thisKey]: `{{ literal ${tablePrimary} }}`, + relationshipType: RelationshipType.MANY_TO_MANY, }) } } @@ -400,6 +405,7 @@ export class ExternalRequest { [thisKey]: breakRowIdField(relationship)[0], // leave the ID for enrichment later [otherKey]: `{{ literal ${tablePrimary} }}`, + relationshipType: RelationshipType.MANY_TO_ONE, }) } } @@ -420,14 +426,30 @@ export class ExternalRequest { 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 * information. */ - async lookupRelations(tableId: string, row: Row) { - const related: { - [key: string]: { rows: Row[]; isMany: boolean; tableId: string } - } = {} + private async lookupRelations(tableId: string, row: Row) { + const related: Record< + string, + { + rows: Row[] + isMany: boolean + tableId: string + } + > = {} + const { tableName } = breakExternalTableId(tableId) const table = this.tables[tableName] // @ts-ignore @@ -459,11 +481,8 @@ export class ExternalRequest { "Unable to lookup relationships - undefined column properties." ) } - const { tableName: relatedTableName } = - breakExternalTableId(relatedTableId) - // @ts-ignore - const linkPrimaryKey = this.tables[relatedTableName].primary[0] - if (!lookupField || !row?.[lookupField] == null) { + + if (!lookupField || !row?.[lookupField]) { continue } const endpoint = getEndpoint(relatedTableId, Operation.READ) @@ -487,10 +506,8 @@ export class ExternalRequest { !Array.isArray(response) || isKnexEmptyReadResponse(response) ? [] : response - const storeTo = isManyToMany(field) - ? field.throughFrom || linkPrimaryKey - : fieldName - related[storeTo] = { + + related[this.getLookupRelationsKey(field)] = { rows, isMany: isManyToMany(field), tableId: relatedTableId, @@ -518,7 +535,8 @@ export class ExternalRequest { const promises = [] const related = await this.lookupRelations(mainTableId, row) 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 linkTable = this.getTable(tableId) const relationshipPrimary = linkTable?.primary || [] @@ -529,7 +547,14 @@ export class ExternalRequest { const linkSecondary = relationshipPrimary[1] - const rows = related[key]?.rows || [] + const rows = + related[ + this.getLookupRelationsKey({ + relationshipType, + fieldName: key, + through: relationship.tableId, + }) + ]?.rows || [] const relationshipMatchPredicate = ({ row, @@ -574,12 +599,12 @@ export class ExternalRequest { } } // 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) // if it's not the foreign key skip it, nothing to do if ( !table || - (!isMany && table.primary && table.primary.indexOf(colName) !== -1) + (!isMany && table.primary && table.primary.indexOf(field) !== -1) ) { continue } @@ -587,7 +612,7 @@ export class ExternalRequest { const rowId = generateIdForRow(row, table) const promise: Promise = isMany ? this.removeManyToManyRelationships(rowId, table) - : this.removeOneToManyRelationships(rowId, table, colName) + : this.removeOneToManyRelationships(rowId, table, field) if (promise) { promises.push(promise) } @@ -599,23 +624,24 @@ export class ExternalRequest { async removeRelationshipsToRow(table: Table, rowId: string) { const row = await this.getRow(table, rowId) const related = await this.lookupRelations(table._id!, row) - for (let column of Object.values(table.schema)) { - const relationshipColumn = column as RelationshipFieldMetadata - if (!isManyToOne(relationshipColumn)) { + for (const column of Object.values(table.schema)) { + if (!isRelationshipColumn(column) || isOneToMany(column)) { 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)! await Promise.all( rows.map(row => { const rowId = generateIdForRow(row, table) return isMany ? this.removeManyToManyRelationships(rowId, table) - : this.removeOneToManyRelationships( - rowId, - table, - relationshipColumn.fieldName - ) + : this.removeOneToManyRelationships(rowId, table, column.fieldName) }) ) } diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 42c3560b36..526e4b7a79 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -965,6 +965,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", () => { @@ -1067,6 +1166,134 @@ describe.each([ const rows = await config.api.row.fetch(table._id!) 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", () => { @@ -1343,6 +1570,73 @@ describe.each([ ) 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", () => { @@ -2809,7 +3103,7 @@ describe.each([ }, ], ["from original saved row", (row: Row) => row], - ["from updated row", (row: Row) => config.api.row.save(viewId, row)], + ["from updated row", (row: Row) => config.api.row.save(viewId, row)], ] it.each(testScenarios)( diff --git a/packages/types/src/api/web/user.ts b/packages/types/src/api/web/user.ts index 471ca86616..e102f136c3 100644 --- a/packages/types/src/api/web/user.ts +++ b/packages/types/src/api/web/user.ts @@ -15,7 +15,10 @@ export interface UserDetails { export interface BulkUserRequest { delete?: { - userIds: string[] + users: Array<{ + userId: string + email: string + }> } create?: { roles?: any[] diff --git a/packages/types/src/documents/global/index.ts b/packages/types/src/documents/global/index.ts index 6784f2638c..b728439dd6 100644 --- a/packages/types/src/documents/global/index.ts +++ b/packages/types/src/documents/global/index.ts @@ -7,4 +7,3 @@ export * from "./schedule" export * from "./templates" export * from "./environmentVariables" export * from "./auditLogs" -export * from "./tenantInfo" diff --git a/packages/types/src/documents/global/tenantInfo.ts b/packages/types/src/documents/global/tenantInfo.ts deleted file mode 100644 index 4c8837cf2a..0000000000 --- a/packages/types/src/documents/global/tenantInfo.ts +++ /dev/null @@ -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 -} diff --git a/packages/types/src/documents/global/user.ts b/packages/types/src/documents/global/user.ts index 829a171843..1b242886b5 100644 --- a/packages/types/src/documents/global/user.ts +++ b/packages/types/src/documents/global/user.ts @@ -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 diff --git a/packages/worker/src/api/controllers/global/tenant.ts b/packages/worker/src/api/controllers/global/tenant.ts deleted file mode 100644 index 8b5ae6d528..0000000000 --- a/packages/worker/src/api/controllers/global/tenant.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { tenancy } from "@budibase/backend-core" -import { TenantInfo, Ctx } from "@budibase/types" - -export const save = async (ctx: Ctx) => { - 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) -} diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index 921e0324d1..52f8821fab 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -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) => { 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) => { } } -const bulkDelete = async (userIds: string[], currentUserId: string) => { - if (userIds?.indexOf(currentUserId) !== -1) { +const bulkDelete = async ( + users: Array, + 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. */ diff --git a/packages/worker/src/api/index.ts b/packages/worker/src/api/index.ts index db0a80acfd..a7594b713e 100644 --- a/packages/worker/src/api/index.ts +++ b/packages/worker/src/api/index.ts @@ -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", }, ] diff --git a/packages/worker/src/api/routes/global/tenant.ts b/packages/worker/src/api/routes/global/tenant.ts deleted file mode 100644 index f15e374774..0000000000 --- a/packages/worker/src/api/routes/global/tenant.ts +++ /dev/null @@ -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 diff --git a/packages/worker/src/api/routes/global/tests/tenant.spec.ts b/packages/worker/src/api/routes/global/tests/tenant.spec.ts deleted file mode 100644 index 390cfa2af5..0000000000 --- a/packages/worker/src/api/routes/global/tests/tenant.spec.ts +++ /dev/null @@ -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"}') - }) - }) -}) diff --git a/packages/worker/src/api/routes/global/tests/users.spec.ts b/packages/worker/src/api/routes/global/tests/users.spec.ts index c8e71f7eb4..b6237c7b4b 100644 --- a/packages/worker/src/api/routes/global/tests/users.spec.ts +++ b/packages/worker/src/api/routes/global/tests/users.spec.ts @@ -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", () => { diff --git a/packages/worker/src/api/routes/global/users.ts b/packages/worker/src/api/routes/global/users.ts index d5dfa47923..4078f348ad 100644 --- a/packages/worker/src/api/routes/global/users.ts +++ b/packages/worker/src/api/routes/global/users.ts @@ -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) diff --git a/packages/worker/src/api/routes/index.ts b/packages/worker/src/api/routes/index.ts index 2eb4b5cd5d..e6cacf110f 100644 --- a/packages/worker/src/api/routes/index.ts +++ b/packages/worker/src/api/routes/index.ts @@ -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, ] diff --git a/packages/worker/src/api/routes/validation/users.ts b/packages/worker/src/api/routes/validation/users.ts index 333435ed29..46c66285fd 100644 --- a/packages/worker/src/api/routes/validation/users.ts +++ b/packages/worker/src/api/routes/validation/users.ts @@ -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) + ), }), } diff --git a/packages/worker/src/tests/api/tenants.ts b/packages/worker/src/tests/api/tenants.ts index c404b8ad58..16f970915a 100644 --- a/packages/worker/src/tests/api/tenants.ts +++ b/packages/worker/src/tests/api/tenants.ts @@ -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) - } } diff --git a/packages/worker/src/tests/api/users.ts b/packages/worker/src/tests/api/users.ts index 8ff31f04fc..b54fb45d2c 100644 --- a/packages/worker/src/tests/api/users.ts +++ b/packages/worker/src/tests/api/users.ts @@ -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)