Merge branch 'BUDI-9127/split-oauthconfig-per-document' into BUDI-9127/track-usage

This commit is contained in:
Adria Navarro 2025-03-21 09:12:56 +01:00
commit f904646519
30 changed files with 510 additions and 385 deletions

View File

@ -200,3 +200,13 @@ export function getStartEndKeyURL(baseKey: any, tenantId?: string) {
export const getPluginParams = (pluginId?: string | null, otherProps = {}) => { export const getPluginParams = (pluginId?: string | null, otherProps = {}) => {
return getDocParams(DocumentType.PLUGIN, pluginId, otherProps) return getDocParams(DocumentType.PLUGIN, pluginId, otherProps)
} }
/**
* Gets parameters for retrieving OAuth2 configs, this is a utility function for the getDocParams function.
*/
export const getOAuth2ConfigParams = (
configId?: string | null,
otherProps: Partial<DatabaseQueryOpts> = {}
) => {
return getDocParams(DocumentType.OAUTH2_CONFIG, configId, otherProps)
}

View File

@ -1,4 +1,4 @@
<script> <script lang="ts">
import Popover from "../Popover/Popover.svelte" import Popover from "../Popover/Popover.svelte"
import Layout from "../Layout/Layout.svelte" import Layout from "../Layout/Layout.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
@ -11,15 +11,17 @@
getThemeClassNames, getThemeClassNames,
DefaultAppTheme, DefaultAppTheme,
} from "@budibase/shared-core" } from "@budibase/shared-core"
import type { Theme } from "@budibase/types"
import type { PopoverAlignment } from "../constants"
export let value export let value: string | undefined = undefined
export let size = "M" export let size: "S" | "M" | "L" = "M"
export let spectrumTheme export let spectrumTheme: Theme | undefined = undefined
export let offset export let offset: number | undefined = undefined
export let align export let align: PopoverAlignment | undefined = undefined
let dropdown let dropdown: Popover | undefined
let preview let preview: HTMLElement | undefined
$: customValue = getCustomValue(value) $: customValue = getCustomValue(value)
$: checkColor = getCheckColor(value) $: checkColor = getCheckColor(value)
@ -124,7 +126,7 @@
}, },
] ]
const getThemeClasses = theme => { const getThemeClasses = (theme: Theme | undefined) => {
if (!theme) { if (!theme) {
return "" return ""
} }
@ -132,12 +134,12 @@
return getThemeClassNames(theme) return getThemeClassNames(theme)
} }
const onChange = value => { const onChange = (value: string | null) => {
dispatch("change", value) dispatch("change", value)
dropdown.hide() dropdown?.hide()
} }
const getCustomValue = value => { const getCustomValue = (value: string | undefined) => {
if (!value) { if (!value) {
return value return value
} }
@ -152,11 +154,11 @@
return found ? null : value return found ? null : value
} }
const prettyPrint = color => { const prettyPrint = (color: string) => {
return capitalise(color.split("-").join(" ")) return capitalise(color.split("-").join(" "))
} }
const getCheckColor = value => { const getCheckColor = (value: string | undefined) => {
// Use dynamic color for theme grays // Use dynamic color for theme grays
if (value?.includes("-gray-")) { if (value?.includes("-gray-")) {
return /^.*(gray-(50|75|100|200|300|400|500))\)$/.test(value) return /^.*(gray-(50|75|100|200|300|400|500))\)$/.test(value)
@ -187,7 +189,7 @@
bind:this={preview} bind:this={preview}
class="preview size--{size || 'M'}" class="preview size--{size || 'M'}"
on:click={() => { on:click={() => {
dropdown.toggle() dropdown?.toggle()
}} }}
> >
<div <div

View File

@ -88,16 +88,14 @@
// Required for reactivity // Required for reactivity
fields = fields fields = fields
const newActivity = {} const newActivity = {}
const trimmedFields = []
for (let idx = 0; idx < fields.length; idx++) { for (let idx = 0; idx < fields.length; idx++) {
const fieldName = fields[idx].name const fieldName = fields[idx].name
if (fieldName) { if (fieldName) {
newActivity[fieldName] = fieldActivity[idx] newActivity[fieldName] = fieldActivity[idx]
trimmedFields.push(fields[idx])
} }
} }
activity = newActivity activity = newActivity
dispatch("change", trimmedFields) dispatch("change", fields)
} }
function isJsonArray(value) { function isJsonArray(value) {

View File

@ -29,7 +29,7 @@
...authConfigs, ...authConfigs,
...$oauth2.configs.map(c => ({ ...$oauth2.configs.map(c => ({
label: c.name, label: c.name,
value: c.id, value: c._id,
})), })),
] ]
$: authConfig = allConfigs.find(c => c.value === authConfigId) $: authConfig = allConfigs.find(c => c.value === authConfigId)
@ -108,8 +108,9 @@
{#each $oauth2.configs as config} {#each $oauth2.configs as config}
<ListItem <ListItem
title={config.name} title={config.name}
on:click={() => selectConfiguration(config.id, RestAuthType.OAUTH2)} on:click={() =>
selected={config.id === authConfigId} selectConfiguration(config._id, RestAuthType.OAUTH2)}
selected={config._id === authConfigId}
/> />
{/each} {/each}
</List> </List>

View File

@ -7,7 +7,7 @@
Modal, Modal,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import type { OAuth2Config } from "@budibase/types" import type { OAuth2Config } from "@/types"
import OAuth2ConfigModalContent from "./OAuth2ConfigModalContent.svelte" import OAuth2ConfigModalContent from "./OAuth2ConfigModalContent.svelte"
import { confirm } from "@/helpers" import { confirm } from "@/helpers"
@ -26,7 +26,7 @@
warning: true, warning: true,
onConfirm: async () => { onConfirm: async () => {
try { try {
await oauth2.delete(row.id) await oauth2.delete(row._id, row._rev)
notifications.success(`Config '${row.name}' deleted successfully`) notifications.success(`Config '${row.name}' deleted successfully`)
} catch (e: any) { } catch (e: any) {
let message = "Error deleting config" let message = "Error deleting config"

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { oauth2 } from "@/stores/builder" import { oauth2 } from "@/stores/builder"
import type { OAuth2Config, UpsertOAuth2Config } from "@/types" import type { OAuth2Config } from "@/types"
import { import {
Body, Body,
Divider, Divider,
@ -12,6 +12,7 @@
notifications, notifications,
Select, Select,
} from "@budibase/bbui" } from "@budibase/bbui"
import type { InsertOAuth2ConfigRequest } from "@budibase/types"
import { import {
OAuth2CredentialsMethod, OAuth2CredentialsMethod,
PASSWORD_REPLACEMENT, PASSWORD_REPLACEMENT,
@ -50,7 +51,7 @@
name: requiredString("Name is required.").refine( name: requiredString("Name is required.").refine(
val => val =>
!$oauth2.configs !$oauth2.configs
.filter(c => c.id !== config.id) .filter(c => c._id !== config._id)
.map(c => c.name.toLowerCase()) .map(c => c.name.toLowerCase())
.includes(val.toLowerCase()), .includes(val.toLowerCase()),
{ {
@ -63,7 +64,7 @@
method: z.nativeEnum(OAuth2CredentialsMethod, { method: z.nativeEnum(OAuth2CredentialsMethod, {
message: "Authentication method is required.", message: "Authentication method is required.",
}), }),
}) satisfies ZodType<UpsertOAuth2Config> }) satisfies ZodType<InsertOAuth2ConfigRequest>
const validationResult = validator.safeParse(config) const validationResult = validator.safeParse(config)
errors = {} errors = {}
@ -91,7 +92,7 @@
const { data: configData } = validationResult const { data: configData } = validationResult
try { try {
const connectionValidation = await oauth2.validate({ const connectionValidation = await oauth2.validate({
id: config?.id, _id: config?._id,
url: configData.url, url: configData.url,
clientId: configData.clientId, clientId: configData.clientId,
clientSecret: configData.clientSecret, clientSecret: configData.clientSecret,
@ -110,7 +111,11 @@
await oauth2.create(configData) await oauth2.create(configData)
notifications.success("Settings created.") notifications.success("Settings created.")
} else { } else {
await oauth2.edit(config!.id, configData) await oauth2.edit({
...configData,
_id: config!._id,
_rev: config!._rev,
})
notifications.success("Settings saved.") notifications.success("Settings saved.")
} }
} catch (e: any) { } catch (e: any) {

View File

@ -1,7 +1,11 @@
import { API } from "@/api" import { API } from "@/api"
import { BudiStore } from "@/stores/BudiStore" import { BudiStore } from "@/stores/BudiStore"
import { OAuth2Config, UpsertOAuth2Config } from "@/types" import { OAuth2Config } from "@/types"
import { RequiredKeys, ValidateConfigRequest } from "@budibase/types" import {
InsertOAuth2ConfigRequest,
UpdateOAuth2ConfigRequest,
ValidateConfigRequest,
} from "@budibase/types"
interface OAuth2StoreState { interface OAuth2StoreState {
configs: OAuth2Config[] configs: OAuth2Config[]
@ -26,8 +30,9 @@ export class OAuth2Store extends BudiStore<OAuth2StoreState> {
const configs = await API.oauth2.fetch() const configs = await API.oauth2.fetch()
this.store.update(store => ({ this.store.update(store => ({
...store, ...store,
configs: configs.map<RequiredKeys<OAuth2Config>>(c => ({ configs: configs.map(c => ({
id: c.id, _id: c._id,
_rev: c._rev,
name: c.name, name: c.name,
url: c.url, url: c.url,
clientId: c.clientId, clientId: c.clientId,
@ -46,18 +51,18 @@ export class OAuth2Store extends BudiStore<OAuth2StoreState> {
} }
} }
async create(config: UpsertOAuth2Config) { async create(config: InsertOAuth2ConfigRequest) {
await API.oauth2.create(config) await API.oauth2.create(config)
await this.fetch() await this.fetch()
} }
async edit(id: string, config: UpsertOAuth2Config) { async edit(config: UpdateOAuth2ConfigRequest) {
await API.oauth2.update(id, config) await API.oauth2.update(config)
await this.fetch() await this.fetch()
} }
async delete(id: string) { async delete(id: string, rev: string) {
await API.oauth2.delete(id) await API.oauth2.delete(id, rev)
await this.fetch() await this.fetch()
} }

View File

@ -1,8 +1,3 @@
import { import { OAuth2ConfigResponse } from "@budibase/types"
UpsertOAuth2ConfigRequest,
FetchOAuth2ConfigsResponse,
} from "@budibase/types"
export type OAuth2Config = FetchOAuth2ConfigsResponse["configs"][0] export interface OAuth2Config extends OAuth2ConfigResponse {}
export interface UpsertOAuth2Config extends UpsertOAuth2ConfigRequest {}

View File

@ -1,38 +1,39 @@
<script> <script lang="ts">
import { getContext } from "svelte" import { getContext } from "svelte"
import InnerForm from "./InnerForm.svelte" import InnerForm from "./InnerForm.svelte"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { writable } from "svelte/store" import { writable } from "svelte/store"
import type { DataFetchDatasource, Table, TableSchema } from "@budibase/types"
export let dataSource export let dataSource: DataFetchDatasource
export let size export let size: "Medium" | "Large"
export let disabled = false export let disabled: boolean = false
export let readonly = false export let readonly: boolean = false
export let actionType = "Create" export let actionType: "Create" = "Create"
export let initialFormStep = 1 export let initialFormStep: string | number = 1
// Not exposed as a builder setting. Used internally to disable validation // Not exposed as a builder setting. Used internally to disable validation
// for fields rendered in things like search blocks. // for fields rendered in things like search blocks.
export let disableSchemaValidation = false export let disableSchemaValidation: boolean = false
// Not exposed as a builder setting. Used internally to allow searching on // Not exposed as a builder setting. Used internally to allow searching on
// auto columns. // auto columns.
export let editAutoColumns = false export let editAutoColumns: boolean = false
const context = getContext("context") const context = getContext("context")
const component = getContext("component") const component = getContext("component")
const { fetchDatasourceSchema, fetchDatasourceDefinition } = getContext("sdk") const { fetchDatasourceSchema, fetchDatasourceDefinition } = getContext("sdk")
const getInitialFormStep = () => { const getInitialFormStep = () => {
const parsedFormStep = parseInt(initialFormStep) const parsedFormStep = parseInt(initialFormStep.toString())
if (isNaN(parsedFormStep)) { if (isNaN(parsedFormStep)) {
return 1 return 1
} }
return parsedFormStep return parsedFormStep
} }
let definition let definition: Table | undefined
let schema let schema: TableSchema | undefined
let loaded = false let loaded = false
let currentStep = getContext("current-step") || writable(getInitialFormStep()) let currentStep = getContext("current-step") || writable(getInitialFormStep())
@ -49,7 +50,12 @@
) )
// Returns the closes data context which isn't a built in context // Returns the closes data context which isn't a built in context
const getInitialValues = (type, dataSource, path, context) => { const getInitialValues = (
type: string,
dataSource: DataFetchDatasource,
path: string[],
context: Record<string, any>
) => {
// Only inherit values for update forms // Only inherit values for update forms
if (type !== "Update") { if (type !== "Update") {
return {} return {}
@ -82,11 +88,11 @@
} }
// Fetches the form schema from this form's dataSource // Fetches the form schema from this form's dataSource
const fetchSchema = async dataSource => { const fetchSchema = async (dataSource: DataFetchDatasource) => {
try { try {
definition = await fetchDatasourceDefinition(dataSource) definition = await fetchDatasourceDefinition(dataSource)
} catch (error) { } catch (error) {
definition = null definition = undefined
} }
const res = await fetchDatasourceSchema(dataSource) const res = await fetchDatasourceSchema(dataSource)
schema = res || {} schema = res || {}
@ -98,7 +104,7 @@
// Generates a predictable string that uniquely identifies a schema. We can't // Generates a predictable string that uniquely identifies a schema. We can't
// simply stringify the whole schema as there are array fields which have // simply stringify the whole schema as there are array fields which have
// random order. // random order.
const generateSchemaKey = schema => { const generateSchemaKey = (schema: TableSchema | undefined) => {
if (!schema) { if (!schema) {
return null return null
} }

View File

@ -1,31 +1,62 @@
<script> <script lang="ts">
import { setContext, getContext } from "svelte" import { setContext, getContext } from "svelte"
import type { Readable, Writable } from "svelte/store"
import { derived, get, writable } from "svelte/store" import { derived, get, writable } from "svelte/store"
import { createValidatorFromConstraints } from "./validation" import { createValidatorFromConstraints } from "./validation"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import type {
DataFetchDatasource,
FieldSchema,
FieldType,
Table,
TableSchema,
UIFieldValidationRule,
} from "@budibase/types"
export let dataSource = undefined type FieldInfo<T = any> = {
export let disabled = false name: string
export let readonly = false step: number
export let initialValues = undefined type: `${FieldType}`
export let size = undefined fieldState: {
export let schema = undefined fieldId: string
export let definition = undefined value: T
export let disableSchemaValidation = false defaultValue: T
export let editAutoColumns = false disabled: boolean
readonly: boolean
validator: ((_value: T) => string | null) | null
error: string | null | undefined
lastUpdate: number
}
fieldApi: {
setValue(_value: T): void
validate(): boolean
reset(): void
}
fieldSchema: FieldSchema | {}
}
export let dataSource: DataFetchDatasource | undefined = undefined
export let disabled: boolean = false
export let readonly: boolean = false
export let initialValues: Record<string, any> | undefined = undefined
export let size: "Medium" | "Large" | undefined = undefined
export let schema: TableSchema | undefined = undefined
export let definition: Table | undefined = undefined
export let disableSchemaValidation: boolean = false
export let editAutoColumns: boolean = false
// For internal use only, to disable context when being used with standalone // For internal use only, to disable context when being used with standalone
// fields // fields
export let provideContext = true export let provideContext: boolean = true
// We export this store so that when we remount the inner form we can still // We export this store so that when we remount the inner form we can still
// persist what step we're on // persist what step we're on
export let currentStep export let currentStep: Writable<number>
const component = getContext("component") const component = getContext("component")
const { styleable, Provider, ActionTypes } = getContext("sdk") const { styleable, Provider, ActionTypes } = getContext("sdk")
let fields = [] let fields: Writable<FieldInfo>[] = []
const formState = writable({ const formState = writable({
values: {}, values: {},
errors: {}, errors: {},
@ -75,19 +106,24 @@
// Generates a derived store from an array of fields, comprised of a map of // Generates a derived store from an array of fields, comprised of a map of
// extracted values from the field array // extracted values from the field array
const deriveFieldProperty = (fieldStores, getProp) => { const deriveFieldProperty = (
fieldStores: Readable<FieldInfo>[],
getProp: (_field: FieldInfo) => any
) => {
return derived(fieldStores, fieldValues => { return derived(fieldStores, fieldValues => {
const reducer = (map, field) => ({ ...map, [field.name]: getProp(field) }) return fieldValues.reduce(
return fieldValues.reduce(reducer, {}) (map, field) => ({ ...map, [field.name]: getProp(field) }),
{}
)
}) })
} }
// Derives any enrichments which need to be made so that bindings work for // Derives any enrichments which need to be made so that bindings work for
// special data types like attachments. Relationships are currently not // special data types like attachments. Relationships are currently not
// handled as we don't have the primaryDisplay field that is required. // handled as we don't have the primaryDisplay field that is required.
const deriveBindingEnrichments = fieldStores => { const deriveBindingEnrichments = (fieldStores: Readable<FieldInfo>[]) => {
return derived(fieldStores, fieldValues => { return derived(fieldStores, fieldValues => {
let enrichments = {} const enrichments: Record<string, string> = {}
fieldValues.forEach(field => { fieldValues.forEach(field => {
if (field.type === "attachment") { if (field.type === "attachment") {
const value = field.fieldState.value const value = field.fieldState.value
@ -104,7 +140,11 @@
// Derive the overall form value and deeply set all field paths so that we // Derive the overall form value and deeply set all field paths so that we
// can support things like JSON fields. // can support things like JSON fields.
const deriveFormValue = (initialValues, values, enrichments) => { const deriveFormValue = (
initialValues: Record<string, any> | undefined,
values: Record<string, any>,
enrichments: Record<string, string>
) => {
let formValue = Helpers.cloneDeep(initialValues || {}) let formValue = Helpers.cloneDeep(initialValues || {})
// We need to sort the keys to avoid a JSON field overwriting a nested field // We need to sort the keys to avoid a JSON field overwriting a nested field
@ -118,7 +158,7 @@
} }
}) })
.sort((a, b) => { .sort((a, b) => {
return a.lastUpdate > b.lastUpdate return a.lastUpdate - b.lastUpdate
}) })
// Merge all values and enrichments into a single value // Merge all values and enrichments into a single value
@ -132,12 +172,16 @@
} }
// Searches the field array for a certain field // Searches the field array for a certain field
const getField = name => { const getField = (name: string) => {
return fields.find(field => get(field).name === name) return fields.find(field => get(field).name === name)!
} }
// Sanitises a value by ensuring it doesn't contain any invalid data // Sanitises a value by ensuring it doesn't contain any invalid data
const sanitiseValue = (value, schema, type) => { const sanitiseValue = (
value: any,
schema: FieldSchema | undefined,
type: `${FieldType}`
) => {
// Check arrays - remove any values not present in the field schema and // Check arrays - remove any values not present in the field schema and
// convert any values supplied to strings // convert any values supplied to strings
if (Array.isArray(value) && type === "array" && schema) { if (Array.isArray(value) && type === "array" && schema) {
@ -149,13 +193,13 @@
const formApi = { const formApi = {
registerField: ( registerField: (
field, field: string,
type, type: FieldType,
defaultValue = null, defaultValue: string | null = null,
fieldDisabled = false, fieldDisabled: boolean = false,
fieldReadOnly = false, fieldReadOnly: boolean = false,
validationRules, validationRules: UIFieldValidationRule[],
step = 1 step: number = 1
) => { ) => {
if (!field) { if (!field) {
return return
@ -200,7 +244,7 @@
const isAutoColumn = !!schema?.[field]?.autocolumn const isAutoColumn = !!schema?.[field]?.autocolumn
// Construct field info // Construct field info
const fieldInfo = writable({ const fieldInfo = writable<FieldInfo>({
name: field, name: field,
type, type,
step: step || 1, step: step || 1,
@ -210,7 +254,8 @@
error: initialError, error: initialError,
disabled: disabled:
disabled || fieldDisabled || (isAutoColumn && !editAutoColumns), disabled || fieldDisabled || (isAutoColumn && !editAutoColumns),
readonly: readonly || fieldReadOnly || schema?.[field]?.readonly, readonly:
readonly || fieldReadOnly || (schema?.[field] as any)?.readonly,
defaultValue, defaultValue,
validator, validator,
lastUpdate: Date.now(), lastUpdate: Date.now(),
@ -254,7 +299,13 @@
get(field).fieldApi.reset() get(field).fieldApi.reset()
}) })
}, },
changeStep: ({ type, number }) => { changeStep: ({
type,
number,
}: {
type: "next" | "prev" | "first" | "specific"
number: any
}) => {
if (type === "next") { if (type === "next") {
currentStep.update(step => step + 1) currentStep.update(step => step + 1)
} else if (type === "prev") { } else if (type === "prev") {
@ -265,12 +316,12 @@
currentStep.set(parseInt(number)) currentStep.set(parseInt(number))
} }
}, },
setStep: step => { setStep: (step: number) => {
if (step) { if (step) {
currentStep.set(step) currentStep.set(step)
} }
}, },
setFieldValue: (fieldName, value) => { setFieldValue: (fieldName: string, value: any) => {
const field = getField(fieldName) const field = getField(fieldName)
if (!field) { if (!field) {
return return
@ -278,7 +329,7 @@
const { fieldApi } = get(field) const { fieldApi } = get(field)
fieldApi.setValue(value) fieldApi.setValue(value)
}, },
resetField: fieldName => { resetField: (fieldName: string) => {
const field = getField(fieldName) const field = getField(fieldName)
if (!field) { if (!field) {
return return
@ -289,9 +340,9 @@
} }
// Creates an API for a specific field // Creates an API for a specific field
const makeFieldApi = field => { const makeFieldApi = (field: string) => {
// Sets the value for a certain field and invokes validation // Sets the value for a certain field and invokes validation
const setValue = (value, skipCheck = false) => { const setValue = (value: any, skipCheck = false) => {
const fieldInfo = getField(field) const fieldInfo = getField(field)
const { fieldState } = get(fieldInfo) const { fieldState } = get(fieldInfo)
const { validator } = fieldState const { validator } = fieldState
@ -328,36 +379,6 @@
}) })
} }
// Updates the validator rules for a certain field
const updateValidation = validationRules => {
const fieldInfo = getField(field)
const { fieldState } = get(fieldInfo)
const { value, error } = fieldState
// Create new validator
const schemaConstraints = disableSchemaValidation
? null
: schema?.[field]?.constraints
const validator = createValidatorFromConstraints(
schemaConstraints,
validationRules,
field,
definition
)
// Update validator
fieldInfo.update(state => {
state.fieldState.validator = validator
return state
})
// If there is currently an error, run the validator again in case
// the error should be cleared by the new validation rules
if (error) {
setValue(value, true)
}
}
// We don't want to actually remove the field state when deregistering, just // We don't want to actually remove the field state when deregistering, just
// remove any errors and validation // remove any errors and validation
const deregister = () => { const deregister = () => {
@ -370,7 +391,7 @@
} }
// Updates the disabled state of a certain field // Updates the disabled state of a certain field
const setDisabled = fieldDisabled => { const setDisabled = (fieldDisabled: boolean) => {
const fieldInfo = getField(field) const fieldInfo = getField(field)
// Auto columns are always disabled // Auto columns are always disabled
@ -386,7 +407,6 @@
return { return {
setValue, setValue,
reset, reset,
updateValidation,
setDisabled, setDisabled,
deregister, deregister,
validate: () => { validate: () => {
@ -412,7 +432,15 @@
// register their fields to step 1 // register their fields to step 1
setContext("form-step", writable(1)) setContext("form-step", writable(1))
const handleUpdateFieldValue = ({ type, field, value }) => { const handleUpdateFieldValue = ({
type,
field,
value,
}: {
type: "set" | "reset"
field: string
value: any
}) => {
if (type === "set") { if (type === "set") {
formApi.setFieldValue(field, value) formApi.setFieldValue(field, value)
} else { } else {
@ -420,16 +448,19 @@
} }
} }
const handleScrollToField = ({ field }) => { const handleScrollToField = (props: { field: FieldInfo | string }) => {
if (!field.fieldState) { let field
field = get(getField(field)) if (typeof props.field === "string") {
field = get(getField(props.field))
} else {
field = props.field
} }
const fieldId = field.fieldState.fieldId const fieldId = field.fieldState.fieldId
const fieldElement = document.getElementById(fieldId) const fieldElement = document.getElementById(fieldId)
if (fieldElement) { if (fieldElement) {
fieldElement.focus({ preventScroll: true }) fieldElement.focus({ preventScroll: true })
} }
const label = document.querySelector(`label[for="${fieldId}"]`) const label = document.querySelector<HTMLElement>(`label[for="${fieldId}"]`)
if (label) { if (label) {
label.style.scrollMargin = "100px" label.style.scrollMargin = "100px"
label.scrollIntoView({ behavior: "smooth", block: "nearest" }) label.scrollIntoView({ behavior: "smooth", block: "nearest" })

View File

@ -1,6 +1,11 @@
import dayjs from "dayjs" import dayjs from "dayjs"
import { FieldTypes } from "../../../constants"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import {
FieldConstraints,
FieldType,
Table,
UIFieldValidationRule,
} from "@budibase/types"
/** /**
* Creates a validation function from a combination of schema-level constraints * Creates a validation function from a combination of schema-level constraints
@ -12,19 +17,19 @@ import { Helpers } from "@budibase/bbui"
* @returns {function} a validator function which accepts test values * @returns {function} a validator function which accepts test values
*/ */
export const createValidatorFromConstraints = ( export const createValidatorFromConstraints = (
schemaConstraints, schemaConstraints: FieldConstraints | null | undefined,
customRules, customRules: UIFieldValidationRule[],
field, field: string,
definition definition: Table | undefined
) => { ) => {
let rules = [] let rules: UIFieldValidationRule[] = []
// Convert schema constraints into validation rules // Convert schema constraints into validation rules
if (schemaConstraints) { if (schemaConstraints) {
// Required constraint // Required constraint
if ( if (
field === definition?.primaryDisplay || field === definition?.primaryDisplay ||
schemaConstraints.presence?.allowEmpty === false || (schemaConstraints.presence as any)?.allowEmpty === false ||
schemaConstraints.presence === true schemaConstraints.presence === true
) { ) {
rules.push({ rules.push({
@ -106,7 +111,7 @@ export const createValidatorFromConstraints = (
rules = rules.concat(customRules || []) rules = rules.concat(customRules || [])
// Evaluate each constraint // Evaluate each constraint
return value => { return (value: any) => {
for (let rule of rules) { for (let rule of rules) {
const error = evaluateRule(rule, value) const error = evaluateRule(rule, value)
if (error) { if (error) {
@ -124,7 +129,7 @@ export const createValidatorFromConstraints = (
* @param value the value to validate against * @param value the value to validate against
* @returns {null|*} an error if validation fails or null if it passes * @returns {null|*} an error if validation fails or null if it passes
*/ */
const evaluateRule = (rule, value) => { const evaluateRule = (rule: UIFieldValidationRule, value: any) => {
if (!rule) { if (!rule) {
return null return null
} }
@ -150,14 +155,14 @@ const evaluateRule = (rule, value) => {
* @param type the type to parse * @param type the type to parse
* @returns {boolean|string|*|number|null|array} the parsed value, or null if invalid * @returns {boolean|string|*|number|null|array} the parsed value, or null if invalid
*/ */
const parseType = (value, type) => { const parseType = (value: any, type: `${FieldType}`) => {
// Treat nulls or empty strings as null // Treat nulls or empty strings as null
if (!exists(value) || !type) { if (!exists(value) || !type) {
return null return null
} }
// Parse as string // Parse as string
if (type === FieldTypes.STRING) { if (type === FieldType.STRING) {
if (typeof value === "string" || Array.isArray(value)) { if (typeof value === "string" || Array.isArray(value)) {
return value return value
} }
@ -168,7 +173,7 @@ const parseType = (value, type) => {
} }
// Parse as number // Parse as number
if (type === FieldTypes.NUMBER) { if (type === FieldType.NUMBER) {
if (isNaN(value)) { if (isNaN(value)) {
return null return null
} }
@ -176,7 +181,7 @@ const parseType = (value, type) => {
} }
// Parse as date // Parse as date
if (type === FieldTypes.DATETIME) { if (type === FieldType.DATETIME) {
if (value instanceof Date) { if (value instanceof Date) {
return value.getTime() return value.getTime()
} }
@ -185,7 +190,7 @@ const parseType = (value, type) => {
} }
// Parse as boolean // Parse as boolean
if (type === FieldTypes.BOOLEAN) { if (type === FieldType.BOOLEAN) {
if (typeof value === "string") { if (typeof value === "string") {
return value.toLowerCase() === "true" return value.toLowerCase() === "true"
} }
@ -193,7 +198,7 @@ const parseType = (value, type) => {
} }
// Parse attachments, treating no elements as null // Parse attachments, treating no elements as null
if (type === FieldTypes.ATTACHMENTS) { if (type === FieldType.ATTACHMENTS) {
if (!Array.isArray(value) || !value.length) { if (!Array.isArray(value) || !value.length) {
return null return null
} }
@ -202,8 +207,8 @@ const parseType = (value, type) => {
// Parse attachment/signature single, treating no key as null // Parse attachment/signature single, treating no key as null
if ( if (
type === FieldTypes.ATTACHMENT_SINGLE || type === FieldType.ATTACHMENT_SINGLE ||
type === FieldTypes.SIGNATURE_SINGLE type === FieldType.SIGNATURE_SINGLE
) { ) {
if (!value?.key) { if (!value?.key) {
return null return null
@ -212,7 +217,7 @@ const parseType = (value, type) => {
} }
// Parse links, treating no elements as null // Parse links, treating no elements as null
if (type === FieldTypes.LINK) { if (type === FieldType.LINK) {
if (!Array.isArray(value) || !value.length) { if (!Array.isArray(value) || !value.length) {
return null return null
} }
@ -220,7 +225,7 @@ const parseType = (value, type) => {
} }
// Parse array, treating no elements as null // Parse array, treating no elements as null
if (type === FieldTypes.ARRAY) { if (type === FieldType.ARRAY) {
if (!Array.isArray(value) || !value.length) { if (!Array.isArray(value) || !value.length) {
return null return null
} }
@ -229,7 +234,7 @@ const parseType = (value, type) => {
// For JSON we don't touch the value at all as we want to verify it in its // For JSON we don't touch the value at all as we want to verify it in its
// raw form // raw form
if (type === FieldTypes.JSON) { if (type === FieldType.JSON) {
return value return value
} }
@ -238,69 +243,74 @@ const parseType = (value, type) => {
} }
// Evaluates a required constraint // Evaluates a required constraint
const requiredHandler = value => { const requiredHandler = (value: any) => {
return value != null return value != null
} }
// Evaluates a min length constraint // Evaluates a min length constraint
const minLengthHandler = (value, rule) => { const minLengthHandler = (value: any, rule: UIFieldValidationRule) => {
const limit = parseType(rule.value, "number") const limit = parseType(rule.value, "number")
return value == null || value.length >= limit return value == null || value.length >= limit
} }
// Evaluates a max length constraint // Evaluates a max length constraint
const maxLengthHandler = (value, rule) => { const maxLengthHandler = (value: any, rule: UIFieldValidationRule) => {
const limit = parseType(rule.value, "number") const limit = parseType(rule.value, "number")
return value == null || value.length <= limit return value == null || value.length <= limit
} }
// Evaluates a max file size (MB) constraint // Evaluates a max file size (MB) constraint
const maxFileSizeHandler = (value, rule) => { const maxFileSizeHandler = (value: any, rule: UIFieldValidationRule) => {
const limit = parseType(rule.value, "number") const limit = parseType(rule.value, "number")
const check = attachment => attachment.size / 1000000 > limit const check = (attachment: { size: number }) =>
attachment.size / 1000000 > limit
return value == null || !(value?.key ? check(value) : value.some(check)) return value == null || !(value?.key ? check(value) : value.some(check))
} }
// Evaluates a max total upload size (MB) constraint // Evaluates a max total upload size (MB) constraint
const maxUploadSizeHandler = (value, rule) => { const maxUploadSizeHandler = (value: any, rule: UIFieldValidationRule) => {
const limit = parseType(rule.value, "number") const limit: number = parseType(rule.value, "number")
return ( return (
value == null || value == null ||
(value?.key (value?.key
? value.size / 1000000 <= limit ? value.size / 1000000 <= limit
: value.reduce((acc, currentItem) => acc + currentItem.size, 0) / : value.reduce(
(acc: number, currentItem: { size: number }) =>
acc + currentItem.size,
0
) /
1000000 <= 1000000 <=
limit) limit)
) )
} }
// Evaluates a min value constraint // Evaluates a min value constraint
const minValueHandler = (value, rule) => { const minValueHandler = (value: any, rule: UIFieldValidationRule) => {
// Use same type as the value so that things can be compared // Use same type as the value so that things can be compared
const limit = parseType(rule.value, rule.type) const limit = parseType(rule.value, rule.type)
return value == null || value >= limit return value == null || value >= limit
} }
// Evaluates a max value constraint // Evaluates a max value constraint
const maxValueHandler = (value, rule) => { const maxValueHandler = (value: any, rule: UIFieldValidationRule) => {
// Use same type as the value so that things can be compared // Use same type as the value so that things can be compared
const limit = parseType(rule.value, rule.type) const limit = parseType(rule.value, rule.type)
return value == null || value <= limit return value == null || value <= limit
} }
// Evaluates an inclusion constraint // Evaluates an inclusion constraint
const inclusionHandler = (value, rule) => { const inclusionHandler = (value: any, rule: UIFieldValidationRule) => {
return value == null || rule.value.includes(value) return value == null || (rule.value as any).includes(value)
} }
// Evaluates an equal constraint // Evaluates an equal constraint
const equalHandler = (value, rule) => { const equalHandler = (value: any, rule: UIFieldValidationRule) => {
const ruleValue = parseType(rule.value, rule.type) const ruleValue = parseType(rule.value, rule.type)
return value === ruleValue return value === ruleValue
} }
// Evaluates a not equal constraint // Evaluates a not equal constraint
const notEqualHandler = (value, rule) => { const notEqualHandler = (value: any, rule: UIFieldValidationRule) => {
const ruleValue = parseType(rule.value, rule.type) const ruleValue = parseType(rule.value, rule.type)
if (value == null && ruleValue == null) { if (value == null && ruleValue == null) {
return true return true
@ -309,7 +319,7 @@ const notEqualHandler = (value, rule) => {
} }
// Evaluates a regex constraint // Evaluates a regex constraint
const regexHandler = (value, rule) => { const regexHandler = (value: any, rule: UIFieldValidationRule) => {
const regex = parseType(rule.value, "string") const regex = parseType(rule.value, "string")
if (!value) { if (!value) {
value = "" value = ""
@ -318,23 +328,23 @@ const regexHandler = (value, rule) => {
} }
// Evaluates a not regex constraint // Evaluates a not regex constraint
const notRegexHandler = (value, rule) => { const notRegexHandler = (value: any, rule: UIFieldValidationRule) => {
return !regexHandler(value, rule) return !regexHandler(value, rule)
} }
// Evaluates a contains constraint // Evaluates a contains constraint
const containsHandler = (value, rule) => { const containsHandler = (value: any, rule: UIFieldValidationRule) => {
const expectedValue = parseType(rule.value, "string") const expectedValue = parseType(rule.value, "string")
return value && value.includes(expectedValue) return value && value.includes(expectedValue)
} }
// Evaluates a not contains constraint // Evaluates a not contains constraint
const notContainsHandler = (value, rule) => { const notContainsHandler = (value: any, rule: UIFieldValidationRule) => {
return !containsHandler(value, rule) return !containsHandler(value, rule)
} }
// Evaluates a constraint that the value must be a valid json object // Evaluates a constraint that the value must be a valid json object
const jsonHandler = value => { const jsonHandler = (value: any) => {
if (typeof value !== "object" || Array.isArray(value)) { if (typeof value !== "object" || Array.isArray(value)) {
return false return false
} }
@ -372,6 +382,6 @@ const handlerMap = {
* @param value the value to test * @param value the value to test
* @returns {boolean} whether the value exists or not * @returns {boolean} whether the value exists or not
*/ */
const exists = value => { const exists = <T = any>(value: T | null | undefined): value is T => {
return value != null && value !== "" return value != null && value !== ""
} }

View File

@ -1,12 +1,12 @@
import { Writable } from "svelte" import { Writable } from "svelte"
import { Component, FieldGroupContext, FormContext } from "@/types" import { Component, Context, FieldGroupContext, FormContext } from "@/types"
import { Readable } from "svelte/store"
import { SDK } from "@/index.ts" import { SDK } from "@/index.ts"
declare module "svelte" { declare module "svelte" {
export function getContext(key: "sdk"): SDK export function getContext(key: "sdk"): SDK
export function getContext(key: "component"): Component export function getContext(key: "component"): Component
export function getContext(key: "context"): Readable<Record<string, any>> export function getContext(key: "current-step"): Writable<number>
export function getContext(key: "context"): Context
export function getContext(key: "form"): FormContext | undefined export function getContext(key: "form"): FormContext | undefined
export function getContext(key: "form-step"): Writable<number> | undefined export function getContext(key: "form-step"): Writable<number> | undefined
export function getContext(key: "field-group"): FieldGroupContext | undefined export function getContext(key: "field-group"): FieldGroupContext | undefined

View File

@ -16,7 +16,6 @@ import {
} from "@/stores" } from "@/stores"
import { get } from "svelte/store" import { get } from "svelte/store"
import { initWebsocket } from "@/websocket" import { initWebsocket } from "@/websocket"
import { Readable } from "svelte/store"
import { import {
Screen, Screen,
Theme, Theme,
@ -27,6 +26,8 @@ import {
Snippet, Snippet,
UIComponentError, UIComponentError,
CustomComponent, CustomComponent,
Table,
DataFetchDatasource,
} from "@budibase/types" } from "@budibase/types"
import { ActionTypes } from "@/constants" import { ActionTypes } from "@/constants"
import { APIClient } from "@budibase/frontend-core" import { APIClient } from "@budibase/frontend-core"
@ -75,14 +76,13 @@ declare global {
} }
} }
export type Context = Readable<Record<string, any>>
export interface SDK { export interface SDK {
API: APIClient API: APIClient
styleable: any styleable: any
Provider: any Provider: any
ActionTypes: typeof ActionTypes ActionTypes: typeof ActionTypes
fetchDatasourceSchema: any fetchDatasourceSchema: any
fetchDatasourceDefinition: (datasource: DataFetchDatasource) => Promise<Table>
generateGoldenSample: any generateGoldenSample: any
builderStore: typeof builderStore builderStore: typeof builderStore
authStore: typeof authStore authStore: typeof authStore

View File

@ -6,4 +6,5 @@ export type Component = Readable<{
styles: any styles: any
editing: boolean editing: boolean
errorState: boolean errorState: boolean
path: string[]
}> }>

View File

@ -1,3 +1,7 @@
import { Readable } from "svelte/store"
export * from "./components" export * from "./components"
export * from "./fields" export * from "./fields"
export * from "./forms" export * from "./forms"
export type Context = Readable<Record<string, any>>

View File

@ -1,7 +1,9 @@
import { import {
FetchOAuth2ConfigsResponse, FetchOAuth2ConfigsResponse,
UpsertOAuth2ConfigRequest, InsertOAuth2ConfigRequest,
UpsertOAuth2ConfigResponse, InsertOAuth2ConfigResponse,
UpdateOAuth2ConfigRequest,
UpdateOAuth2ConfigResponse,
ValidateConfigRequest, ValidateConfigRequest,
ValidateConfigResponse, ValidateConfigResponse,
} from "@budibase/types" } from "@budibase/types"
@ -10,13 +12,12 @@ import { BaseAPIClient } from "./types"
export interface OAuth2Endpoints { export interface OAuth2Endpoints {
fetch: () => Promise<FetchOAuth2ConfigsResponse["configs"]> fetch: () => Promise<FetchOAuth2ConfigsResponse["configs"]>
create: ( create: (
config: UpsertOAuth2ConfigRequest config: InsertOAuth2ConfigRequest
) => Promise<UpsertOAuth2ConfigResponse> ) => Promise<InsertOAuth2ConfigResponse>
update: ( update: (
id: string, config: UpdateOAuth2ConfigRequest
config: UpsertOAuth2ConfigRequest ) => Promise<UpdateOAuth2ConfigResponse>
) => Promise<UpsertOAuth2ConfigResponse> delete: (id: string, rev: string) => Promise<void>
delete: (id: string) => Promise<void>
validate: (config: ValidateConfigRequest) => Promise<ValidateConfigResponse> validate: (config: ValidateConfigRequest) => Promise<ValidateConfigResponse>
} }
@ -37,8 +38,8 @@ export const buildOAuth2Endpoints = (API: BaseAPIClient): OAuth2Endpoints => ({
*/ */
create: async config => { create: async config => {
return await API.post< return await API.post<
UpsertOAuth2ConfigRequest, InsertOAuth2ConfigRequest,
UpsertOAuth2ConfigResponse InsertOAuth2ConfigResponse
>({ >({
url: `/api/oauth2`, url: `/api/oauth2`,
body: { body: {
@ -50,10 +51,10 @@ export const buildOAuth2Endpoints = (API: BaseAPIClient): OAuth2Endpoints => ({
/** /**
* Updates an existing OAuth2 configuration. * Updates an existing OAuth2 configuration.
*/ */
update: async (id, config) => { update: async config => {
return await API.put<UpsertOAuth2ConfigRequest, UpsertOAuth2ConfigResponse>( return await API.put<UpdateOAuth2ConfigRequest, UpdateOAuth2ConfigResponse>(
{ {
url: `/api/oauth2/${id}`, url: `/api/oauth2/${config._id}`,
body: { body: {
...config, ...config,
}, },
@ -64,10 +65,11 @@ export const buildOAuth2Endpoints = (API: BaseAPIClient): OAuth2Endpoints => ({
/** /**
* Deletes an OAuth2 configuration by its id. * Deletes an OAuth2 configuration by its id.
* @param id the ID of the OAuth2 config * @param id the ID of the OAuth2 config
* @param rev the rev of the OAuth2 config
*/ */
delete: async id => { delete: async (id, rev) => {
return await API.delete<void, void>({ return await API.delete<void, void>({
url: `/api/oauth2/${id}`, url: `/api/oauth2/${id}/${rev}`,
}) })
}, },
validate: async function ( validate: async function (

View File

@ -1,14 +1,15 @@
import { import {
UpsertOAuth2ConfigRequest,
UpsertOAuth2ConfigResponse,
Ctx, Ctx,
FetchOAuth2ConfigsResponse, FetchOAuth2ConfigsResponse,
OAuth2Config, OAuth2Config,
RequiredKeys,
OAuth2ConfigResponse, OAuth2ConfigResponse,
PASSWORD_REPLACEMENT, PASSWORD_REPLACEMENT,
ValidateConfigResponse, ValidateConfigResponse,
ValidateConfigRequest, ValidateConfigRequest,
InsertOAuth2ConfigRequest,
InsertOAuth2ConfigResponse,
UpdateOAuth2ConfigRequest,
UpdateOAuth2ConfigResponse,
} from "@budibase/types" } from "@budibase/types"
import sdk from "../../sdk" import sdk from "../../sdk"
@ -16,7 +17,8 @@ function toFetchOAuth2ConfigsResponse(
config: OAuth2Config config: OAuth2Config
): OAuth2ConfigResponse { ): OAuth2ConfigResponse {
return { return {
id: config.id, _id: config._id!,
_rev: config._rev!,
name: config.name, name: config.name,
url: config.url, url: config.url,
clientId: config.clientId, clientId: config.clientId,
@ -43,10 +45,10 @@ export async function fetch(ctx: Ctx<void, FetchOAuth2ConfigsResponse>) {
} }
export async function create( export async function create(
ctx: Ctx<UpsertOAuth2ConfigRequest, UpsertOAuth2ConfigResponse> ctx: Ctx<InsertOAuth2ConfigRequest, InsertOAuth2ConfigResponse>
) { ) {
const { body } = ctx.request const { body } = ctx.request
const newConfig: RequiredKeys<Omit<OAuth2Config, "id">> = { const newConfig = {
name: body.name, name: body.name,
url: body.url, url: body.url,
clientId: body.clientId, clientId: body.clientId,
@ -62,11 +64,17 @@ export async function create(
} }
export async function edit( export async function edit(
ctx: Ctx<UpsertOAuth2ConfigRequest, UpsertOAuth2ConfigResponse> ctx: Ctx<UpdateOAuth2ConfigRequest, UpdateOAuth2ConfigResponse>
) { ) {
const { body } = ctx.request const { body } = ctx.request
const toUpdate: RequiredKeys<OAuth2Config> = {
id: ctx.params.id, if (ctx.params.id !== body._id) {
ctx.throw("Path and body ids do not match", 400)
}
const toUpdate = {
_id: body._id,
_rev: body._rev,
name: body.name, name: body.name,
url: body.url, url: body.url,
clientId: body.clientId, clientId: body.clientId,
@ -80,12 +88,10 @@ export async function edit(
} }
} }
export async function remove( export async function remove(ctx: Ctx<void, void>) {
ctx: Ctx<UpsertOAuth2ConfigRequest, UpsertOAuth2ConfigResponse> const { id, rev } = ctx.params
) {
const configToRemove = ctx.params.id
await sdk.oauth2.remove(configToRemove) await sdk.oauth2.remove(id, rev)
ctx.status = 204 ctx.status = 204
} }
@ -100,10 +106,10 @@ export async function validate(
method: body.method, method: body.method,
} }
if (config.clientSecret === PASSWORD_REPLACEMENT && body.id) { if (config.clientSecret === PASSWORD_REPLACEMENT && body._id) {
const existingConfig = await sdk.oauth2.get(body.id) const existingConfig = await sdk.oauth2.get(body._id)
if (!existingConfig) { if (!existingConfig) {
ctx.throw(`OAuth2 config with id '${body.id}' not found.`, 404) ctx.throw(`OAuth2 config with id '${body._id}' not found.`, 404)
} }
config.clientSecret = existingConfig.clientSecret config.clientSecret = existingConfig.clientSecret

View File

@ -6,7 +6,7 @@ import authorized from "../../middleware/authorized"
import * as controller from "../controllers/oauth2" import * as controller from "../controllers/oauth2"
import Joi from "joi" import Joi from "joi"
const baseValidation = { const baseSchema = {
url: Joi.string().required(), url: Joi.string().required(),
clientId: Joi.string().required(), clientId: Joi.string().required(),
clientSecret: Joi.string().required(), clientSecret: Joi.string().required(),
@ -15,24 +15,27 @@ const baseValidation = {
.valid(...Object.values(OAuth2CredentialsMethod)), .valid(...Object.values(OAuth2CredentialsMethod)),
} }
function oAuth2ConfigValidator() { const insertSchema = Joi.object({
return middleware.joiValidator.body( name: Joi.string().required(),
Joi.object({ ...baseSchema,
name: Joi.string().required(), })
...baseValidation,
}),
{ allowUnknown: false }
)
}
function oAuth2ConfigValidationValidator() { const updateSchema = Joi.object({
return middleware.joiValidator.body( _id: Joi.string().required(),
Joi.object({ _rev: Joi.string().required(),
id: Joi.string(), name: Joi.string().required(),
...baseValidation, ...baseSchema,
}), })
{ allowUnknown: false }
) const validationSchema = Joi.object({
_id: Joi.string(),
...baseSchema,
})
function oAuth2ConfigValidator(
schema: typeof validationSchema | typeof insertSchema | typeof updateSchema
) {
return middleware.joiValidator.body(schema, { allowUnknown: false })
} }
const router: Router = new Router() const router: Router = new Router()
@ -41,24 +44,24 @@ router.get("/api/oauth2", authorized(PermissionType.BUILDER), controller.fetch)
router.post( router.post(
"/api/oauth2", "/api/oauth2",
authorized(PermissionType.BUILDER), authorized(PermissionType.BUILDER),
oAuth2ConfigValidator(), oAuth2ConfigValidator(insertSchema),
controller.create controller.create
) )
router.put( router.put(
"/api/oauth2/:id", "/api/oauth2/:id",
authorized(PermissionType.BUILDER), authorized(PermissionType.BUILDER),
oAuth2ConfigValidator(), oAuth2ConfigValidator(updateSchema),
controller.edit controller.edit
) )
router.delete( router.delete(
"/api/oauth2/:id", "/api/oauth2/:id/:rev",
authorized(PermissionType.BUILDER), authorized(PermissionType.BUILDER),
controller.remove controller.remove
) )
router.post( router.post(
"/api/oauth2/validate", "/api/oauth2/validate",
authorized(PermissionType.BUILDER), authorized(PermissionType.BUILDER),
oAuth2ConfigValidationValidator(), oAuth2ConfigValidator(validationSchema),
controller.validate controller.validate
) )

View File

@ -1,9 +1,9 @@
import { import {
OAuth2Config, DocumentType,
InsertOAuth2ConfigRequest,
OAuth2ConfigResponse,
OAuth2CredentialsMethod, OAuth2CredentialsMethod,
PASSWORD_REPLACEMENT, PASSWORD_REPLACEMENT,
UpsertOAuth2ConfigRequest,
VirtualDocumentType,
} from "@budibase/types" } from "@budibase/types"
import * as setup from "./utilities" import * as setup from "./utilities"
import { generator } from "@budibase/backend-core/tests" import { generator } from "@budibase/backend-core/tests"
@ -12,7 +12,7 @@ import _ from "lodash/fp"
describe("/oauth2", () => { describe("/oauth2", () => {
let config = setup.getConfig() let config = setup.getConfig()
function makeOAuth2Config(): UpsertOAuth2ConfigRequest { function makeOAuth2Config(): InsertOAuth2ConfigRequest {
return { return {
name: generator.guid(), name: generator.guid(),
url: generator.url(), url: generator.url(),
@ -27,7 +27,7 @@ describe("/oauth2", () => {
beforeEach(async () => await config.newTenant()) beforeEach(async () => await config.newTenant())
const expectOAuth2ConfigId = expect.stringMatching( const expectOAuth2ConfigId = expect.stringMatching(
`^${VirtualDocumentType.OAUTH2_CONFIG}_.+$` `^${DocumentType.OAUTH2_CONFIG}_.+$`
) )
describe("fetch", () => { describe("fetch", () => {
@ -43,7 +43,7 @@ describe("/oauth2", () => {
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const oauth2Config = makeOAuth2Config() const oauth2Config = makeOAuth2Config()
const result = await config.api.oauth2.create(oauth2Config) const result = await config.api.oauth2.create(oauth2Config)
existingConfigs.push({ ...oauth2Config, id: result.config.id }) existingConfigs.push(result.config)
} }
const response = await config.api.oauth2.fetch() const response = await config.api.oauth2.fetch()
@ -51,7 +51,8 @@ describe("/oauth2", () => {
expect(response).toEqual({ expect(response).toEqual({
configs: expect.arrayContaining( configs: expect.arrayContaining(
existingConfigs.map(c => ({ existingConfigs.map(c => ({
id: c.id, _id: c._id,
_rev: c._rev,
name: c.name, name: c.name,
url: c.url, url: c.url,
clientId: c.clientId, clientId: c.clientId,
@ -72,7 +73,8 @@ describe("/oauth2", () => {
expect(response).toEqual({ expect(response).toEqual({
configs: [ configs: [
{ {
id: expectOAuth2ConfigId, _id: expectOAuth2ConfigId,
_rev: expect.stringMatching(/^1-\w+/),
name: oauth2Config.name, name: oauth2Config.name,
url: oauth2Config.url, url: oauth2Config.url,
clientId: oauth2Config.clientId, clientId: oauth2Config.clientId,
@ -90,25 +92,29 @@ describe("/oauth2", () => {
await config.api.oauth2.create(oauth2Config2, { status: 201 }) await config.api.oauth2.create(oauth2Config2, { status: 201 })
const response = await config.api.oauth2.fetch() const response = await config.api.oauth2.fetch()
expect(response.configs).toEqual([ expect(response.configs).toEqual(
{ expect.arrayContaining([
id: expectOAuth2ConfigId, {
name: oauth2Config.name, _id: expectOAuth2ConfigId,
url: oauth2Config.url, _rev: expect.stringMatching(/^1-\w+/),
clientId: oauth2Config.clientId, name: oauth2Config.name,
clientSecret: PASSWORD_REPLACEMENT, url: oauth2Config.url,
method: oauth2Config.method, clientId: oauth2Config.clientId,
}, clientSecret: PASSWORD_REPLACEMENT,
{ method: oauth2Config.method,
id: expectOAuth2ConfigId, },
name: oauth2Config2.name, {
url: oauth2Config2.url, _id: expectOAuth2ConfigId,
clientId: oauth2Config2.clientId, _rev: expect.stringMatching(/^1-\w+/),
clientSecret: PASSWORD_REPLACEMENT, name: oauth2Config2.name,
method: oauth2Config2.method, url: oauth2Config2.url,
}, clientId: oauth2Config2.clientId,
]) clientSecret: PASSWORD_REPLACEMENT,
expect(response.configs[0].id).not.toEqual(response.configs[1].id) method: oauth2Config2.method,
},
])
)
expect(response.configs[0]._id).not.toEqual(response.configs[1]._id)
}) })
it("cannot create configurations with already existing names", async () => { it("cannot create configurations with already existing names", async () => {
@ -118,7 +124,7 @@ describe("/oauth2", () => {
await config.api.oauth2.create(oauth2Config2, { await config.api.oauth2.create(oauth2Config2, {
status: 400, status: 400,
body: { body: {
message: "Name already used", message: `OAuth2 config with name '${oauth2Config.name}' is already taken.`,
status: 400, status: 400,
}, },
}) })
@ -126,7 +132,8 @@ describe("/oauth2", () => {
const response = await config.api.oauth2.fetch() const response = await config.api.oauth2.fetch()
expect(response.configs).toEqual([ expect(response.configs).toEqual([
{ {
id: expectOAuth2ConfigId, _id: expectOAuth2ConfigId,
_rev: expect.stringMatching(/^1-\w+/),
name: oauth2Config.name, name: oauth2Config.name,
url: oauth2Config.url, url: oauth2Config.url,
clientId: oauth2Config.clientId, clientId: oauth2Config.clientId,
@ -138,7 +145,7 @@ describe("/oauth2", () => {
}) })
describe("update", () => { describe("update", () => {
let existingConfigs: OAuth2Config[] = [] let existingConfigs: OAuth2ConfigResponse[] = []
beforeEach(async () => { beforeEach(async () => {
existingConfigs = [] existingConfigs = []
@ -146,14 +153,14 @@ describe("/oauth2", () => {
const oauth2Config = makeOAuth2Config() const oauth2Config = makeOAuth2Config()
const result = await config.api.oauth2.create(oauth2Config) const result = await config.api.oauth2.create(oauth2Config)
existingConfigs.push({ ...oauth2Config, id: result.config.id }) existingConfigs.push(result.config)
} }
}) })
it("can update an existing configuration", async () => { it("can update an existing configuration", async () => {
const { id: configId, ...configData } = _.sample(existingConfigs)! const configData = _.sample(existingConfigs)!
await config.api.oauth2.update(configId, { await config.api.oauth2.update({
...configData, ...configData,
name: "updated name", name: "updated name",
}) })
@ -163,7 +170,8 @@ describe("/oauth2", () => {
expect(response.configs).toEqual( expect(response.configs).toEqual(
expect.arrayContaining([ expect.arrayContaining([
{ {
id: configId, _id: configData._id,
_rev: expect.not.stringMatching(configData._rev),
name: "updated name", name: "updated name",
url: configData.url, url: configData.url,
clientId: configData.clientId, clientId: configData.clientId,
@ -175,7 +183,12 @@ describe("/oauth2", () => {
}) })
it("throw if config not found", async () => { it("throw if config not found", async () => {
await config.api.oauth2.update("unexisting", makeOAuth2Config(), { const toUpdate = {
...makeOAuth2Config(),
_id: "unexisting",
_rev: "unexisting",
}
await config.api.oauth2.update(toUpdate, {
status: 404, status: 404,
body: { message: "OAuth2 config with id 'unexisting' not found." }, body: { message: "OAuth2 config with id 'unexisting' not found." },
}) })
@ -183,12 +196,10 @@ describe("/oauth2", () => {
it("throws if trying to use an existing name", async () => { it("throws if trying to use an existing name", async () => {
const [config1, config2] = _.sampleSize(2, existingConfigs) const [config1, config2] = _.sampleSize(2, existingConfigs)
const { id: configId, ...configData } = config1
await config.api.oauth2.update( await config.api.oauth2.update(
configId,
{ {
...configData, ...config1,
name: config2.name, name: config2.name,
}, },
{ {
@ -202,7 +213,7 @@ describe("/oauth2", () => {
}) })
describe("delete", () => { describe("delete", () => {
let existingConfigs: OAuth2Config[] = [] let existingConfigs: OAuth2ConfigResponse[] = []
beforeEach(async () => { beforeEach(async () => {
existingConfigs = [] existingConfigs = []
@ -210,22 +221,26 @@ describe("/oauth2", () => {
const oauth2Config = makeOAuth2Config() const oauth2Config = makeOAuth2Config()
const result = await config.api.oauth2.create(oauth2Config) const result = await config.api.oauth2.create(oauth2Config)
existingConfigs.push({ ...oauth2Config, id: result.config.id }) existingConfigs.push(result.config)
} }
}) })
it("can delete an existing configuration", async () => { it("can delete an existing configuration", async () => {
const { id: configId } = _.sample(existingConfigs)! const configToDelete = _.sample(existingConfigs)!
await config.api.oauth2.delete(configId, { status: 204 }) await config.api.oauth2.delete(configToDelete._id, configToDelete._rev, {
status: 204,
})
const response = await config.api.oauth2.fetch() const response = await config.api.oauth2.fetch()
expect(response.configs).toHaveLength(existingConfigs.length - 1) expect(response.configs).toHaveLength(existingConfigs.length - 1)
expect(response.configs.find(c => c.id === configId)).toBeUndefined() expect(
response.configs.find(c => c._id === configToDelete._id)
).toBeUndefined()
}) })
it("throw if config not found", async () => { it("throw if config not found", async () => {
await config.api.oauth2.delete("unexisting", { await config.api.oauth2.delete("unexisting", "rev", {
status: 404, status: 404,
body: { message: "OAuth2 config with id 'unexisting' not found." }, body: { message: "OAuth2 config with id 'unexisting' not found." },
}) })

View File

@ -307,7 +307,7 @@ describe("REST Integration", () => {
config.appId, config.appId,
async () => async () =>
await integration.read({ await integration.read({
authConfigId: oauthConfig.id, authConfigId: oauthConfig._id,
authConfigType: RestAuthType.OAUTH2, authConfigType: RestAuthType.OAUTH2,
}) })
) )
@ -349,7 +349,7 @@ describe("REST Integration", () => {
config.appId, config.appId,
async () => async () =>
await integration.read({ await integration.read({
authConfigId: oauthConfig.id, authConfigId: oauthConfig._id,
authConfigType: RestAuthType.OAUTH2, authConfigType: RestAuthType.OAUTH2,
}) })
) )

View File

@ -1,104 +1,95 @@
import { context, docIds, HTTPError, utils } from "@budibase/backend-core" import { context, docIds, HTTPError, utils } from "@budibase/backend-core"
import { import {
Database,
DocumentType, DocumentType,
OAuth2Config, OAuth2Config,
OAuth2Configs,
PASSWORD_REPLACEMENT, PASSWORD_REPLACEMENT,
SEPARATOR, SEPARATOR,
VirtualDocumentType, WithRequired,
} from "@budibase/types" } from "@budibase/types"
async function getDocument(db: Database = context.getAppDB()) { type CreatedOAuthConfig = WithRequired<OAuth2Config, "_id" | "_rev">
const result = await db.tryGet<OAuth2Configs>(DocumentType.OAUTH2_CONFIG)
return result
}
export async function fetch(): Promise<OAuth2Config[]> { async function guardName(name: string, id?: string) {
const result = await getDocument() const existingConfigs = await fetch()
if (!result) {
return []
}
return Object.values(result.configs)
}
export async function create( if (existingConfigs.find(c => c.name === name && c._id !== id)) {
config: Omit<OAuth2Config, "id">
): Promise<OAuth2Config> {
const db = context.getAppDB()
const doc: OAuth2Configs = (await getDocument(db)) ?? {
_id: DocumentType.OAUTH2_CONFIG,
configs: {},
}
if (Object.values(doc.configs).find(c => c.name === config.name)) {
throw new HTTPError("Name already used", 400)
}
const id = `${VirtualDocumentType.OAUTH2_CONFIG}${SEPARATOR}${utils.newid()}`
doc.configs[id] = {
id,
...config,
}
await db.put(doc)
return doc.configs[id]
}
export async function get(id: string): Promise<OAuth2Config | undefined> {
const doc = await getDocument()
return doc?.configs?.[id]
}
export async function update(config: OAuth2Config): Promise<OAuth2Config> {
const db = context.getAppDB()
const doc: OAuth2Configs = (await getDocument(db)) ?? {
_id: DocumentType.OAUTH2_CONFIG,
configs: {},
}
if (!doc.configs[config.id]) {
throw new HTTPError(`OAuth2 config with id '${config.id}' not found.`, 404)
}
if (
Object.values(doc.configs).find(
c => c.name === config.name && c.id !== config.id
)
) {
throw new HTTPError( throw new HTTPError(
`OAuth2 config with name '${config.name}' is already taken.`, `OAuth2 config with name '${name}' is already taken.`,
400 400
) )
} }
}
doc.configs[config.id] = { export async function fetch(): Promise<CreatedOAuthConfig[]> {
const db = context.getAppDB()
const docs = await db.allDocs<OAuth2Config>(
docIds.getOAuth2ConfigParams(null, { include_docs: true })
)
const result = docs.rows.map(r => ({
...r.doc!,
_id: r.doc!._id!,
_rev: r.doc!._rev!,
}))
return result
}
export async function create(
config: Omit<OAuth2Config, "_id" | "_rev" | "createdAt" | "updatedAt">
): Promise<CreatedOAuthConfig> {
const db = context.getAppDB()
await guardName(config.name)
const response = await db.put({
_id: `${DocumentType.OAUTH2_CONFIG}${SEPARATOR}${utils.newid()}`,
...config,
})
return {
_id: response.id!,
_rev: response.rev!,
...config,
}
}
export async function get(id: string): Promise<OAuth2Config | undefined> {
const db = context.getAppDB()
return await db.tryGet(id)
}
export async function update(
config: CreatedOAuthConfig
): Promise<CreatedOAuthConfig> {
const db = context.getAppDB()
await guardName(config.name, config._id)
const existing = await get(config._id)
if (!existing) {
throw new HTTPError(`OAuth2 config with id '${config._id}' not found.`, 404)
}
const toUpdate = {
...config, ...config,
clientSecret: clientSecret:
config.clientSecret === PASSWORD_REPLACEMENT config.clientSecret === PASSWORD_REPLACEMENT
? doc.configs[config.id].clientSecret ? existing.clientSecret
: config.clientSecret, : config.clientSecret,
} }
await db.put(doc) const result = await db.put(toUpdate)
return doc.configs[config.id] return { ...toUpdate, _rev: result.rev }
} }
export async function remove(configId: string): Promise<void> { export async function remove(configId: string, _rev: string): Promise<void> {
const db = context.getAppDB() const db = context.getAppDB()
const doc: OAuth2Configs = (await getDocument(db)) ?? { try {
_id: DocumentType.OAUTH2_CONFIG, await db.remove(configId, _rev)
configs: {}, } catch (e: any) {
if (e.status === 404) {
throw new HTTPError(`OAuth2 config with id '${configId}' not found.`, 404)
}
throw e
} }
if (!doc.configs[configId]) {
throw new HTTPError(`OAuth2 config with id '${configId}' not found.`, 404)
}
delete doc.configs[configId]
await db.put(doc)
const usageLog = await db.tryGet(docIds.generateOAuth2LogID(configId)) const usageLog = await db.tryGet(docIds.generateOAuth2LogID(configId))
if (usageLog) { if (usageLog) {
await db.remove(usageLog) await db.remove(usageLog)

View File

@ -57,7 +57,7 @@ describe("oauth2 utils", () => {
method, method,
}) })
const response = await getToken(oauthConfig.id) const response = await getToken(oauthConfig._id)
return response return response
}) })
@ -75,7 +75,7 @@ describe("oauth2 utils", () => {
method, method,
}) })
await getToken(oauthConfig.id) await getToken(oauthConfig._id)
}) })
).rejects.toThrow("Error fetching oauth2 token: Not Found") ).rejects.toThrow("Error fetching oauth2 token: Not Found")
}) })
@ -91,7 +91,7 @@ describe("oauth2 utils", () => {
method, method,
}) })
await getToken(oauthConfig.id) await getToken(oauthConfig._id)
}) })
).rejects.toThrow( ).rejects.toThrow(
"Error fetching oauth2 token: Invalid client or Invalid client credentials" "Error fetching oauth2 token: Invalid client or Invalid client credentials"
@ -109,7 +109,7 @@ describe("oauth2 utils", () => {
method, method,
}) })
await getToken(oauthConfig.id) await getToken(oauthConfig._id)
}) })
).rejects.toThrow( ).rejects.toThrow(
"Error fetching oauth2 token: Invalid client or Invalid client credentials" "Error fetching oauth2 token: Invalid client or Invalid client credentials"

View File

@ -1,7 +1,9 @@
import { import {
UpsertOAuth2ConfigRequest, InsertOAuth2ConfigRequest,
UpsertOAuth2ConfigResponse, InsertOAuth2ConfigResponse,
FetchOAuth2ConfigsResponse, FetchOAuth2ConfigsResponse,
UpdateOAuth2ConfigRequest,
UpdateOAuth2ConfigResponse,
} from "@budibase/types" } from "@budibase/types"
import { Expectations, TestAPI } from "./base" import { Expectations, TestAPI } from "./base"
@ -13,10 +15,10 @@ export class OAuth2API extends TestAPI {
} }
create = async ( create = async (
body: UpsertOAuth2ConfigRequest, body: InsertOAuth2ConfigRequest,
expectations?: Expectations expectations?: Expectations
) => { ) => {
return await this._post<UpsertOAuth2ConfigResponse>("/api/oauth2", { return await this._post<InsertOAuth2ConfigResponse>("/api/oauth2", {
body, body,
expectations: { expectations: {
status: expectations?.status ?? 201, status: expectations?.status ?? 201,
@ -26,18 +28,20 @@ export class OAuth2API extends TestAPI {
} }
update = async ( update = async (
id: string, body: UpdateOAuth2ConfigRequest,
body: UpsertOAuth2ConfigRequest,
expectations?: Expectations expectations?: Expectations
) => { ) => {
return await this._put<UpsertOAuth2ConfigResponse>(`/api/oauth2/${id}`, { return await this._put<UpdateOAuth2ConfigResponse>(
body, `/api/oauth2/${body._id}`,
expectations, {
}) body,
expectations,
}
)
} }
delete = async (id: string, expectations?: Expectations) => { delete = async (id: string, rev: string, expectations?: Expectations) => {
return await this._delete<void>(`/api/oauth2/${id}`, { return await this._delete<void>(`/api/oauth2/${id}/${rev}`, {
expectations, expectations,
}) })
} }

View File

@ -10,7 +10,7 @@ import { User } from "@budibase/types"
* @param key the key * @param key the key
* @return the value or null if a value was not found for this key * @return the value or null if a value was not found for this key
*/ */
export const deepGet = (obj: { [x: string]: any }, key: string) => { export const deepGet = (obj: Record<string, any> | undefined, key: string) => {
if (!obj || !key) { if (!obj || !key) {
return null return null
} }

View File

@ -1,7 +1,8 @@
import { OAuth2CredentialsMethod } from "@budibase/types" import { OAuth2CredentialsMethod } from "@budibase/types"
export interface OAuth2ConfigResponse { export interface OAuth2ConfigResponse {
id: string _id: string
_rev: string
name: string name: string
url: string url: string
clientId: string clientId: string
@ -13,7 +14,7 @@ export interface FetchOAuth2ConfigsResponse {
configs: (OAuth2ConfigResponse & { lastUsage?: string })[] configs: (OAuth2ConfigResponse & { lastUsage?: string })[]
} }
export interface UpsertOAuth2ConfigRequest { export interface InsertOAuth2ConfigRequest {
name: string name: string
url: string url: string
clientId: string clientId: string
@ -21,12 +22,26 @@ export interface UpsertOAuth2ConfigRequest {
method: OAuth2CredentialsMethod method: OAuth2CredentialsMethod
} }
export interface UpsertOAuth2ConfigResponse { export interface InsertOAuth2ConfigResponse {
config: OAuth2ConfigResponse
}
export interface UpdateOAuth2ConfigRequest {
_id: string
_rev: string
name: string
url: string
clientId: string
clientSecret: string
method: OAuth2CredentialsMethod
}
export interface UpdateOAuth2ConfigResponse {
config: OAuth2ConfigResponse config: OAuth2ConfigResponse
} }
export interface ValidateConfigRequest { export interface ValidateConfigRequest {
id?: string _id?: string
url: string url: string
clientId: string clientId: string
clientSecret: string clientSecret: string

View File

@ -5,15 +5,10 @@ export enum OAuth2CredentialsMethod {
BODY = "BODY", BODY = "BODY",
} }
export interface OAuth2Config { export interface OAuth2Config extends Document {
id: string
name: string name: string
url: string url: string
clientId: string clientId: string
clientSecret: string clientSecret: string
method: OAuth2CredentialsMethod method: OAuth2CredentialsMethod
} }
export interface OAuth2Configs extends Document {
configs: Record<string, OAuth2Config>
}

View File

@ -83,7 +83,6 @@ export enum InternalTable {
export enum VirtualDocumentType { export enum VirtualDocumentType {
VIEW = "view", VIEW = "view",
ROW_ACTION = "row_action", ROW_ACTION = "row_action",
OAUTH2_CONFIG = "oauth2",
} }
// Because VirtualDocumentTypes can overlap, we need to make sure that we search // Because VirtualDocumentTypes can overlap, we need to make sure that we search

View File

@ -3,7 +3,7 @@ import {
FieldConstraints, FieldConstraints,
type FieldSchema, type FieldSchema,
type FormulaResponseType, type FormulaResponseType,
} from "../" } from "../../"
export interface UIField { export interface UIField {
name: string name: string

View File

@ -0,0 +1,2 @@
export * from "./fields"
export * from "./validationRules"

View File

@ -0,0 +1,25 @@
import { FieldType } from "../../documents"
export interface UIFieldValidationRule {
type: `${FieldType}`
constraint: FieldValidationRuleType
value?: string | number | string[]
error: string
}
export type FieldValidationRuleType =
| "required"
| "minLength"
| "maxLength"
| "minValue"
| "maxValue"
| "inclusion"
| "equal"
| "notEqual"
| "regex"
| "notRegex"
| "contains"
| "notContains"
| "json"
| "maxFileSize"
| "maxUploadSize"