Merge branch 'v3-ui' into feature/automation-branching-ux
This commit is contained in:
commit
392dc087c1
|
@ -3,7 +3,7 @@ name: Deploy QA
|
|||
on:
|
||||
push:
|
||||
branches:
|
||||
- v3-ui
|
||||
- feature/automation-branching-ux
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||
"version": "2.33.3",
|
||||
"version": "2.33.4",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*",
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 8cd052ce8288f343812a514d06c5a9459b3ba1a8
|
||||
Subproject commit 607e3db866c366cb609b0015a1293edbeb703237
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -206,7 +206,7 @@
|
|||
if (!user?._id) {
|
||||
$goto("./")
|
||||
}
|
||||
tenantOwner = await users.tenantOwner($auth.tenantId)
|
||||
tenantOwner = await users.getAccountHolder()
|
||||
}
|
||||
|
||||
async function toggleFlags(detail) {
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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<T extends Operation> {
|
|||
[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<T extends Operation> {
|
|||
[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<T extends Operation> {
|
|||
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<T extends Operation> {
|
|||
"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<T extends Operation> {
|
|||
!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<T extends Operation> {
|
|||
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<T extends Operation> {
|
|||
|
||||
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<T extends Operation> {
|
|||
}
|
||||
}
|
||||
// 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<T extends Operation> {
|
|||
const rowId = generateIdForRow(row, table)
|
||||
const promise: Promise<any> = 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<T extends Operation> {
|
|||
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)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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", () => {
|
||||
|
@ -1054,6 +1153,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", () => {
|
||||
|
@ -1330,6 +1557,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", () => {
|
||||
|
|
|
@ -15,7 +15,10 @@ export interface UserDetails {
|
|||
|
||||
export interface BulkUserRequest {
|
||||
delete?: {
|
||||
userIds: string[]
|
||||
users: Array<{
|
||||
userId: string
|
||||
email: string
|
||||
}>
|
||||
}
|
||||
create?: {
|
||||
roles?: any[]
|
||||
|
|
|
@ -7,4 +7,3 @@ export * from "./schedule"
|
|||
export * from "./templates"
|
||||
export * from "./environmentVariables"
|
||||
export * from "./auditLogs"
|
||||
export * from "./tenantInfo"
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
|
|
|
@ -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
|
|
@ -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"}')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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", () => {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
|
||||
|
|
|
@ -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)
|
||||
),
|
||||
}),
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue