Merge remote-tracking branch 'origin/develop' into feat/relationship-configuration

This commit is contained in:
Peter Clement 2023-09-28 15:28:21 +01:00
commit 7e6faaf587
57 changed files with 915 additions and 230 deletions

View File

@ -1,5 +1,5 @@
{ {
"version": "2.10.12-alpha.26", "version": "2.10.16-alpha.7",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -18,7 +18,7 @@ export enum ViewName {
ROUTING = "screen_routes", ROUTING = "screen_routes",
AUTOMATION_LOGS = "automation_logs", AUTOMATION_LOGS = "automation_logs",
ACCOUNT_BY_EMAIL = "account_by_email", ACCOUNT_BY_EMAIL = "account_by_email",
PLATFORM_USERS_LOWERCASE = "platform_users_lowercase", PLATFORM_USERS_LOWERCASE = "platform_users_lowercase_2",
USER_BY_GROUP = "user_by_group", USER_BY_GROUP = "user_by_group",
APP_BACKUP_BY_TRIGGER = "by_trigger", APP_BACKUP_BY_TRIGGER = "by_trigger",
} }

View File

@ -190,6 +190,10 @@ export const createPlatformUserView = async () => {
if (doc.tenantId) { if (doc.tenantId) {
emit(doc._id.toLowerCase(), doc._id) emit(doc._id.toLowerCase(), doc._id)
} }
if (doc.ssoId) {
emit(doc.ssoId, doc._id)
}
}` }`
await createPlatformView(viewJs, ViewName.PLATFORM_USERS_LOWERCASE) await createPlatformView(viewJs, ViewName.PLATFORM_USERS_LOWERCASE)
} }

View File

@ -5,6 +5,7 @@ import {
PlatformUser, PlatformUser,
PlatformUserByEmail, PlatformUserByEmail,
PlatformUserById, PlatformUserById,
PlatformUserBySsoId,
User, User,
} from "@budibase/types" } from "@budibase/types"
@ -45,6 +46,20 @@ function newUserEmailDoc(
} }
} }
function newUserSsoIdDoc(
ssoId: string,
email: string,
userId: string,
tenantId: string
): PlatformUserBySsoId {
return {
_id: ssoId,
userId,
email,
tenantId,
}
}
/** /**
* Add a new user id or email doc if it doesn't exist. * Add a new user id or email doc if it doesn't exist.
*/ */
@ -64,11 +79,24 @@ async function addUserDoc(emailOrId: string, newDocFn: () => PlatformUser) {
} }
} }
export async function addUser(tenantId: string, userId: string, email: string) { export async function addUser(
await Promise.all([ tenantId: string,
userId: string,
email: string,
ssoId?: string
) {
const promises = [
addUserDoc(userId, () => newUserIdDoc(userId, tenantId)), addUserDoc(userId, () => newUserIdDoc(userId, tenantId)),
addUserDoc(email, () => newUserEmailDoc(userId, email, tenantId)), addUserDoc(email, () => newUserEmailDoc(userId, email, tenantId)),
]) ]
if (ssoId) {
promises.push(
addUserDoc(ssoId, () => newUserSsoIdDoc(ssoId, email, userId, tenantId))
)
}
await Promise.all(promises)
} }
// DELETE // DELETE

View File

@ -1,8 +1,9 @@
import { PermissionType, PermissionLevel } from "@budibase/types" import { PermissionLevel, PermissionType } from "@budibase/types"
export { PermissionType, PermissionLevel } from "@budibase/types"
import flatten from "lodash/flatten" import flatten from "lodash/flatten"
import cloneDeep from "lodash/fp/cloneDeep" import cloneDeep from "lodash/fp/cloneDeep"
export { PermissionType, PermissionLevel } from "@budibase/types"
export type RoleHierarchy = { export type RoleHierarchy = {
permissionId: string permissionId: string
}[] }[]
@ -78,6 +79,7 @@ export const BUILTIN_PERMISSIONS = {
permissions: [ permissions: [
new Permission(PermissionType.QUERY, PermissionLevel.READ), new Permission(PermissionType.QUERY, PermissionLevel.READ),
new Permission(PermissionType.TABLE, PermissionLevel.READ), new Permission(PermissionType.TABLE, PermissionLevel.READ),
new Permission(PermissionType.APP, PermissionLevel.READ),
], ],
}, },
WRITE: { WRITE: {
@ -88,6 +90,7 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.TABLE, PermissionLevel.WRITE), new Permission(PermissionType.TABLE, PermissionLevel.WRITE),
new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE), new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE),
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ), new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
new Permission(PermissionType.APP, PermissionLevel.READ),
], ],
}, },
POWER: { POWER: {
@ -99,6 +102,7 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE), new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE),
new Permission(PermissionType.WEBHOOK, PermissionLevel.READ), new Permission(PermissionType.WEBHOOK, PermissionLevel.READ),
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ), new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
new Permission(PermissionType.APP, PermissionLevel.READ),
], ],
}, },
ADMIN: { ADMIN: {
@ -111,6 +115,7 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.WEBHOOK, PermissionLevel.READ), new Permission(PermissionType.WEBHOOK, PermissionLevel.READ),
new Permission(PermissionType.QUERY, PermissionLevel.ADMIN), new Permission(PermissionType.QUERY, PermissionLevel.ADMIN),
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ), new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
new Permission(PermissionType.APP, PermissionLevel.READ),
], ],
}, },
} }

View File

@ -215,21 +215,23 @@ async function getAllUserRoles(userRoleId?: string): Promise<RoleDoc[]> {
return roles return roles
} }
export async function getUserRoleIdHierarchy(
userRoleId?: string
): Promise<string[]> {
const roles = await getUserRoleHierarchy(userRoleId)
return roles.map(role => role._id!)
}
/** /**
* Returns an ordered array of the user's inherited role IDs, this can be used * Returns an ordered array of the user's inherited role IDs, this can be used
* to determine if a user can access something that requires a specific role. * to determine if a user can access something that requires a specific role.
* @param {string} userRoleId The user's role ID, this can be found in their access token. * @param {string} userRoleId The user's role ID, this can be found in their access token.
* @param {object} opts Various options, such as whether to only retrieve the IDs (default true). * @returns {Promise<object[]>} returns an ordered array of the roles, with the first being their
* @returns {Promise<string[]|object[]>} returns an ordered array of the roles, with the first being their
* highest level of access and the last being the lowest level. * highest level of access and the last being the lowest level.
*/ */
export async function getUserRoleHierarchy( export async function getUserRoleHierarchy(userRoleId?: string) {
userRoleId?: string,
opts = { idOnly: true }
) {
// special case, if they don't have a role then they are a public user // special case, if they don't have a role then they are a public user
const roles = await getAllUserRoles(userRoleId) return getAllUserRoles(userRoleId)
return opts.idOnly ? roles.map(role => role._id) : roles
} }
// this function checks that the provided permissions are in an array format // this function checks that the provided permissions are in an array format
@ -249,6 +251,11 @@ export function checkForRoleResourceArray(
return rolePerms return rolePerms
} }
export async function getAllRoleIds(appId?: string) {
const roles = await getAllRoles(appId)
return roles.map(role => role._id)
}
/** /**
* Given an app ID this will retrieve all of the roles that are currently within that app. * Given an app ID this will retrieve all of the roles that are currently within that app.
* @return {Promise<object[]>} An array of the role objects that were found. * @return {Promise<object[]>} An array of the role objects that were found.
@ -332,9 +339,7 @@ export class AccessController {
} }
let roleIds = userRoleId ? this.userHierarchies[userRoleId] : null let roleIds = userRoleId ? this.userHierarchies[userRoleId] : null
if (!roleIds && userRoleId) { if (!roleIds && userRoleId) {
roleIds = (await getUserRoleHierarchy(userRoleId, { roleIds = await getUserRoleIdHierarchy(userRoleId)
idOnly: true,
})) as string[]
this.userHierarchies[userRoleId] = roleIds this.userHierarchies[userRoleId] = roleIds
} }

View File

@ -278,7 +278,12 @@ export class UserDB {
builtUser._rev = response.rev builtUser._rev = response.rev
await eventHelpers.handleSaveEvents(builtUser, dbUser) await eventHelpers.handleSaveEvents(builtUser, dbUser)
await platform.users.addUser(tenantId, builtUser._id!, builtUser.email) await platform.users.addUser(
tenantId,
builtUser._id!,
builtUser.email,
builtUser.ssoId
)
await cache.user.invalidateUser(response.id) await cache.user.invalidateUser(response.id)
await Promise.all(groupPromises) await Promise.all(groupPromises)

View File

@ -1,4 +1,4 @@
import { generator, uuid, quotas } from "." import { generator, quotas, uuid } from "."
import { generateGlobalUserID } from "../../../../src/docIds" import { generateGlobalUserID } from "../../../../src/docIds"
import { import {
Account, Account,
@ -6,10 +6,11 @@ import {
AccountSSOProviderType, AccountSSOProviderType,
AuthType, AuthType,
CloudAccount, CloudAccount,
Hosting,
SSOAccount,
CreateAccount, CreateAccount,
CreatePassswordAccount, CreatePassswordAccount,
CreateVerifiableSSOAccount,
Hosting,
SSOAccount,
} from "@budibase/types" } from "@budibase/types"
import sample from "lodash/sample" import sample from "lodash/sample"
@ -68,6 +69,23 @@ export function ssoAccount(account: Account = cloudAccount()): SSOAccount {
} }
} }
export function verifiableSsoAccount(
account: Account = cloudAccount()
): SSOAccount {
return {
...account,
authType: AuthType.SSO,
oauth2: {
accessToken: generator.string(),
refreshToken: generator.string(),
},
pictureUrl: generator.url(),
provider: AccountSSOProvider.MICROSOFT,
providerType: AccountSSOProviderType.MICROSOFT,
thirdPartyProfile: { id: "abc123" },
}
}
export const cloudCreateAccount: CreatePassswordAccount = { export const cloudCreateAccount: CreatePassswordAccount = {
email: "cloud@budibase.com", email: "cloud@budibase.com",
tenantId: "cloud", tenantId: "cloud",
@ -91,6 +109,19 @@ export const cloudSSOCreateAccount: CreateAccount = {
profession: "Software Engineer", profession: "Software Engineer",
} }
export const cloudVerifiableSSOCreateAccount: CreateVerifiableSSOAccount = {
email: "cloud-sso@budibase.com",
tenantId: "cloud-sso",
hosting: Hosting.CLOUD,
authType: AuthType.SSO,
tenantName: "cloudsso",
name: "Budi Armstrong",
size: "10+",
profession: "Software Engineer",
provider: AccountSSOProvider.MICROSOFT,
thirdPartyProfile: { id: "abc123" },
}
export const selfCreateAccount: CreatePassswordAccount = { export const selfCreateAccount: CreatePassswordAccount = {
email: "self@budibase.com", email: "self@budibase.com",
tenantId: "self", tenantId: "self",

View File

@ -1,3 +1,5 @@
test
## Get Started ## Get Started
`yarn install` `yarn install`

View File

@ -33,6 +33,8 @@ const generateTableBlock = datasource => {
showTitleButton: true, showTitleButton: true,
titleButtonText: "Create row", titleButtonText: "Create row",
titleButtonClickBehaviour: "new", titleButtonClickBehaviour: "new",
sidePanelSaveLabel: "Save",
sidePanelDeleteLabel: "Delete",
}) })
.instanceName(`${datasource.label} - Table block`) .instanceName(`${datasource.label} - Table block`)
return tableBlock return tableBlock

View File

@ -33,7 +33,7 @@
import { getBindings } from "components/backend/DataTable/formula" import { getBindings } from "components/backend/DataTable/formula"
import JSONSchemaModal from "./JSONSchemaModal.svelte" import JSONSchemaModal from "./JSONSchemaModal.svelte"
import { ValidColumnNameRegex } from "@budibase/shared-core" import { ValidColumnNameRegex } from "@budibase/shared-core"
import { FieldSubtype, FieldType } from "@budibase/types" import { FieldType } from "@budibase/types"
import RelationshipSelector from "components/common/RelationshipSelector.svelte" import RelationshipSelector from "components/common/RelationshipSelector.svelte"
const AUTO_TYPE = "auto" const AUTO_TYPE = "auto"
@ -43,11 +43,7 @@
const NUMBER_TYPE = FIELDS.NUMBER.type const NUMBER_TYPE = FIELDS.NUMBER.type
const JSON_TYPE = FIELDS.JSON.type const JSON_TYPE = FIELDS.JSON.type
const DATE_TYPE = FIELDS.DATETIME.type const DATE_TYPE = FIELDS.DATETIME.type
const BB_REFERENCE_TYPE = FieldType.BB_REFERENCE const USER_REFRENCE_TYPE = FIELDS.BB_REFERENCE_USER.compositeType
const BB_USER_REFERENCE_TYPE = composeType(
BB_REFERENCE_TYPE,
FieldSubtype.USER
)
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"] const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"]
@ -84,33 +80,6 @@
let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions) let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions)
let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions) let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions)
const bbRefTypeMapping = {}
function composeType(fieldType, subtype) {
return `${fieldType}_${subtype}`
}
// Handling fields with subtypes
fieldDefinitions = Object.entries(fieldDefinitions).reduce(
(p, [key, field]) => {
if (field.type === BB_REFERENCE_TYPE) {
const composedType = composeType(field.type, field.subtype)
p[key] = {
...field,
type: composedType,
}
bbRefTypeMapping[composedType] = {
type: field.type,
subtype: field.subtype,
}
} else {
p[key] = field
}
return p
},
{}
)
$: if (primaryDisplay) { $: if (primaryDisplay) {
editableColumn.constraints.presence = { allowEmpty: false } editableColumn.constraints.presence = { allowEmpty: false }
} }
@ -170,12 +139,8 @@
$tables.selected.primaryDisplay == null || $tables.selected.primaryDisplay == null ||
$tables.selected.primaryDisplay === editableColumn.name $tables.selected.primaryDisplay === editableColumn.name
const mapped = Object.entries(bbRefTypeMapping).find( if (editableColumn.type === FieldType.BB_REFERENCE) {
([_, v]) => v.type === field.type && v.subtype === field.subtype editableColumn.type = `${editableColumn.type}_${editableColumn.subtype}`
)
if (mapped) {
editableColumn.type = mapped[0]
delete editableColumn.subtype
} }
// Here we are setting the relationship values based on the editableColumn // Here we are setting the relationship values based on the editableColumn
// This part of the code is used when viewing an existing field hence the check // This part of the code is used when viewing an existing field hence the check
@ -212,8 +177,6 @@
$: initialiseField(field, savingColumn) $: initialiseField(field, savingColumn)
$: isBBReference = !!bbRefTypeMapping[editableColumn.type]
$: checkConstraints(editableColumn) $: checkConstraints(editableColumn)
$: required = !!editableColumn?.constraints?.presence || primaryDisplay $: required = !!editableColumn?.constraints?.presence || primaryDisplay
$: uneditable = $: uneditable =
@ -286,11 +249,12 @@
let saveColumn = cloneDeep(editableColumn) let saveColumn = cloneDeep(editableColumn)
if (bbRefTypeMapping[saveColumn.type]) { // Handle types on composite types
saveColumn = { const definition = fieldDefinitions[saveColumn.type.toUpperCase()]
...saveColumn, if (definition && saveColumn.type === definition.compositeType) {
...bbRefTypeMapping[saveColumn.type], saveColumn.type = definition.type
} saveColumn.subtype = definition.subtype
delete saveColumn.compositeType
} }
if (saveColumn.type === AUTO_TYPE) { if (saveColumn.type === AUTO_TYPE) {
@ -373,7 +337,7 @@
editableColumn.relationshipType = RelationshipType.MANY_TO_MANY editableColumn.relationshipType = RelationshipType.MANY_TO_MANY
} else if (editableColumn.type === FORMULA_TYPE) { } else if (editableColumn.type === FORMULA_TYPE) {
editableColumn.formulaType = "dynamic" editableColumn.formulaType = "dynamic"
} else if (editableColumn.type === BB_USER_REFERENCE_TYPE) { } else if (editableColumn.type === USER_REFRENCE_TYPE) {
editableColumn.relationshipType = RelationshipType.ONE_TO_MANY editableColumn.relationshipType = RelationshipType.ONE_TO_MANY
} }
} }
@ -431,14 +395,12 @@
FIELDS.BOOLEAN, FIELDS.BOOLEAN,
FIELDS.FORMULA, FIELDS.FORMULA,
FIELDS.BIGINT, FIELDS.BIGINT,
FIELDS.BB_REFERENCE_USER,
] ]
// no-sql or a spreadsheet // no-sql or a spreadsheet
if (!external || table.sql) { if (!external || table.sql) {
fields = [...fields, FIELDS.LINK, FIELDS.ARRAY] fields = [...fields, FIELDS.LINK, FIELDS.ARRAY]
} }
if (fieldDefinitions.USER) {
fields.push(fieldDefinitions.USER)
}
return fields return fields
} }
} }
@ -447,8 +409,9 @@
if (!fieldToCheck) { if (!fieldToCheck) {
return return
} }
// most types need this, just make sure its always present // most types need this, just make sure its always present
if (fieldToCheck && !fieldToCheck.constraints) { if (!fieldToCheck.constraints) {
fieldToCheck.constraints = {} fieldToCheck.constraints = {}
} }
// some string types may have been built by server, may not always have constraints // some string types may have been built by server, may not always have constraints
@ -528,7 +491,7 @@
on:change={handleTypeChange} on:change={handleTypeChange}
options={allowedTypes} options={allowedTypes}
getOptionLabel={field => field.name} getOptionLabel={field => field.name}
getOptionValue={field => field.type} getOptionValue={field => field.compositeType || field.type}
getOptionIcon={field => field.icon} getOptionIcon={field => field.icon}
isOptionEnabled={option => { isOptionEnabled={option => {
if (option.type == AUTO_TYPE) { if (option.type == AUTO_TYPE) {
@ -694,7 +657,7 @@
<Button primary text on:click={openJsonSchemaEditor} <Button primary text on:click={openJsonSchemaEditor}
>Open schema editor</Button >Open schema editor</Button
> >
{:else if isBBReference} {:else if editableColumn.type === USER_REFRENCE_TYPE}
<Toggle <Toggle
value={editableColumn.relationshipType === RelationshipType.MANY_TO_MANY} value={editableColumn.relationshipType === RelationshipType.MANY_TO_MANY}
on:change={e => on:change={e =>

View File

@ -337,11 +337,12 @@
padding: 8px 10px 8px 16px; padding: 8px 10px 8px 16px;
display: flex; display: flex;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
transition: border-bottom 130ms ease-out; transition: border-bottom 130ms ease-out, background 130ms ease-out;
} }
.header.scrolling { .header.scrolling {
border-bottom: var(--border-light); border-bottom: var(--border-light);
background: var(--background);
} }
.body { .body {

View File

@ -120,10 +120,11 @@ export const FIELDS = {
presence: false, presence: false,
}, },
}, },
USER: { BB_REFERENCE_USER: {
name: "User", name: "User",
type: "bb_reference", type: "bb_reference",
subtype: "user", subtype: "user",
compositeType: "bb_reference_user", // Used for working with the subtype on CreateEditColumn as is it was a primary type
icon: "User", icon: "User",
}, },
} }

View File

@ -46,7 +46,9 @@
{#if loaded} {#if loaded}
<div class="page"> <div class="page">
{#if $apps.length > 0}
<PortalSideBar /> <PortalSideBar />
{/if}
<slot /> <slot />
</div> </div>
{/if} {/if}

View File

@ -4,15 +4,14 @@
import { Heading, Icon, clickOutside } from "@budibase/bbui" import { Heading, Icon, clickOutside } from "@budibase/bbui"
import { FieldTypes } from "constants" import { FieldTypes } from "constants"
import active from "svelte-spa-router/active" import active from "svelte-spa-router/active"
import { RoleUtils } from "@budibase/frontend-core"
const sdk = getContext("sdk") const sdk = getContext("sdk")
const { const {
routeStore, routeStore,
roleStore,
styleable, styleable,
linkable, linkable,
builderStore, builderStore,
currentRole,
sidePanelStore, sidePanelStore,
} = sdk } = sdk
const component = getContext("component") const component = getContext("component")
@ -61,7 +60,7 @@
}) })
setContext("layout", store) setContext("layout", store)
$: validLinks = getValidLinks(links, $currentRole) $: validLinks = getValidLinks(links, $roleStore)
$: typeClass = NavigationClasses[navigation] || NavigationClasses.None $: typeClass = NavigationClasses[navigation] || NavigationClasses.None
$: navWidthClass = WidthClasses[navWidth || width] || WidthClasses.Large $: navWidthClass = WidthClasses[navWidth || width] || WidthClasses.Large
$: pageWidthClass = WidthClasses[pageWidth || width] || WidthClasses.Large $: pageWidthClass = WidthClasses[pageWidth || width] || WidthClasses.Large
@ -99,14 +98,12 @@
} }
} }
const getValidLinks = (allLinks, role) => { const getValidLinks = (allLinks, userRoleHierarchy) => {
// Strip links missing required info // Strip links missing required info
let validLinks = (allLinks || []).filter(link => link.text && link.url) let validLinks = (allLinks || []).filter(link => link.text && link.url)
// Filter to only links allowed by the current role // Filter to only links allowed by the current role
const priority = RoleUtils.getRolePriority(role)
return validLinks.filter(link => { return validLinks.filter(link => {
return !link.roleId || RoleUtils.getRolePriority(link.roleId) <= priority return userRoleHierarchy?.find(roleId => roleId === link.roleId)
}) })
} }

View File

@ -47,28 +47,29 @@
<style> <style>
div { div {
display: grid; display: grid;
grid-gap: 16px; --gap: 16px;
grid-gap: var(--gap);
} }
.mainSidebar { .mainSidebar {
grid-template-columns: 3fr 1fr; grid-template-columns:
calc((100% - var(--gap)) / 4 * 3) /* 75% */
calc((100% - var(--gap)) / 4); /* 25% */
} }
.sidebarMain { .sidebarMain {
grid-template-columns: 1fr 3fr; grid-template-columns:
} calc((100% - var(--gap)) / 4) /* 25% */
.oneColumn { calc((100% - var(--gap)) / 4 * 3); /* 75% */
grid-template-columns: 1fr;
}
.twoColumns {
grid-template-columns: 1fr 1fr;
}
.threeColumns {
grid-template-columns: 1fr 1fr 1fr;
} }
.oneColumn,
.columns-1 { .columns-1 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.twoColumns,
.columns-2 { .columns-2 {
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(2, calc((100% - var(--gap)) / 2));
}
.threeColumns {
grid-template-columns: repeat(3, calc((100% - var(--gap)) / 3));
} }
.placeholder { .placeholder {
border: 2px dashed var(--spectrum-global-color-gray-600); border: 2px dashed var(--spectrum-global-color-gray-600);

View File

@ -45,8 +45,21 @@
let enrichedSearchColumns let enrichedSearchColumns
let schemaLoaded = false let schemaLoaded = false
$: deleteLabel = setDeleteLabel(sidePanelDeleteLabel, sidePanelShowDelete)
const setDeleteLabel = sidePanelDeleteLabel => {
// Accommodate old config to ensure delete button does not reappear // Accommodate old config to ensure delete button does not reappear
$: deleteLabel = sidePanelShowDelete === false ? "" : sidePanelDeleteLabel let labelText = sidePanelShowDelete === false ? "" : sidePanelDeleteLabel
// Empty text is considered hidden.
if (labelText?.trim() === "") {
return ""
}
// Default to "Delete" if the value is unset
return labelText || "Delete"
}
$: isDSPlus = dataSource?.type === "table" || dataSource?.type === "viewV2" $: isDSPlus = dataSource?.type === "table" || dataSource?.type === "viewV2"
$: fetchSchema(dataSource) $: fetchSchema(dataSource)
$: enrichSearchColumns(searchColumns, schema).then( $: enrichSearchColumns(searchColumns, schema).then(
@ -249,7 +262,7 @@
props={{ props={{
dataSource, dataSource,
saveButtonLabel: sidePanelSaveLabel || "Save", //always show saveButtonLabel: sidePanelSaveLabel || "Save", //always show
deleteButtonLabel: deleteLabel, //respect config deleteButtonLabel: deleteLabel,
actionType: "Update", actionType: "Update",
rowId: `{{ ${safe("state")}.${safe(stateKey)} }}`, rowId: `{{ ${safe("state")}.${safe(stateKey)} }}`,
fields: sidePanelFields || normalFields, fields: sidePanelFields || normalFields,

View File

@ -1,32 +1,39 @@
<script> <script>
import { Heading, Select, ActionButton } from "@budibase/bbui" import { Heading, Select, ActionButton } from "@budibase/bbui"
import { devToolsStore, appStore } from "../../stores" import { devToolsStore, appStore, roleStore } from "../../stores"
import { getContext } from "svelte" import { getContext, onMount } from "svelte"
const context = getContext("context") const context = getContext("context")
const SELF_ROLE = "self"
$: previewOptions = [ let staticRoleList
{
$: previewOptions = buildRoleList(staticRoleList)
function buildRoleList(roleIds) {
const list = []
list.push({
label: "View as yourself", label: "View as yourself",
value: "self", value: SELF_ROLE,
}, })
{ if (!roleIds) {
label: "View as public user", return list
value: "PUBLIC", }
}, for (let roleId of roleIds) {
{ list.push({
label: "View as basic user", label: `View as ${roleId.toLowerCase()} user`,
value: "BASIC", value: roleId,
}, })
{ }
label: "View as power user", devToolsStore.actions.changeRole(SELF_ROLE)
value: "POWER", return list
}, }
{
label: "View as admin user", onMount(async () => {
value: "ADMIN", // make sure correct before starting
}, await devToolsStore.actions.changeRole(SELF_ROLE)
] staticRoleList = await roleStore.actions.fetchAccessibleRoles()
})
</script> </script>
<div class="dev-preview-header" class:mobile={$context.device.mobile}> <div class="dev-preview-header" class:mobile={$context.device.mobile}>
@ -34,7 +41,7 @@
<Select <Select
quiet quiet
options={previewOptions} options={previewOptions}
value={$devToolsStore.role || "self"} value={$devToolsStore.role || SELF_ROLE}
placeholder={null} placeholder={null}
autoWidth autoWidth
on:change={e => devToolsStore.actions.changeRole(e.detail)} on:change={e => devToolsStore.actions.changeRole(e.detail)}

View File

@ -13,6 +13,7 @@ import {
sidePanelStore, sidePanelStore,
dndIsDragging, dndIsDragging,
confirmationStore, confirmationStore,
roleStore,
} from "stores" } from "stores"
import { styleable } from "utils/styleable" import { styleable } from "utils/styleable"
import { linkable } from "utils/linkable" import { linkable } from "utils/linkable"
@ -39,6 +40,7 @@ export default {
dndIsDragging, dndIsDragging,
currentRole, currentRole,
confirmationStore, confirmationStore,
roleStore,
styleable, styleable,
linkable, linkable,
getAction, getAction,

View File

@ -11,12 +11,13 @@ export { stateStore } from "./state"
export { themeStore } from "./theme" export { themeStore } from "./theme"
export { devToolsStore } from "./devTools" export { devToolsStore } from "./devTools"
export { componentStore } from "./components" export { componentStore } from "./components"
export { uploadStore } from "./uploads.js" export { uploadStore } from "./uploads"
export { rowSelectionStore } from "./rowSelection.js" export { rowSelectionStore } from "./rowSelection"
export { blockStore } from "./blocks.js" export { blockStore } from "./blocks"
export { environmentStore } from "./environment" export { environmentStore } from "./environment"
export { eventStore } from "./events.js" export { eventStore } from "./events"
export { orgStore } from "./org.js" export { orgStore } from "./org"
export { roleStore } from "./roles"
export { export {
dndStore, dndStore,
dndIndex, dndIndex,
@ -25,7 +26,7 @@ export {
dndIsNewComponent, dndIsNewComponent,
dndIsDragging, dndIsDragging,
} from "./dnd" } from "./dnd"
export { sidePanelStore } from "./sidePanel.js" export { sidePanelStore } from "./sidePanel"
// Context stores are layered and duplicated, so it is not a singleton // Context stores are layered and duplicated, so it is not a singleton
export { createContextStore } from "./context" export { createContextStore } from "./context"

View File

@ -0,0 +1,24 @@
import { API } from "api"
import { writable } from "svelte/store"
import { currentRole } from "./derived"
const createRoleStore = () => {
const store = writable([])
// Fetches the user object if someone is logged in and has reloaded the page
const fetchAccessibleRoles = async () => {
const accessible = await API.getAccessibleRoles()
// Use the app self if present, otherwise fallback to the global self
store.set(accessible || [])
return accessible
}
return {
subscribe: store.subscribe,
actions: { fetchAccessibleRoles },
}
}
export const roleStore = createRoleStore()
currentRole.subscribe(roleStore.actions.fetchAccessibleRoles)

View File

@ -38,4 +38,13 @@ export const buildRoleEndpoints = API => ({
url: `/api/global/roles/${appId}`, url: `/api/global/roles/${appId}`,
}) })
}, },
/**
* For the logged in user and current app - retrieves accessible roles.
*/
getAccessibleRoles: async () => {
return await API.get({
url: `/api/roles/accessible`,
})
},
}) })

View File

@ -1567,8 +1567,7 @@
} }
}, },
"required": [ "required": [
"email", "email"
"roles"
] ]
}, },
"userOutput": { "userOutput": {
@ -1639,7 +1638,6 @@
}, },
"required": [ "required": [
"email", "email",
"roles",
"_id" "_id"
] ]
} }
@ -1718,7 +1716,6 @@
}, },
"required": [ "required": [
"email", "email",
"roles",
"_id" "_id"
] ]
} }

View File

@ -1337,7 +1337,6 @@ components:
role ID, e.g. ADMIN. role ID, e.g. ADMIN.
required: required:
- email - email
- roles
userOutput: userOutput:
type: object type: object
properties: properties:
@ -1398,7 +1397,6 @@ components:
type: string type: string
required: required:
- email - email
- roles
- _id - _id
required: required:
- data - data
@ -1464,7 +1462,6 @@ components:
type: string type: string
required: required:
- email - email
- roles
- _id - _id
required: required:
- data - data

View File

@ -92,7 +92,7 @@ const userSchema = object(
}, },
}, },
}, },
{ required: ["email", "roles"] } { required: ["email"] }
) )
const userOutputSchema = { const userOutputSchema = {

View File

@ -15,10 +15,15 @@ function user(body: any): User {
} }
} }
function mapUser(ctx: any): { data: User } { function mapUser(ctx: any) {
return { const body: { data: User; message?: string } = {
data: user(ctx.body), data: user(ctx.body),
} }
if (ctx.extra?.message) {
body.message = ctx.extra.message
delete ctx.extra
}
return body
} }
function mapUsers(ctx: any): { data: User[] } { function mapUsers(ctx: any): { data: User[] } {

View File

@ -10,6 +10,32 @@ import { search as stringSearch } from "./utils"
import { UserCtx, User } from "@budibase/types" import { UserCtx, User } from "@budibase/types"
import { Next } from "koa" import { Next } from "koa"
import { sdk } from "@budibase/pro" import { sdk } from "@budibase/pro"
import { isEqual, cloneDeep } from "lodash"
function rolesRemoved(base: User, ctx: UserCtx) {
return (
!isEqual(base.builder, ctx.request.body.builder) ||
!isEqual(base.admin, ctx.request.body.admin) ||
!isEqual(base.roles, ctx.request.body.roles)
)
}
const NO_ROLES_MSG =
"Roles/admin/builder can only be set on business/enterprise licenses - input ignored."
async function createUpdateResponse(ctx: UserCtx, user?: User) {
const base = cloneDeep(ctx.request.body)
ctx = await sdk.publicApi.users.roleCheck(ctx, user)
// check the ctx before any updates to it
const removed = rolesRemoved(base, ctx)
ctx = publicApiUserFix(ctx)
const response = await saveGlobalUser(ctx)
ctx.body = await getUser(ctx, response._id)
if (removed) {
ctx.extra = { message: NO_ROLES_MSG }
}
return ctx
}
function isLoggedInUser(ctx: UserCtx, user: User) { function isLoggedInUser(ctx: UserCtx, user: User) {
const loggedInId = ctx.user?._id const loggedInId = ctx.user?._id
@ -35,9 +61,7 @@ export async function search(ctx: UserCtx, next: Next) {
} }
export async function create(ctx: UserCtx, next: Next) { export async function create(ctx: UserCtx, next: Next) {
ctx = publicApiUserFix(await sdk.publicApi.users.roleCheck(ctx)) await createUpdateResponse(ctx)
const response = await saveGlobalUser(ctx)
ctx.body = await getUser(ctx, response._id)
await next() await next()
} }
@ -52,9 +76,7 @@ export async function update(ctx: UserCtx, next: Next) {
...ctx.request.body, ...ctx.request.body,
_rev: user._rev, _rev: user._rev,
} }
ctx = publicApiUserFix(await sdk.publicApi.users.roleCheck(ctx, user)) await createUpdateResponse(ctx, user)
const response = await saveGlobalUser(ctx)
ctx.body = await getUser(ctx, response._id)
await next() await next()
} }

View File

@ -1,6 +1,7 @@
import { roles, context, events, db as dbCore } from "@budibase/backend-core" import { context, db as dbCore, events, roles } from "@budibase/backend-core"
import { getUserMetadataParams, InternalTables } from "../../db/utils" import { getUserMetadataParams, InternalTables } from "../../db/utils"
import { UserCtx, Database, UserRoles, Role } from "@budibase/types" import { Database, Role, UserCtx, UserRoles } from "@budibase/types"
import { sdk as sharedSdk } from "@budibase/shared-core"
import sdk from "../../sdk" import sdk from "../../sdk"
const UpdateRolesOptions = { const UpdateRolesOptions = {
@ -94,7 +95,6 @@ export async function save(ctx: UserCtx) {
) )
role._rev = result.rev role._rev = result.rev
ctx.body = role ctx.body = role
ctx.message = `Role '${role.name}' created successfully.`
} }
export async function destroy(ctx: UserCtx) { export async function destroy(ctx: UserCtx) {
@ -131,3 +131,16 @@ export async function destroy(ctx: UserCtx) {
ctx.message = `Role ${ctx.params.roleId} deleted successfully` ctx.message = `Role ${ctx.params.roleId} deleted successfully`
ctx.status = 200 ctx.status = 200
} }
export async function accessible(ctx: UserCtx) {
let roleId = ctx.user?.roleId
if (!roleId) {
roleId = roles.BUILTIN_ROLE_IDS.PUBLIC
}
if (ctx.user && sharedSdk.users.isAdminOrBuilder(ctx.user)) {
const appId = context.getAppId()
ctx.body = await roles.getAllRoleIds(appId)
} else {
ctx.body = await roles.getUserRoleIdHierarchy(roleId!)
}
}

View File

@ -63,9 +63,7 @@ export async function fetch(ctx: UserCtx) {
export async function clientFetch(ctx: UserCtx) { export async function clientFetch(ctx: UserCtx) {
const routing = await getRoutingStructure() const routing = await getRoutingStructure()
let roleId = ctx.user?.role?._id let roleId = ctx.user?.role?._id
const roleIds = (await roles.getUserRoleHierarchy(roleId, { const roleIds = await roles.getUserRoleIdHierarchy(roleId)
idOnly: true,
})) as string[]
for (let topLevel of Object.values(routing.routes) as any) { for (let topLevel of Object.values(routing.routes) as any) {
for (let subpathKey of Object.keys(topLevel.subpaths)) { for (let subpathKey of Object.keys(topLevel.subpaths)) {
let found = false let found = false

View File

@ -269,13 +269,25 @@ function isEditableColumn(column: FieldSchema) {
return !(isExternalAutoColumn || isFormula) return !(isExternalAutoColumn || isFormula)
} }
export class ExternalRequest { export type ExternalRequestReturnType<T> = T extends Operation.READ
private operation: Operation ?
private tableId: string | Row[]
| {
row: Row
table: Table
}
: {
row: Row
table: Table
}
export class ExternalRequest<T extends Operation> {
private readonly operation: T
private readonly tableId: string
private datasource?: Datasource private datasource?: Datasource
private tables: { [key: string]: Table } = {} private tables: { [key: string]: Table } = {}
constructor(operation: Operation, tableId: string, datasource?: Datasource) { constructor(operation: T, tableId: string, datasource?: Datasource) {
this.operation = operation this.operation = operation
this.tableId = tableId this.tableId = tableId
this.datasource = datasource this.datasource = datasource
@ -739,7 +751,7 @@ export class ExternalRequest {
return fields return fields
} }
async run(config: RunConfig) { async run(config: RunConfig): Promise<ExternalRequestReturnType<T>> {
const { operation, tableId } = this const { operation, tableId } = this
let { datasourceId, tableName } = breakExternalTableId(tableId) let { datasourceId, tableName } = breakExternalTableId(tableId)
if (!tableName) { if (!tableName) {
@ -830,8 +842,11 @@ export class ExternalRequest {
} }
const output = this.outputProcessing(response, table, relationships) const output = this.outputProcessing(response, table, relationships)
// if reading it'll just be an array of rows, return whole thing // if reading it'll just be an array of rows, return whole thing
return operation === Operation.READ && Array.isArray(response) const result = (
operation === Operation.READ && Array.isArray(response)
? output ? output
: { row: output[0], table } : { row: output[0], table }
) as ExternalRequestReturnType<T>
return result
} }
} }

View File

@ -1,9 +1,13 @@
import { FieldTypes, NoEmptyFilterStrings } from "../../../constants" import { FieldTypes } from "../../../constants"
import { import {
breakExternalTableId, breakExternalTableId,
breakRowIdField, breakRowIdField,
} from "../../../integrations/utils" } from "../../../integrations/utils"
import { ExternalRequest, RunConfig } from "./ExternalRequest" import {
ExternalRequest,
ExternalRequestReturnType,
RunConfig,
} from "./ExternalRequest"
import { import {
Datasource, Datasource,
IncludeRelationship, IncludeRelationship,
@ -18,14 +22,17 @@ import {
import sdk from "../../../sdk" import sdk from "../../../sdk"
import * as utils from "./utils" import * as utils from "./utils"
import { dataFilters } from "@budibase/shared-core" import { dataFilters } from "@budibase/shared-core"
import { inputProcessing } from "../../../utilities/rowProcessor" import {
inputProcessing,
outputProcessing,
} from "../../../utilities/rowProcessor"
import { cloneDeep, isEqual } from "lodash" import { cloneDeep, isEqual } from "lodash"
export async function handleRequest( export async function handleRequest<T extends Operation>(
operation: Operation, operation: T,
tableId: string, tableId: string,
opts?: RunConfig opts?: RunConfig
) { ): Promise<ExternalRequestReturnType<T>> {
// make sure the filters are cleaned up, no empty strings for equals, fuzzy or string // make sure the filters are cleaned up, no empty strings for equals, fuzzy or string
if (opts && opts.filters) { if (opts && opts.filters) {
opts.filters = sdk.rows.removeEmptyFilters(opts.filters) opts.filters = sdk.rows.removeEmptyFilters(opts.filters)
@ -34,7 +41,7 @@ export async function handleRequest(
!dataFilters.hasFilters(opts?.filters) && !dataFilters.hasFilters(opts?.filters) &&
opts?.filters?.onEmptyFilter === EmptyFilterOption.RETURN_NONE opts?.filters?.onEmptyFilter === EmptyFilterOption.RETURN_NONE
) { ) {
return [] return [] as any
} }
return new ExternalRequest(operation, tableId, opts?.datasource).run( return new ExternalRequest(operation, tableId, opts?.datasource).run(
@ -46,21 +53,26 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
const tableId = utils.getTableId(ctx) const tableId = utils.getTableId(ctx)
const { _id, ...rowData } = ctx.request.body const { _id, ...rowData } = ctx.request.body
const table = await sdk.tables.getTable(tableId)
const { row: dataToUpdate } = await inputProcessing(
ctx.user?._id,
cloneDeep(table),
rowData
)
const validateResult = await sdk.rows.utils.validate({ const validateResult = await sdk.rows.utils.validate({
row: rowData, row: dataToUpdate,
tableId, tableId,
}) })
if (!validateResult.valid) { if (!validateResult.valid) {
throw { validation: validateResult.errors } throw { validation: validateResult.errors }
} }
const response = await handleRequest(Operation.UPDATE, tableId, { const response = await handleRequest(Operation.UPDATE, tableId, {
id: breakRowIdField(_id), id: breakRowIdField(_id),
row: rowData, row: dataToUpdate,
}) })
const row = await sdk.rows.external.getRow(tableId, _id, { const row = await outputProcessing(table, response.row)
relationships: true,
})
const table = await sdk.tables.getTable(tableId)
return { return {
...response, ...response,
row, row,
@ -71,13 +83,6 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
export async function save(ctx: UserCtx) { export async function save(ctx: UserCtx) {
const inputs = ctx.request.body const inputs = ctx.request.body
const tableId = utils.getTableId(ctx) const tableId = utils.getTableId(ctx)
const validateResult = await sdk.rows.utils.validate({
row: inputs,
tableId,
})
if (!validateResult.valid) {
throw { validation: validateResult.errors }
}
const table = await sdk.tables.getTable(tableId) const table = await sdk.tables.getTable(tableId)
const { table: updatedTable, row } = await inputProcessing( const { table: updatedTable, row } = await inputProcessing(
@ -86,6 +91,14 @@ export async function save(ctx: UserCtx) {
inputs inputs
) )
const validateResult = await sdk.rows.utils.validate({
row,
tableId,
})
if (!validateResult.valid) {
throw { validation: validateResult.errors }
}
const response = await handleRequest(Operation.CREATE, tableId, { const response = await handleRequest(Operation.CREATE, tableId, {
row, row,
}) })
@ -103,7 +116,7 @@ export async function save(ctx: UserCtx) {
}) })
return { return {
...response, ...response,
row, row: await outputProcessing(table, row),
} }
} else { } else {
return response return response
@ -121,7 +134,12 @@ export async function find(ctx: UserCtx): Promise<Row> {
ctx.throw(404) ctx.throw(404)
} }
return row const table = await sdk.tables.getTable(tableId)
// Preserving links, as the outputProcessing does not support external rows yet and we don't need it in this use case
return await outputProcessing(table, row, {
squash: false,
preserveLinks: true,
})
} }
export async function destroy(ctx: UserCtx) { export async function destroy(ctx: UserCtx) {

View File

@ -107,6 +107,11 @@ export const serveApp = async function (ctx: any) {
//Public Settings //Public Settings
const { config } = await configs.getSettingsConfigDoc() const { config } = await configs.getSettingsConfigDoc()
const branding = await pro.branding.getBrandingConfig(config) const branding = await pro.branding.getBrandingConfig(config)
// incase running direct from TS
let appHbsPath = join(__dirname, "app.hbs")
if (!fs.existsSync(appHbsPath)) {
appHbsPath = join(__dirname, "templates", "app.hbs")
}
let db let db
try { try {
@ -138,7 +143,7 @@ export const serveApp = async function (ctx: any) {
? objectStore.getGlobalFileUrl("settings", "logoUrl") ? objectStore.getGlobalFileUrl("settings", "logoUrl")
: "", : "",
}) })
const appHbs = loadHandlebarsFile(`${__dirname}/app.hbs`) const appHbs = loadHandlebarsFile(appHbsPath)
ctx.body = await processString(appHbs, { ctx.body = await processString(appHbs, {
head, head,
body: html, body: html,
@ -166,7 +171,7 @@ export const serveApp = async function (ctx: any) {
: "", : "",
}) })
const appHbs = loadHandlebarsFile(`${__dirname}/app.hbs`) const appHbs = loadHandlebarsFile(appHbsPath)
ctx.body = await processString(appHbs, { ctx.body = await processString(appHbs, {
head, head,
body: html, body: html,
@ -193,8 +198,13 @@ export const serveBuilderPreview = async function (ctx: any) {
} }
export const serveClientLibrary = async function (ctx: any) { export const serveClientLibrary = async function (ctx: any) {
let rootPath = join(NODE_MODULES_PATH, "@budibase", "client", "dist")
// incase running from TS directly
if (env.isDev() && !fs.existsSync(rootPath)) {
rootPath = join(require.resolve("@budibase/client"), "..")
}
return send(ctx, "budibase-client.js", { return send(ctx, "budibase-client.js", {
root: join(NODE_MODULES_PATH, "@budibase", "client", "dist"), root: rootPath,
}) })
} }

View File

@ -68,6 +68,7 @@ describe("no user role update in free", () => {
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.body.data.roles["app_a"]).toBeUndefined() expect(res.body.data.roles["app_a"]).toBeUndefined()
expect(res.body.message).toBeDefined()
}) })
it("should not allow 'admin' to be updated", async () => { it("should not allow 'admin' to be updated", async () => {
@ -77,6 +78,7 @@ describe("no user role update in free", () => {
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.body.data.admin).toBeUndefined() expect(res.body.data.admin).toBeUndefined()
expect(res.body.message).toBeDefined()
}) })
it("should not allow 'builder' to be updated", async () => { it("should not allow 'builder' to be updated", async () => {
@ -86,6 +88,7 @@ describe("no user role update in free", () => {
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.body.data.builder).toBeUndefined() expect(res.body.data.builder).toBeUndefined()
expect(res.body.message).toBeDefined()
}) })
}) })
@ -102,6 +105,7 @@ describe("no user role update in business", () => {
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.body.data.roles["app_a"]).toBe("BASIC") expect(res.body.data.roles["app_a"]).toBe("BASIC")
expect(res.body.message).toBeUndefined()
}) })
it("should allow 'admin' to be updated", async () => { it("should allow 'admin' to be updated", async () => {
@ -112,6 +116,7 @@ describe("no user role update in business", () => {
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.body.data.admin.global).toBe(true) expect(res.body.data.admin.global).toBe(true)
expect(res.body.message).toBeUndefined()
}) })
it("should allow 'builder' to be updated", async () => { it("should allow 'builder' to be updated", async () => {
@ -122,5 +127,6 @@ describe("no user role update in business", () => {
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.body.data.builder.global).toBe(true) expect(res.body.data.builder.global).toBe(true)
expect(res.body.message).toBeUndefined()
}) })
}) })

View File

@ -7,6 +7,9 @@ import { roleValidator } from "./utils/validators"
const router: Router = new Router() const router: Router = new Router()
router router
// retrieve a list of the roles a user can access
// needs to be public for public screens
.get("/api/roles/accessible", controller.accessible)
.post( .post(
"/api/roles", "/api/roles",
authorized(permissions.BUILDER), authorized(permissions.BUILDER),

View File

@ -15,7 +15,7 @@ describe("/roles", () => {
await config.init() await config.init()
}) })
const createRole = async (role) => { const createRole = async role => {
if (!role) { if (!role) {
role = basicRole() role = basicRole()
} }
@ -33,9 +33,6 @@ describe("/roles", () => {
const role = basicRole() const role = basicRole()
const res = await createRole(role) const res = await createRole(role)
expect(res.res.statusMessage).toEqual(
`Role '${role.name}' created successfully.`
)
expect(res.body._id).toBeDefined() expect(res.body._id).toBeDefined()
expect(res.body._rev).toBeDefined() expect(res.body._rev).toBeDefined()
expect(events.role.updated).not.toBeCalled() expect(events.role.updated).not.toBeCalled()
@ -51,9 +48,6 @@ describe("/roles", () => {
jest.clearAllMocks() jest.clearAllMocks()
res = await createRole(res.body) res = await createRole(res.body)
expect(res.res.statusMessage).toEqual(
`Role '${role.name}' created successfully.`
)
expect(res.body._id).toBeDefined() expect(res.body._id).toBeDefined()
expect(res.body._rev).toBeDefined() expect(res.body._rev).toBeDefined()
expect(events.role.created).not.toBeCalled() expect(events.role.created).not.toBeCalled()
@ -99,7 +93,11 @@ describe("/roles", () => {
it("should be able to get the role with a permission added", async () => { it("should be able to get the role with a permission added", async () => {
const table = await config.createTable() const table = await config.createTable()
await config.api.permission.set({ roleId: BUILTIN_ROLE_IDS.POWER, resourceId: table._id, level: PermissionLevel.READ }) await config.api.permission.set({
roleId: BUILTIN_ROLE_IDS.POWER,
resourceId: table._id,
level: PermissionLevel.READ,
})
const res = await request const res = await request
.get(`/api/roles`) .get(`/api/roles`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
@ -131,4 +129,34 @@ describe("/roles", () => {
expect(events.role.deleted).toBeCalledWith(customRole) expect(events.role.deleted).toBeCalledWith(customRole)
}) })
}) })
describe("accessible", () => {
it("should be able to fetch accessible roles (with builder)", async () => {
const res = await request
.get("/api/roles/accessible")
.set(config.defaultHeaders())
.expect(200)
expect(res.body.length).toBe(5)
expect(typeof res.body[0]).toBe("string")
})
it("should be able to fetch accessible roles (basic user)", async () => {
const res = await request
.get("/api/roles/accessible")
.set(await config.basicRoleHeaders())
.expect(200)
expect(res.body.length).toBe(2)
expect(res.body[0]).toBe("BASIC")
expect(res.body[1]).toBe("PUBLIC")
})
it("should be able to fetch accessible roles (no user)", async () => {
const res = await request
.get("/api/roles/accessible")
.set(config.publicHeaders())
.expect(200)
expect(res.body.length).toBe(1)
expect(res.body[0]).toBe("PUBLIC")
})
})
}) })

View File

@ -7,6 +7,7 @@ import { context, InternalTable, roles, tenancy } from "@budibase/backend-core"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
import { import {
FieldType, FieldType,
FieldTypeSubtypes,
MonthlyQuotaName, MonthlyQuotaName,
PermissionLevel, PermissionLevel,
QuotaUsageType, QuotaUsageType,
@ -17,6 +18,7 @@ import {
SortType, SortType,
StaticQuotaName, StaticQuotaName,
Table, Table,
User,
} from "@budibase/types" } from "@budibase/types"
import { import {
expectAnyExternalColsAttributes, expectAnyExternalColsAttributes,
@ -25,6 +27,7 @@ import {
mocks, mocks,
structures, structures,
} from "@budibase/backend-core/tests" } from "@budibase/backend-core/tests"
import _ from "lodash"
const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString() const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString()
tk.freeze(timestamp) tk.freeze(timestamp)
@ -34,7 +37,7 @@ const { basicRow } = setup.structures
describe.each([ describe.each([
["internal", undefined], ["internal", undefined],
["postgres", databaseTestProviders.postgres], ["postgres", databaseTestProviders.postgres],
])("/rows (%s)", (_, dsProvider) => { ])("/rows (%s)", (__, dsProvider) => {
const isInternal = !dsProvider const isInternal = !dsProvider
const request = setup.getRequest() const request = setup.getRequest()
@ -1511,4 +1514,393 @@ describe.each([
}) })
}) })
}) })
describe("bb reference fields", () => {
let tableId: string
let users: User[]
beforeAll(async () => {
const tableConfig = generateTableConfig()
if (config.datasource) {
tableConfig.sourceId = config.datasource._id
if (config.datasource.plus) {
tableConfig.type = "external"
}
}
const table = await config.api.table.create({
...tableConfig,
schema: {
...tableConfig.schema,
user: {
name: "user",
type: FieldType.BB_REFERENCE,
subtype: FieldTypeSubtypes.BB_REFERENCE.USER,
relationshipType: RelationshipType.ONE_TO_MANY,
},
users: {
name: "users",
type: FieldType.BB_REFERENCE,
subtype: FieldTypeSubtypes.BB_REFERENCE.USER,
relationshipType: RelationshipType.MANY_TO_MANY,
},
},
})
tableId = table._id!
users = [
await config.createUser(),
await config.createUser(),
await config.createUser(),
await config.createUser(),
]
})
it("can save a row when BB reference fields are empty", async () => {
const rowData = {
...basicRow(tableId),
name: generator.name(),
description: generator.name(),
}
const row = await config.api.row.save(tableId, rowData)
expect(row).toEqual({
name: rowData.name,
description: rowData.description,
tableId,
_id: expect.any(String),
_rev: expect.any(String),
id: isInternal ? undefined : expect.any(Number),
type: isInternal ? "row" : undefined,
})
})
it("can save a row with a single BB reference field", async () => {
const user = _.sample(users)!
const rowData = {
...basicRow(tableId),
name: generator.name(),
description: generator.name(),
user: user,
}
const row = await config.api.row.save(tableId, rowData)
expect(row).toEqual({
name: rowData.name,
description: rowData.description,
tableId,
user: [
{
_id: user._id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
primaryDisplay: user.email,
},
],
_id: expect.any(String),
_rev: expect.any(String),
id: isInternal ? undefined : expect.any(Number),
type: isInternal ? "row" : undefined,
})
})
it("can save a row with a multiple BB reference field", async () => {
const selectedUsers = _.sampleSize(users, 2)
const rowData = {
...basicRow(tableId),
name: generator.name(),
description: generator.name(),
users: selectedUsers,
}
const row = await config.api.row.save(tableId, rowData)
expect(row).toEqual({
name: rowData.name,
description: rowData.description,
tableId,
users: selectedUsers.map(u => ({
_id: u._id,
email: u.email,
firstName: u.firstName,
lastName: u.lastName,
primaryDisplay: u.email,
})),
_id: expect.any(String),
_rev: expect.any(String),
id: isInternal ? undefined : expect.any(Number),
type: isInternal ? "row" : undefined,
})
})
it("can retrieve rows with no populated BB references", async () => {
const rowData = {
...basicRow(tableId),
name: generator.name(),
description: generator.name(),
}
const row = await config.api.row.save(tableId, rowData)
const { body: retrieved } = await config.api.row.get(tableId, row._id!)
expect(retrieved).toEqual({
name: rowData.name,
description: rowData.description,
tableId,
user: undefined,
users: undefined,
_id: row._id,
_rev: expect.any(String),
id: isInternal ? undefined : expect.any(Number),
...defaultRowFields,
})
})
it("can retrieve rows with populated BB references", async () => {
const [user1, user2] = _.sampleSize(users, 2)
const rowData = {
...basicRow(tableId),
name: generator.name(),
description: generator.name(),
users: [user1, user2],
user: [user1],
}
const row = await config.api.row.save(tableId, rowData)
const { body: retrieved } = await config.api.row.get(tableId, row._id!)
expect(retrieved).toEqual({
name: rowData.name,
description: rowData.description,
tableId,
user: [user1].map(u => ({
_id: u._id,
email: u.email,
firstName: u.firstName,
lastName: u.lastName,
primaryDisplay: u.email,
})),
users: [user1, user2].map(u => ({
_id: u._id,
email: u.email,
firstName: u.firstName,
lastName: u.lastName,
primaryDisplay: u.email,
})),
_id: row._id,
_rev: expect.any(String),
id: isInternal ? undefined : expect.any(Number),
...defaultRowFields,
})
})
it("can update an existing populated row", async () => {
const [user1, user2, user3] = _.sampleSize(users, 3)
const rowData = {
...basicRow(tableId),
name: generator.name(),
description: generator.name(),
users: [user1, user2],
}
const row = await config.api.row.save(tableId, rowData)
const updatedRow = await config.api.row.save(tableId, {
...row,
user: [user3],
users: [user3, user2],
})
expect(updatedRow).toEqual({
name: rowData.name,
description: rowData.description,
tableId,
user: [
{
_id: user3._id,
email: user3.email,
firstName: user3.firstName,
lastName: user3.lastName,
primaryDisplay: user3.email,
},
],
users: [user3, user2].map(u => ({
_id: u._id,
email: u.email,
firstName: u.firstName,
lastName: u.lastName,
primaryDisplay: u.email,
})),
_id: row._id,
_rev: expect.any(String),
id: isInternal ? undefined : expect.any(Number),
type: isInternal ? "row" : undefined,
})
})
it("can wipe an existing populated BB references in row", async () => {
const [user1, user2] = _.sampleSize(users, 2)
const rowData = {
...basicRow(tableId),
name: generator.name(),
description: generator.name(),
users: [user1, user2],
}
const row = await config.api.row.save(tableId, rowData)
const updatedRow = await config.api.row.save(tableId, {
...row,
user: null,
users: null,
})
expect(updatedRow).toEqual({
name: rowData.name,
description: rowData.description,
tableId,
user: isInternal ? null : undefined,
users: isInternal ? null : undefined,
_id: row._id,
_rev: expect.any(String),
id: isInternal ? undefined : expect.any(Number),
type: isInternal ? "row" : undefined,
})
})
it("fetch all will populate the BB references", async () => {
const [user1, user2, user3] = _.sampleSize(users, 3)
const rows: {
name: string
description: string
user?: User[]
users?: User[]
tableId: string
}[] = [
{
...basicRow(tableId),
name: generator.name(),
description: generator.name(),
users: [user1, user2],
},
{
...basicRow(tableId),
name: generator.name(),
description: generator.name(),
user: [user1],
users: [user1, user3],
},
{
...basicRow(tableId),
name: generator.name(),
description: generator.name(),
users: [user3],
},
]
await config.api.row.save(tableId, rows[0])
await config.api.row.save(tableId, rows[1])
await config.api.row.save(tableId, rows[2])
const res = await config.api.row.fetch(tableId)
expect(res).toEqual(
expect.arrayContaining(
rows.map(r => ({
name: r.name,
description: r.description,
tableId,
user: r.user?.map(u => ({
_id: u._id,
email: u.email,
firstName: u.firstName,
lastName: u.lastName,
primaryDisplay: u.email,
})),
users: r.users?.map(u => ({
_id: u._id,
email: u.email,
firstName: u.firstName,
lastName: u.lastName,
primaryDisplay: u.email,
})),
_id: expect.any(String),
_rev: expect.any(String),
id: isInternal ? undefined : expect.any(Number),
...defaultRowFields,
}))
)
)
})
it("search all will populate the BB references", async () => {
const [user1, user2, user3] = _.sampleSize(users, 3)
const rows: {
name: string
description: string
user?: User[]
users?: User[]
tableId: string
}[] = [
{
...basicRow(tableId),
name: generator.name(),
description: generator.name(),
users: [user1, user2],
},
{
...basicRow(tableId),
name: generator.name(),
description: generator.name(),
user: [user1],
users: [user1, user3],
},
{
...basicRow(tableId),
name: generator.name(),
description: generator.name(),
users: [user3],
},
]
await config.api.row.save(tableId, rows[0])
await config.api.row.save(tableId, rows[1])
await config.api.row.save(tableId, rows[2])
const res = await config.api.row.search(tableId)
expect(res).toEqual({
rows: expect.arrayContaining(
rows.map(r => ({
name: r.name,
description: r.description,
tableId,
user: r.user?.map(u => ({
_id: u._id,
email: u.email,
firstName: u.firstName,
lastName: u.lastName,
primaryDisplay: u.email,
})),
users: r.users?.map(u => ({
_id: u._id,
email: u.email,
firstName: u.firstName,
lastName: u.lastName,
primaryDisplay: u.email,
})),
_id: expect.any(String),
_rev: expect.any(String),
id: isInternal ? undefined : expect.any(Number),
...defaultRowFields,
}))
),
...(isInternal
? {}
: {
hasNextPage: false,
bookmark: null,
}),
})
})
})
}) })

View File

@ -613,7 +613,7 @@ export interface components {
global?: boolean; global?: boolean;
}; };
/** @description Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license. */ /** @description Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license. */
roles: { [key: string]: string }; roles?: { [key: string]: string };
}; };
userOutput: { userOutput: {
data: { data: {
@ -643,7 +643,7 @@ export interface components {
global?: boolean; global?: boolean;
}; };
/** @description Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license. */ /** @description Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license. */
roles: { [key: string]: string }; roles?: { [key: string]: string };
/** @description The ID of the user. */ /** @description The ID of the user. */
_id: string; _id: string;
}; };
@ -676,7 +676,7 @@ export interface components {
global?: boolean; global?: boolean;
}; };
/** @description Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license. */ /** @description Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license. */
roles: { [key: string]: string }; roles?: { [key: string]: string };
/** @description The ID of the user. */ /** @description The ID of the user. */
_id: string; _id: string;
}[]; }[];

View File

@ -1,6 +1,5 @@
import { import {
ConnectionInfo, ConnectionInfo,
Datasource,
DatasourceFeature, DatasourceFeature,
DatasourceFieldType, DatasourceFieldType,
DatasourcePlus, DatasourcePlus,
@ -23,7 +22,6 @@ import fetch from "node-fetch"
import { cache, configs, context, HTTPError } from "@budibase/backend-core" import { cache, configs, context, HTTPError } from "@budibase/backend-core"
import { dataFilters, utils } from "@budibase/shared-core" import { dataFilters, utils } from "@budibase/shared-core"
import { GOOGLE_SHEETS_PRIMARY_KEY } from "../constants" import { GOOGLE_SHEETS_PRIMARY_KEY } from "../constants"
import sdk from "../sdk"
interface GoogleSheetsConfig { interface GoogleSheetsConfig {
spreadsheetId: string spreadsheetId: string
@ -56,6 +54,7 @@ const ALLOWED_TYPES = [
FieldType.OPTIONS, FieldType.OPTIONS,
FieldType.BOOLEAN, FieldType.BOOLEAN,
FieldType.BARCODEQR, FieldType.BARCODEQR,
FieldType.BB_REFERENCE,
] ]
const SCHEMA: Integration = { const SCHEMA: Integration = {
@ -213,7 +212,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
await setupCreationAuth(this.config) await setupCreationAuth(this.config)
// Initialise oAuth client // Initialise oAuth client
let googleConfig = await configs.getGoogleDatasourceConfig() const googleConfig = await configs.getGoogleDatasourceConfig()
if (!googleConfig) { if (!googleConfig) {
throw new HTTPError("Google config not found", 400) throw new HTTPError("Google config not found", 400)
} }
@ -323,14 +322,14 @@ class GoogleSheetsIntegration implements DatasourcePlus {
case Operation.UPDATE: case Operation.UPDATE:
return this.update({ return this.update({
// exclude the header row and zero index // exclude the header row and zero index
rowIndex: json.extra?.idFilter?.equal?.rowNumber - 2, rowIndex: json.extra?.idFilter?.equal?.rowNumber,
sheet, sheet,
row: json.body, row: json.body,
}) })
case Operation.DELETE: case Operation.DELETE:
return this.delete({ return this.delete({
// exclude the header row and zero index // exclude the header row and zero index
rowIndex: json.extra?.idFilter?.equal?.rowNumber - 2, rowIndex: json.extra?.idFilter?.equal?.rowNumber,
sheet, sheet,
}) })
case Operation.CREATE_TABLE: case Operation.CREATE_TABLE:
@ -541,17 +540,30 @@ class GoogleSheetsIntegration implements DatasourcePlus {
} }
} }
private async getRowByIndex(sheetTitle: string, rowIndex: number) {
const sheet = this.client.sheetsByTitle[sheetTitle]
const rows = await sheet.getRows()
// We substract 2, as the SDK is skipping the header automatically and Google Spreadsheets is base 1
const row = rows[rowIndex - 2]
return { sheet, row }
}
async update(query: { sheet: string; rowIndex: number; row: any }) { async update(query: { sheet: string; rowIndex: number; row: any }) {
try { try {
await this.connect() await this.connect()
const sheet = this.client.sheetsByTitle[query.sheet] const { sheet, row } = await this.getRowByIndex(
const rows = await sheet.getRows() query.sheet,
const row = rows[query.rowIndex] query.rowIndex
)
if (row) { if (row) {
const updateValues = const updateValues =
typeof query.row === "string" ? JSON.parse(query.row) : query.row typeof query.row === "string" ? JSON.parse(query.row) : query.row
for (let key in updateValues) { for (let key in updateValues) {
row[key] = updateValues[key] row[key] = updateValues[key]
if (row[key] === null) {
row[key] = ""
}
} }
await row.save() await row.save()
return [ return [
@ -568,9 +580,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
async delete(query: { sheet: string; rowIndex: number }) { async delete(query: { sheet: string; rowIndex: number }) {
await this.connect() await this.connect()
const sheet = this.client.sheetsByTitle[query.sheet] const { row } = await this.getRowByIndex(query.sheet, query.rowIndex)
const rows = await sheet.getRows()
const row = rows[query.rowIndex]
if (row) { if (row) {
await row.delete() await row.delete()
return [ return [

View File

@ -55,9 +55,7 @@ const checkAuthorizedResource = async (
) => { ) => {
// get the user's roles // get the user's roles
const roleId = ctx.roleId || roles.BUILTIN_ROLE_IDS.PUBLIC const roleId = ctx.roleId || roles.BUILTIN_ROLE_IDS.PUBLIC
const userRoles = (await roles.getUserRoleHierarchy(roleId, { const userRoles = await roles.getUserRoleHierarchy(roleId)
idOnly: false,
})) as Role[]
const permError = "User does not have permission" const permError = "User does not have permission"
// check if the user has the required role // check if the user has the required role
if (resourceRoles.length > 0) { if (resourceRoles.length > 0) {

View File

@ -17,6 +17,7 @@ import { utils } from "@budibase/shared-core"
import { ExportRowsParams, ExportRowsResult } from "../search" import { ExportRowsParams, ExportRowsResult } from "../search"
import { HTTPError, db } from "@budibase/backend-core" import { HTTPError, db } from "@budibase/backend-core"
import pick from "lodash/pick" import pick from "lodash/pick"
import { outputProcessing } from "../../../../utilities/rowProcessor"
export async function search(options: SearchParams) { export async function search(options: SearchParams) {
const { tableId } = options const { tableId } = options
@ -75,6 +76,9 @@ export async function search(options: SearchParams) {
rows = rows.map((r: any) => pick(r, fields)) rows = rows.map((r: any) => pick(r, fields))
} }
const table = await sdk.tables.getTable(tableId)
rows = await outputProcessing(table, rows)
// need wrapper object for bookmarks etc when paginating // need wrapper object for bookmarks etc when paginating
return { rows, hasNextPage, bookmark: bookmark && bookmark + 1 } return { rows, hasNextPage, bookmark: bookmark && bookmark + 1 }
} catch (err: any) { } catch (err: any) {
@ -166,9 +170,11 @@ export async function exportRows(
} }
export async function fetch(tableId: string) { export async function fetch(tableId: string) {
return handleRequest(Operation.READ, tableId, { const response = await handleRequest(Operation.READ, tableId, {
includeSqlRelationships: IncludeRelationship.INCLUDE, includeSqlRelationships: IncludeRelationship.INCLUDE,
}) })
const table = await sdk.tables.getTable(tableId)
return await outputProcessing(table, response)
} }
export async function fetchView(viewName: string) { export async function fetchView(viewName: string) {

View File

@ -7,9 +7,14 @@ import { HTTPError } from "@budibase/backend-core"
import { Operation } from "@budibase/types" import { Operation } from "@budibase/types"
const mockDatasourcesGet = jest.fn() const mockDatasourcesGet = jest.fn()
const mockTableGet = jest.fn()
sdk.datasources.get = mockDatasourcesGet sdk.datasources.get = mockDatasourcesGet
sdk.tables.getTable = mockTableGet
jest.mock("../../../api/controllers/row/ExternalRequest") jest.mock("../../../api/controllers/row/ExternalRequest")
jest.mock("../../../utilities/rowProcessor", () => ({
outputProcessing: jest.fn((_, rows) => rows),
}))
jest.mock("../../../api/controllers/view/exporters", () => ({ jest.mock("../../../api/controllers/view/exporters", () => ({
...jest.requireActual("../../../api/controllers/view/exporters"), ...jest.requireActual("../../../api/controllers/view/exporters"),

View File

@ -425,6 +425,15 @@ class TestConfiguration {
return headers return headers
} }
async basicRoleHeaders() {
return await this.roleHeaders({
email: this.defaultUserValues.email,
builder: false,
prodApp: true,
roleId: roles.BUILTIN_ROLE_IDS.BASIC,
})
}
async roleHeaders({ async roleHeaders({
email = this.defaultUserValues.email, email = this.defaultUserValues.email,
roleId = roles.BUILTIN_ROLE_IDS.ADMIN, roleId = roles.BUILTIN_ROLE_IDS.ADMIN,

View File

@ -44,12 +44,12 @@ export class RowAPI extends TestAPI {
} }
save = async ( save = async (
sourceId: string, tableId: string,
row: SaveRowRequest, row: SaveRowRequest,
{ expectStatus } = { expectStatus: 200 } { expectStatus } = { expectStatus: 200 }
): Promise<Row> => { ): Promise<Row> => {
const resp = await this.request const resp = await this.request
.post(`/api/${sourceId}/rows`) .post(`/api/${tableId}/rows`)
.send(row) .send(row)
.set(this.config.defaultHeaders()) .set(this.config.defaultHeaders())
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
@ -122,4 +122,16 @@ export class RowAPI extends TestAPI {
.expect(expectStatus) .expect(expectStatus)
return request return request
} }
search = async (
sourceId: string,
{ expectStatus } = { expectStatus: 200 }
): Promise<Row[]> => {
const request = this.request
.post(`/api/${sourceId}/search`)
.set(this.config.defaultHeaders())
.expect(expectStatus)
return (await request).body
}
} }

View File

@ -8,7 +8,7 @@ import path from "path"
* @param args Any number of string arguments to add to a path * @param args Any number of string arguments to add to a path
* @returns {string} The final path ready to use * @returns {string} The final path ready to use
*/ */
export function join(...args: any) { export function join(...args: string[]) {
return path.join(...args) return path.join(...args)
} }
@ -17,6 +17,6 @@ export function join(...args: any) {
* @param args Any number of string arguments to add to a path * @param args Any number of string arguments to add to a path
* @returns {string} The final path ready to use * @returns {string} The final path ready to use
*/ */
export function resolve(...args: any) { export function resolve(...args: string[]) {
return path.resolve(...args) return path.resolve(...args)
} }

View File

@ -6,7 +6,7 @@ import { InvalidBBRefError } from "./errors"
export async function processInputBBReferences( export async function processInputBBReferences(
value: string | string[] | { _id: string } | { _id: string }[], value: string | string[] | { _id: string } | { _id: string }[],
subtype: FieldSubtype subtype: FieldSubtype
): Promise<string | undefined> { ): Promise<string | null> {
const referenceIds: string[] = [] const referenceIds: string[] = []
if (Array.isArray(value)) { if (Array.isArray(value)) {
@ -39,7 +39,7 @@ export async function processInputBBReferences(
throw utils.unreachable(subtype) throw utils.unreachable(subtype)
} }
return referenceIds.join(",") || undefined return referenceIds.join(",") || null
} }
export async function processOutputBBReferences( export async function processOutputBBReferences(

View File

@ -200,7 +200,10 @@ export async function inputProcessing(
export async function outputProcessing<T extends Row[] | Row>( export async function outputProcessing<T extends Row[] | Row>(
table: Table, table: Table,
rows: T, rows: T,
opts = { squash: true } opts: { squash?: boolean; preserveLinks?: boolean } = {
squash: true,
preserveLinks: false,
}
): Promise<T> { ): Promise<T> {
let safeRows: Row[] let safeRows: Row[]
let wasArray = true let wasArray = true
@ -211,7 +214,9 @@ export async function outputProcessing<T extends Row[] | Row>(
safeRows = rows safeRows = rows
} }
// attach any linked row information // attach any linked row information
let enriched = await linkRows.attachFullLinkedDocs(table, safeRows) let enriched = !opts.preserveLinks
? await linkRows.attachFullLinkedDocs(table, safeRows)
: safeRows
// process formulas // process formulas
enriched = processFormulas(table, enriched, { dynamic: true }) as Row[] enriched = processFormulas(table, enriched, { dynamic: true }) as Row[]

View File

@ -139,20 +139,20 @@ describe("bbReferenceProcessor", () => {
expect(cacheGetUsersSpy).toBeCalledWith(userIds) expect(cacheGetUsersSpy).toBeCalledWith(userIds)
}) })
it("empty strings will return undefined", async () => { it("empty strings will return null", async () => {
const result = await config.doInTenant(() => const result = await config.doInTenant(() =>
processInputBBReferences("", FieldSubtype.USER) processInputBBReferences("", FieldSubtype.USER)
) )
expect(result).toEqual(undefined) expect(result).toEqual(null)
}) })
it("empty arrays will return undefined", async () => { it("empty arrays will return null", async () => {
const result = await config.doInTenant(() => const result = await config.doInTenant(() =>
processInputBBReferences([], FieldSubtype.USER) processInputBBReferences([], FieldSubtype.USER)
) )
expect(result).toEqual(undefined) expect(result).toEqual(null)
}) })
}) })
}) })

View File

@ -36,5 +36,8 @@ export function publicApiUserFix(ctx: UserCtx) {
if (!ctx.request.body._id && ctx.params.userId) { if (!ctx.request.body._id && ctx.params.userId) {
ctx.request.body._id = ctx.params.userId ctx.request.body._id = ctx.params.userId
} }
if (!ctx.request.body.roles) {
ctx.request.body.roles = {}
}
return ctx return ctx
} }

View File

@ -1,4 +1,4 @@
import { Account } from "../../documents" import { Account, AccountSSOProvider } from "../../documents"
import { Hosting } from "../../sdk" import { Hosting } from "../../sdk"
export interface CreateAccountRequest { export interface CreateAccountRequest {
@ -11,6 +11,8 @@ export interface CreateAccountRequest {
tenantName?: string tenantName?: string
name?: string name?: string
password: string password: string
provider?: AccountSSOProvider
thirdPartyProfile: object
} }
export interface SearchAccountsRequest { export interface SearchAccountsRequest {

View File

@ -61,6 +61,7 @@ export interface CreateAdminUserRequest {
email: string email: string
password: string password: string
tenantId: string tenantId: string
ssoId?: string
} }
export interface CreateAdminUserResponse { export interface CreateAdminUserResponse {

View File

@ -20,6 +20,11 @@ export interface CreatePassswordAccount extends CreateAccount {
password: string password: string
} }
export interface CreateVerifiableSSOAccount extends CreateAccount {
provider?: AccountSSOProvider
thirdPartyProfile?: any
}
export const isCreatePasswordAccount = ( export const isCreatePasswordAccount = (
account: CreateAccount account: CreateAccount
): account is CreatePassswordAccount => account.authType === AuthType.PASSWORD ): account is CreatePassswordAccount => account.authType === AuthType.PASSWORD
@ -50,6 +55,8 @@ export interface Account extends CreateAccount {
licenseKeyActivatedAt?: number licenseKeyActivatedAt?: number
licenseRequestedAt?: number licenseRequestedAt?: number
licenseOverrides?: LicenseOverrides licenseOverrides?: LicenseOverrides
provider?: AccountSSOProvider
providerType?: AccountSSOProviderType
quotaUsage?: QuotaUsage quotaUsage?: QuotaUsage
offlineLicenseToken?: string offlineLicenseToken?: string
} }
@ -87,6 +94,13 @@ export enum AccountSSOProvider {
MICROSOFT = "microsoft", MICROSOFT = "microsoft",
} }
const verifiableSSOProviders: AccountSSOProvider[] = [
AccountSSOProvider.MICROSOFT,
]
export function isVerifiableSSOProvider(provider: AccountSSOProvider): boolean {
return verifiableSSOProviders.includes(provider)
}
export interface AccountSSO { export interface AccountSSO {
provider: AccountSSOProvider provider: AccountSSOProvider
providerType: AccountSSOProviderType providerType: AccountSSOProviderType

View File

@ -55,6 +55,7 @@ export interface User extends Document {
userGroups?: string[] userGroups?: string[]
onboardedAt?: string onboardedAt?: string
scimInfo?: { isSync: true } & Record<string, any> scimInfo?: { isSync: true } & Record<string, any>
ssoId?: string
} }
export enum UserStatus { export enum UserStatus {

View File

@ -15,4 +15,16 @@ export interface PlatformUserById extends Document {
tenantId: string tenantId: string
} }
export type PlatformUser = PlatformUserByEmail | PlatformUserById /**
* doc id is a unique SSO provider ID for the user
*/
export interface PlatformUserBySsoId extends Document {
tenantId: string
userId: string
email: string
}
export type PlatformUser =
| PlatformUserByEmail
| PlatformUserById
| PlatformUserBySsoId

View File

@ -95,7 +95,7 @@ const parseBooleanParam = (param: any) => {
export const adminUser = async ( export const adminUser = async (
ctx: Ctx<CreateAdminUserRequest, CreateAdminUserResponse> ctx: Ctx<CreateAdminUserRequest, CreateAdminUserResponse>
) => { ) => {
const { email, password, tenantId } = ctx.request.body const { email, password, tenantId, ssoId } = ctx.request.body
if (await platform.tenants.exists(tenantId)) { if (await platform.tenants.exists(tenantId)) {
ctx.throw(403, "Organisation already exists.") ctx.throw(403, "Organisation already exists.")
@ -136,6 +136,7 @@ export const adminUser = async (
global: true, global: true,
}, },
tenantId, tenantId,
ssoId,
} }
try { try {
// always bust checklist beforehand, if an error occurs but can proceed, don't get // always bust checklist beforehand, if an error occurs but can proceed, don't get

View File

@ -14,6 +14,7 @@ function buildAdminInitValidation() {
email: Joi.string().required(), email: Joi.string().required(),
password: Joi.string(), password: Joi.string(),
tenantId: Joi.string().required(), tenantId: Joi.string().required(),
ssoId: Joi.string(),
}) })
.required() .required()
.unknown(false) .unknown(false)

View File

@ -1 +0,0 @@
../packages/server/specs/openapi.json

View File

@ -1 +0,0 @@
../packages/server/specs/openapi.yaml