Merge branch 'master' into ts/form
This commit is contained in:
commit
1140c8a52b
|
@ -186,7 +186,7 @@ jobs:
|
|||
id: dotenv
|
||||
uses: falti/dotenv-action@v1.1.3
|
||||
with:
|
||||
path: ./packages/server/datasource-sha.env
|
||||
path: ./packages/server/images-sha.env
|
||||
|
||||
- name: Pull testcontainers images
|
||||
run: |
|
||||
|
@ -213,6 +213,7 @@ jobs:
|
|||
docker pull redis &
|
||||
docker pull testcontainers/ryuk:0.5.1 &
|
||||
docker pull budibase/couchdb:v3.3.3-sqs-v2.1.1 &
|
||||
docker pull ${{ steps.dotenv.outputs.KEYCLOAK_IMAGE }} &
|
||||
|
||||
wait $(jobs -p)
|
||||
|
||||
|
|
|
@ -47,6 +47,9 @@ export default [
|
|||
|
||||
parserOptions: {
|
||||
allowImportExportEverywhere: true,
|
||||
svelteFeatures: {
|
||||
experimentalGenerics: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
@ -141,19 +141,23 @@ function generateSchema(
|
|||
.references(`${tableName}.${relatedPrimary}`)
|
||||
}
|
||||
break
|
||||
case FieldType.SIGNATURE_SINGLE:
|
||||
case FieldType.ATTACHMENTS:
|
||||
case FieldType.ATTACHMENT_SINGLE:
|
||||
// single attachments are stored as an object, multi attachments
|
||||
// are stored as an array
|
||||
schema.json(key)
|
||||
break
|
||||
case FieldType.FORMULA:
|
||||
// This is allowed, but nothing to do on the external datasource
|
||||
break
|
||||
case FieldType.AI:
|
||||
// This is allowed, but nothing to do on the external datasource
|
||||
break
|
||||
case FieldType.ATTACHMENTS:
|
||||
case FieldType.ATTACHMENT_SINGLE:
|
||||
case FieldType.SIGNATURE_SINGLE:
|
||||
case FieldType.AUTO:
|
||||
case FieldType.JSON:
|
||||
case FieldType.INTERNAL:
|
||||
throw `${column.type} is not a valid SQL type`
|
||||
throw new Error(`${column.type} is not a valid SQL type`)
|
||||
|
||||
default:
|
||||
utils.unreachable(columnType)
|
||||
|
|
|
@ -1,25 +1,26 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import { setContext, getContext } from "svelte"
|
||||
import Popover from "../Popover/Popover.svelte"
|
||||
import Menu from "../Menu/Menu.svelte"
|
||||
import type { PopoverAlignment } from "../constants"
|
||||
|
||||
export let disabled = false
|
||||
export let align = "left"
|
||||
export let portalTarget = undefined
|
||||
export let openOnHover = false
|
||||
export let animate = true
|
||||
export let offset = undefined
|
||||
export let disabled: boolean = false
|
||||
export let align: `${PopoverAlignment}` = "left"
|
||||
export let portalTarget: string | undefined = undefined
|
||||
export let openOnHover: boolean = false
|
||||
export let animate: boolean | undefined = true
|
||||
export let offset: number | undefined = undefined
|
||||
|
||||
const actionMenuContext = getContext("actionMenu")
|
||||
|
||||
let anchor
|
||||
let dropdown
|
||||
let timeout
|
||||
let anchor: HTMLElement | undefined
|
||||
let dropdown: Popover
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
|
||||
// This is needed because display: contents is considered "invisible".
|
||||
// It should only ever be an action button, so should be fine.
|
||||
function getAnchor(node) {
|
||||
anchor = node.firstChild
|
||||
function getAnchor(node: HTMLDivElement) {
|
||||
anchor = (node.firstChild as HTMLElement) ?? undefined
|
||||
}
|
||||
|
||||
export const show = () => {
|
||||
|
@ -37,7 +38,7 @@
|
|||
actionMenuContext?.hide()
|
||||
}
|
||||
|
||||
const openMenu = event => {
|
||||
const openMenu = (event: Event) => {
|
||||
if (!disabled) {
|
||||
event.stopPropagation()
|
||||
show()
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
export let searchTerm: string | null = null
|
||||
export let customPopoverHeight: string | undefined = undefined
|
||||
export let open: boolean = false
|
||||
export let loading: boolean
|
||||
export let loading: boolean = false
|
||||
export let onOptionMouseenter = () => {}
|
||||
export let onOptionMouseleave = () => {}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
import DatePicker from "./Core/DatePicker/DatePicker.svelte"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export let value = null
|
||||
export let value = undefined
|
||||
export let label = null
|
||||
export let labelPosition = "above"
|
||||
export let disabled = false
|
||||
|
|
|
@ -1,29 +1,31 @@
|
|||
<script>
|
||||
<script lang="ts" generics="Option">
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import Multiselect from "./Core/Multiselect.svelte"
|
||||
import Field from "./Field.svelte"
|
||||
|
||||
export let value = []
|
||||
export let label = null
|
||||
export let value: string[] | string = []
|
||||
export let label: string | undefined = undefined
|
||||
export let disabled = false
|
||||
export let readonly = false
|
||||
export let labelPosition = "above"
|
||||
export let error = null
|
||||
export let placeholder = null
|
||||
export let options = []
|
||||
export let getOptionLabel = option => option
|
||||
export let getOptionValue = option => option
|
||||
export let error: string | undefined = undefined
|
||||
export let placeholder: string | undefined = undefined
|
||||
export let options: Option[] = []
|
||||
export let getOptionLabel = (option: Option) => option
|
||||
export let getOptionValue = (option: Option) => option
|
||||
export let sort = false
|
||||
export let autoWidth = false
|
||||
export let autocomplete = false
|
||||
export let searchTerm = null
|
||||
export let customPopoverHeight
|
||||
export let helpText = null
|
||||
export let searchTerm: string | undefined = undefined
|
||||
export let customPopoverHeight: string | undefined = undefined
|
||||
export let helpText: string | undefined = undefined
|
||||
export let onOptionMouseenter = () => {}
|
||||
export let onOptionMouseleave = () => {}
|
||||
|
||||
$: arrayValue = value && !Array.isArray(value) ? [value] : (value as string[])
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const onChange = e => {
|
||||
const onChange = (e: any) => {
|
||||
value = e.detail
|
||||
dispatch("change", e.detail)
|
||||
}
|
||||
|
@ -31,10 +33,9 @@
|
|||
|
||||
<Field {helpText} {label} {labelPosition} {error}>
|
||||
<Multiselect
|
||||
{error}
|
||||
{disabled}
|
||||
{readonly}
|
||||
{value}
|
||||
bind:value={arrayValue}
|
||||
{options}
|
||||
{placeholder}
|
||||
{sort}
|
||||
|
|
|
@ -1,9 +1,4 @@
|
|||
<script lang="ts" context="module">
|
||||
type O = any
|
||||
type V = any
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
<script lang="ts" generics="O extends any,V">
|
||||
import Field from "./Field.svelte"
|
||||
import Select from "./Core/Select.svelte"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
@ -22,9 +17,11 @@
|
|||
export let getOptionValue = (option: O, _index?: number) =>
|
||||
extractProperty(option, "value")
|
||||
export let getOptionSubtitle = (option: O, _index?: number) =>
|
||||
option?.subtitle
|
||||
export let getOptionIcon = (option: O, _index?: number) => option?.icon
|
||||
export let getOptionColour = (option: O, _index?: number) => option?.colour
|
||||
(option as any)?.subtitle
|
||||
export let getOptionIcon = (option: O, _index?: number) =>
|
||||
(option as any)?.icon
|
||||
export let getOptionColour = (option: O, _index?: number) =>
|
||||
(option as any)?.colour
|
||||
export let useOptionIconImage = false
|
||||
export let isOptionEnabled:
|
||||
| ((_option: O, _index?: number) => boolean)
|
||||
|
|
|
@ -3,10 +3,10 @@
|
|||
import Switch from "./Core/Switch.svelte"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export let value = null
|
||||
export let value = undefined
|
||||
export let label = null
|
||||
export let labelPosition = "above"
|
||||
export let text = null
|
||||
export let text = undefined
|
||||
export let disabled = false
|
||||
export let error = null
|
||||
export let helpText = null
|
||||
|
|
|
@ -89,7 +89,7 @@
|
|||
/* Selection is only meant for standalone list items (non stacked) so we just set a fixed border radius */
|
||||
.list-item.selected {
|
||||
background-color: var(--spectrum-global-color-blue-100);
|
||||
border-color: var(--spectrum-global-color-blue-100);
|
||||
border: none;
|
||||
}
|
||||
.list-item.selected:after {
|
||||
content: "";
|
||||
|
@ -100,7 +100,7 @@
|
|||
pointer-events: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-radius: 4px;
|
||||
border-radius: inherit;
|
||||
box-sizing: border-box;
|
||||
z-index: 1;
|
||||
opacity: 0.5;
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import { ActionMenu } from "./types"
|
||||
|
||||
declare module "svelte" {
|
||||
export function getContext(key: "actionMenu"): ActionMenu | undefined
|
||||
}
|
||||
|
||||
export const Modal = "bbui-modal"
|
||||
export const PopoverRoot = "bbui-popover-root"
|
|
@ -0,0 +1,3 @@
|
|||
export interface ActionMenu {
|
||||
hide: () => void
|
||||
}
|
|
@ -1,40 +1,42 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
Label,
|
||||
Select,
|
||||
Multiselect,
|
||||
Toggle,
|
||||
Icon,
|
||||
DatePicker,
|
||||
Modal,
|
||||
notifications,
|
||||
Layout,
|
||||
AbsTooltip,
|
||||
Button,
|
||||
DatePicker,
|
||||
Icon,
|
||||
Input,
|
||||
Label,
|
||||
Layout,
|
||||
Modal,
|
||||
Multiselect,
|
||||
notifications,
|
||||
ProgressCircle,
|
||||
Select,
|
||||
Toggle,
|
||||
TooltipPosition,
|
||||
TooltipType,
|
||||
} from "@budibase/bbui"
|
||||
import {
|
||||
canHaveDefaultColumn,
|
||||
helpers,
|
||||
PROTECTED_EXTERNAL_COLUMNS,
|
||||
PROTECTED_INTERNAL_COLUMNS,
|
||||
SWITCHABLE_TYPES,
|
||||
ValidColumnNameRegex,
|
||||
helpers,
|
||||
PROTECTED_INTERNAL_COLUMNS,
|
||||
PROTECTED_EXTERNAL_COLUMNS,
|
||||
canHaveDefaultColumn,
|
||||
} from "@budibase/shared-core"
|
||||
import { makePropSafe } from "@budibase/string-templates"
|
||||
import { createEventDispatcher, getContext, onMount } from "svelte"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
import { tables, datasources } from "@/stores/builder"
|
||||
import { datasources, tables } from "@/stores/builder"
|
||||
import { licensing } from "@/stores/portal"
|
||||
import { TableNames, UNEDITABLE_USER_FIELDS } from "@/constants"
|
||||
import {
|
||||
FIELDS,
|
||||
RelationshipType,
|
||||
PrettyRelationshipDefinitions,
|
||||
DB_TYPE_EXTERNAL,
|
||||
FIELDS,
|
||||
PrettyRelationshipDefinitions,
|
||||
RelationshipType,
|
||||
} from "@/constants/backend"
|
||||
import { getAutoColumnInformation, buildAutoColumn } from "@/helpers/utils"
|
||||
import { buildAutoColumn, getAutoColumnInformation } from "@/helpers/utils"
|
||||
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
|
||||
import AIFieldConfiguration from "@/components/common/AIFieldConfiguration.svelte"
|
||||
import ModalBindableInput from "@/components/common/bindings/ModalBindableInput.svelte"
|
||||
|
@ -43,42 +45,52 @@
|
|||
import {
|
||||
BBReferenceFieldSubType,
|
||||
FieldType,
|
||||
FormulaType,
|
||||
SourceName,
|
||||
} from "@budibase/types"
|
||||
import RelationshipSelector from "@/components/common/RelationshipSelector.svelte"
|
||||
import { RowUtils, canBeDisplayColumn } from "@budibase/frontend-core"
|
||||
import { canBeDisplayColumn, RowUtils } from "@budibase/frontend-core"
|
||||
import ServerBindingPanel from "@/components/common/bindings/ServerBindingPanel.svelte"
|
||||
import OptionsEditor from "./OptionsEditor.svelte"
|
||||
import { getUserBindings } from "@/dataBinding"
|
||||
import type {
|
||||
Table,
|
||||
Datasource,
|
||||
FieldSchema,
|
||||
UIField,
|
||||
AutoFieldSubType,
|
||||
FormulaResponseType,
|
||||
FieldSchemaConfig,
|
||||
} from "@budibase/types"
|
||||
|
||||
export let field
|
||||
export let field: FieldSchema
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const { dispatch: gridDispatch, rows } = getContext("grid")
|
||||
const { dispatch: gridDispatch, rows } = getContext("grid") as any
|
||||
const SafeID = `${makePropSafe("user")}.${makePropSafe("_id")}`
|
||||
const SingleUserDefault = `{{ ${SafeID} }}`
|
||||
const MultiUserDefault = `{{ js "${btoa(`return [$("${SafeID}")]`)}" }}`
|
||||
|
||||
let mounted = false
|
||||
let originalName
|
||||
let linkEditDisabled
|
||||
let primaryDisplay
|
||||
let indexes = [...($tables.selected.indexes || [])]
|
||||
let isCreating = undefined
|
||||
let originalName: string | undefined
|
||||
let linkEditDisabled: boolean = false
|
||||
let hasPrimaryDisplay: boolean
|
||||
let isCreating: boolean | undefined
|
||||
let relationshipPart1 = PrettyRelationshipDefinitions.MANY
|
||||
let relationshipPart2 = PrettyRelationshipDefinitions.ONE
|
||||
let relationshipTableIdPrimary = null
|
||||
let relationshipTableIdSecondary = null
|
||||
let table = $tables.selected
|
||||
let confirmDeleteDialog
|
||||
let savingColumn
|
||||
let deleteColName
|
||||
let jsonSchemaModal
|
||||
let editableColumn = {
|
||||
type: FIELDS.STRING.type,
|
||||
constraints: FIELDS.STRING.constraints,
|
||||
let relationshipTableIdPrimary: string | undefined
|
||||
let relationshipTableIdSecondary: string | undefined
|
||||
let table: Table | undefined = $tables.selected
|
||||
let confirmDeleteDialog: any
|
||||
let savingColumn: boolean
|
||||
let deleteColName: string | undefined
|
||||
let jsonSchemaModal: any
|
||||
let editableColumn: FieldSchemaConfig = {
|
||||
type: FieldType.STRING,
|
||||
constraints: FIELDS.STRING.constraints as any,
|
||||
name: "",
|
||||
// Initial value for column name in other table for linked records
|
||||
fieldName: $tables.selected.name,
|
||||
fieldName: $tables.selected?.name || "",
|
||||
}
|
||||
let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions)
|
||||
let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions)
|
||||
|
@ -99,17 +111,42 @@
|
|||
const autoColumnInfo = getAutoColumnInformation()
|
||||
let optionsValid = true
|
||||
|
||||
// a fixed order for the types to stop them moving around
|
||||
// we've never really guaranteed an order to these, which means that
|
||||
// they can move around very easily
|
||||
const fixedTypeOrder = [
|
||||
FIELDS.STRING,
|
||||
FIELDS.NUMBER,
|
||||
FIELDS.OPTIONS,
|
||||
FIELDS.ARRAY,
|
||||
FIELDS.BOOLEAN,
|
||||
FIELDS.DATETIME,
|
||||
FIELDS.LINK,
|
||||
FIELDS.AI,
|
||||
FIELDS.LONGFORM,
|
||||
FIELDS.USER,
|
||||
FIELDS.USERS,
|
||||
FIELDS.ATTACHMENT_SINGLE,
|
||||
FIELDS.ATTACHMENTS,
|
||||
FIELDS.FORMULA,
|
||||
FIELDS.JSON,
|
||||
FIELDS.BARCODEQR,
|
||||
FIELDS.SIGNATURE_SINGLE,
|
||||
FIELDS.BIGINT,
|
||||
FIELDS.AUTO,
|
||||
]
|
||||
|
||||
$: rowGoldenSample = RowUtils.generateGoldenSample($rows)
|
||||
$: aiEnabled =
|
||||
$licensing.customAIConfigsEnabled || $licensing.budibaseAiEnabled
|
||||
$: if (primaryDisplay) {
|
||||
$licensing.customAIConfigsEnabled || $licensing.budibaseAIEnabled
|
||||
$: if (hasPrimaryDisplay && editableColumn.constraints) {
|
||||
editableColumn.constraints.presence = { allowEmpty: false }
|
||||
}
|
||||
$: {
|
||||
// this parses any changes the user has made when creating a new internal relationship
|
||||
// into what we expect the schema to look like
|
||||
if (editableColumn.type === FieldType.LINK) {
|
||||
relationshipTableIdPrimary = table._id
|
||||
relationshipTableIdPrimary = table?._id
|
||||
if (relationshipPart1 === PrettyRelationshipDefinitions.ONE) {
|
||||
relationshipOpts2 = relationshipOpts2.filter(
|
||||
opt => opt !== PrettyRelationshipDefinitions.ONE
|
||||
|
@ -129,36 +166,44 @@
|
|||
editableColumn.relationshipType = Object.entries(relationshipMap).find(
|
||||
([_, parts]) =>
|
||||
parts.part1 === relationshipPart1 && parts.part2 === relationshipPart2
|
||||
)?.[0]
|
||||
// Set the tableId based on the selected table
|
||||
editableColumn.tableId = relationshipTableIdSecondary
|
||||
)?.[0] as RelationshipType
|
||||
if (relationshipTableIdSecondary) {
|
||||
// Set the tableId based on the selected table
|
||||
editableColumn.tableId = relationshipTableIdSecondary
|
||||
}
|
||||
}
|
||||
}
|
||||
$: initialiseField(field, savingColumn)
|
||||
$: checkConstraints(editableColumn)
|
||||
$: required =
|
||||
primaryDisplay ||
|
||||
hasPrimaryDisplay ||
|
||||
editableColumn?.constraints?.presence === true ||
|
||||
editableColumn?.constraints?.presence?.allowEmpty === false
|
||||
(editableColumn?.constraints?.presence as any)?.allowEmpty === false
|
||||
$: uneditable =
|
||||
$tables.selected?._id === TableNames.USERS &&
|
||||
UNEDITABLE_USER_FIELDS.includes(editableColumn.name)
|
||||
UNEDITABLE_USER_FIELDS.includes(editableColumn.name || "")
|
||||
$: invalid =
|
||||
!editableColumn?.name ||
|
||||
(editableColumn?.type === FieldType.LINK && !editableColumn?.tableId) ||
|
||||
Object.keys(errors).length !== 0 ||
|
||||
Object.keys(errors || {}).length !== 0 ||
|
||||
!optionsValid
|
||||
$: errors = checkErrors(editableColumn)
|
||||
$: datasource = $datasources.list.find(
|
||||
source => source._id === table?.sourceId
|
||||
)
|
||||
) as Datasource | undefined
|
||||
$: tableAutoColumnsTypes = getTableAutoColumnTypes($tables?.selected)
|
||||
$: availableAutoColumns = Object.keys(autoColumnInfo).reduce((acc, key) => {
|
||||
if (!tableAutoColumnsTypes.includes(key)) {
|
||||
acc[key] = autoColumnInfo[key]
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
$: availableAutoColumns = Object.keys(autoColumnInfo).reduce(
|
||||
(acc: Record<string, { enabled: boolean; name: string }>, key: string) => {
|
||||
if (!tableAutoColumnsTypes.includes(key)) {
|
||||
const subtypeKey = key as AutoFieldSubType
|
||||
if (autoColumnInfo[subtypeKey]) {
|
||||
acc[key] = autoColumnInfo[subtypeKey]
|
||||
}
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
$: availableAutoColumnKeys = availableAutoColumns
|
||||
? Object.keys(availableAutoColumns)
|
||||
: []
|
||||
|
@ -176,21 +221,26 @@
|
|||
!editableColumn.autocolumn
|
||||
$: hasDefault =
|
||||
editableColumn?.default != null && editableColumn?.default !== ""
|
||||
$: externalTable = table.sourceType === DB_TYPE_EXTERNAL
|
||||
$: isExternalTable = table?.sourceType === DB_TYPE_EXTERNAL
|
||||
// in the case of internal tables the sourceId will just be undefined
|
||||
$: tableOptions = $tables.list.filter(
|
||||
opt =>
|
||||
opt.sourceType === table.sourceType && table.sourceId === opt.sourceId
|
||||
opt.sourceType === table?.sourceType && table.sourceId === opt.sourceId
|
||||
)
|
||||
$: typeEnabled =
|
||||
!originalName ||
|
||||
(originalName &&
|
||||
SWITCHABLE_TYPES[field.type] &&
|
||||
!editableColumn?.autocolumn)
|
||||
$: allowedTypes = getAllowedTypes(datasource).map(t => ({
|
||||
fieldId: makeFieldId(t.type, t.subtype),
|
||||
...t,
|
||||
}))
|
||||
$: allowedTypes = getAllowedTypes(datasource, table)
|
||||
$: orderedAllowedTypes = fixedTypeOrder
|
||||
.filter(ordered =>
|
||||
allowedTypes.find(allowed => allowed.type === ordered.type)
|
||||
)
|
||||
.map(t => ({
|
||||
fieldId: makeFieldId(t.type, t.subtype),
|
||||
...t,
|
||||
}))
|
||||
$: defaultValueBindings = [
|
||||
{
|
||||
type: "context",
|
||||
|
@ -210,7 +260,23 @@
|
|||
editableColumn.default
|
||||
)
|
||||
|
||||
const fieldDefinitions = Object.values(FIELDS).reduce(
|
||||
const allTableFields = [
|
||||
FIELDS.STRING,
|
||||
FIELDS.NUMBER,
|
||||
FIELDS.OPTIONS,
|
||||
FIELDS.ARRAY,
|
||||
FIELDS.BOOLEAN,
|
||||
FIELDS.DATETIME,
|
||||
FIELDS.LINK,
|
||||
FIELDS.LONGFORM,
|
||||
FIELDS.FORMULA,
|
||||
FIELDS.BARCODEQR,
|
||||
FIELDS.BIGINT,
|
||||
]
|
||||
|
||||
const fieldDefinitions: Record<string, UIField> = Object.values(
|
||||
FIELDS
|
||||
).reduce(
|
||||
// Storing the fields by complex field id
|
||||
(acc, field) => ({
|
||||
...acc,
|
||||
|
@ -219,7 +285,7 @@
|
|||
{}
|
||||
)
|
||||
|
||||
function makeFieldId(type, subtype, autocolumn) {
|
||||
function makeFieldId(type: string, subtype?: string, autocolumn?: boolean) {
|
||||
// don't make field IDs for auto types
|
||||
if (type === FieldType.AUTO || autocolumn) {
|
||||
return type.toUpperCase()
|
||||
|
@ -233,23 +299,29 @@
|
|||
}
|
||||
}
|
||||
|
||||
const initialiseField = (field, savingColumn) => {
|
||||
const initialiseField = (
|
||||
field: FieldSchema | undefined,
|
||||
savingColumn: boolean
|
||||
) => {
|
||||
isCreating = !field
|
||||
if (field && !savingColumn) {
|
||||
editableColumn = cloneDeep(field)
|
||||
originalName = editableColumn.name ? editableColumn.name + "" : null
|
||||
editableColumn = cloneDeep(field) as FieldSchemaConfig
|
||||
originalName = editableColumn.name ? editableColumn.name + "" : undefined
|
||||
linkEditDisabled = originalName != null
|
||||
primaryDisplay =
|
||||
$tables.selected.primaryDisplay == null ||
|
||||
$tables.selected.primaryDisplay === editableColumn.name
|
||||
hasPrimaryDisplay =
|
||||
$tables.selected?.primaryDisplay == null ||
|
||||
$tables.selected?.primaryDisplay === editableColumn.name
|
||||
|
||||
// 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
|
||||
// for the tableId
|
||||
if (editableColumn.type === FieldType.LINK && editableColumn.tableId) {
|
||||
relationshipTableIdPrimary = table._id
|
||||
relationshipTableIdPrimary = table?._id
|
||||
relationshipTableIdSecondary = editableColumn.tableId
|
||||
if (editableColumn.relationshipType in relationshipMap) {
|
||||
if (
|
||||
editableColumn.relationshipType &&
|
||||
editableColumn.relationshipType in relationshipMap
|
||||
) {
|
||||
const { part1, part2 } =
|
||||
relationshipMap[editableColumn.relationshipType]
|
||||
relationshipPart1 = part1
|
||||
|
@ -267,18 +339,21 @@
|
|||
}
|
||||
}
|
||||
|
||||
const getTableAutoColumnTypes = table => {
|
||||
return Object.keys(table?.schema).reduce((acc, key) => {
|
||||
let fieldSchema = table?.schema[key]
|
||||
if (fieldSchema.autocolumn) {
|
||||
acc.push(fieldSchema.subtype)
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
const getTableAutoColumnTypes = (table: Table | undefined) => {
|
||||
return Object.keys(table?.schema || {}).reduce(
|
||||
(acc: string[], key: string) => {
|
||||
let fieldSchema = table?.schema[key]
|
||||
if (fieldSchema?.autocolumn && fieldSchema?.subtype) {
|
||||
acc.push(fieldSchema.subtype)
|
||||
}
|
||||
return acc
|
||||
},
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
async function saveColumn() {
|
||||
if (errors?.length) {
|
||||
if (Object.keys(errors || {}).length) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -287,14 +362,18 @@
|
|||
|
||||
delete saveColumn.fieldId
|
||||
|
||||
if (saveColumn.type === FieldType.AUTO) {
|
||||
if (
|
||||
$tables.selected &&
|
||||
saveColumn.name &&
|
||||
saveColumn.type === FieldType.AUTO
|
||||
) {
|
||||
saveColumn = buildAutoColumn(
|
||||
$tables.selected.name,
|
||||
saveColumn.name,
|
||||
saveColumn.subtype
|
||||
)
|
||||
saveColumn.subtype as AutoFieldSubType
|
||||
) as FieldSchemaConfig
|
||||
}
|
||||
if (saveColumn.type !== FieldType.LINK) {
|
||||
if ("fieldName" in saveColumn && saveColumn.type !== FieldType.LINK) {
|
||||
delete saveColumn.fieldName
|
||||
}
|
||||
|
||||
|
@ -304,22 +383,21 @@
|
|||
}
|
||||
|
||||
// Ensure primary display columns are always required and don't have default values
|
||||
if (primaryDisplay) {
|
||||
saveColumn.constraints.presence = { allowEmpty: false }
|
||||
if (hasPrimaryDisplay) {
|
||||
saveColumn.constraints!.presence = { allowEmpty: false }
|
||||
delete saveColumn.default
|
||||
}
|
||||
|
||||
// Ensure the field is not required if we have a default value
|
||||
if (saveColumn.default) {
|
||||
saveColumn.constraints.presence = false
|
||||
saveColumn.constraints!.presence = false
|
||||
}
|
||||
|
||||
try {
|
||||
await tables.saveField({
|
||||
originalName,
|
||||
field: saveColumn,
|
||||
primaryDisplay,
|
||||
indexes,
|
||||
field: saveColumn as FieldSchema,
|
||||
hasPrimaryDisplay,
|
||||
})
|
||||
dispatch("updatecolumns")
|
||||
gridDispatch("close-edit-column")
|
||||
|
@ -329,7 +407,7 @@
|
|||
} else {
|
||||
notifications.success("Column created successfully")
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
notifications.error(`Error saving column: ${err.message}`)
|
||||
} finally {
|
||||
savingColumn = false
|
||||
|
@ -337,65 +415,83 @@
|
|||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editableColumn.name = originalName
|
||||
if (originalName) {
|
||||
editableColumn.name = originalName
|
||||
}
|
||||
gridDispatch("close-edit-column")
|
||||
}
|
||||
|
||||
async function deleteColumn() {
|
||||
try {
|
||||
editableColumn.name = deleteColName
|
||||
if (editableColumn.name === $tables.selected.primaryDisplay) {
|
||||
if (deleteColName) {
|
||||
editableColumn.name = deleteColName
|
||||
}
|
||||
if (editableColumn.name === $tables.selected?.primaryDisplay) {
|
||||
notifications.error("You cannot delete the display column")
|
||||
} else {
|
||||
await tables.deleteField(editableColumn)
|
||||
await tables.deleteField({ name: editableColumn.name! })
|
||||
notifications.success(`Column ${editableColumn.name} deleted`)
|
||||
confirmDeleteDialog.hide()
|
||||
dispatch("updatecolumns")
|
||||
gridDispatch("close-edit-column")
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
notifications.error(`Error deleting column: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function onHandleTypeChange(event) {
|
||||
function onHandleTypeChange(event: any) {
|
||||
handleTypeChange(event.detail)
|
||||
}
|
||||
|
||||
function handleTypeChange(type) {
|
||||
function handleTypeChange(type?: string) {
|
||||
// remove any extra fields that may not be related to this type
|
||||
delete editableColumn.autocolumn
|
||||
delete editableColumn.subtype
|
||||
delete editableColumn.tableId
|
||||
delete editableColumn.relationshipType
|
||||
delete editableColumn.formulaType
|
||||
delete editableColumn.constraints
|
||||
delete editableColumn.responseType
|
||||
const columnsToClear = [
|
||||
"autocolumn",
|
||||
"subtype",
|
||||
"tableId",
|
||||
"relationshipType",
|
||||
"formulaType",
|
||||
"responseType",
|
||||
]
|
||||
for (let column of columnsToClear) {
|
||||
if (column in editableColumn) {
|
||||
delete editableColumn[column as keyof FieldSchema]
|
||||
}
|
||||
}
|
||||
editableColumn.constraints = {}
|
||||
|
||||
// Add in defaults and initial definition
|
||||
const definition = fieldDefinitions[type?.toUpperCase()]
|
||||
const definition = fieldDefinitions[type?.toUpperCase() || ""]
|
||||
if (definition?.constraints) {
|
||||
editableColumn.constraints = cloneDeep(definition.constraints)
|
||||
}
|
||||
|
||||
editableColumn.type = definition.type
|
||||
editableColumn.subtype = definition.subtype
|
||||
if (definition.subtype) {
|
||||
// @ts-expect-error the setting of sub-type here doesn't fit our definition with
|
||||
// FieldSchema, there is no type checking, it simply sets it if it is provided
|
||||
editableColumn.subtype = definition.subtype
|
||||
}
|
||||
|
||||
// Default relationships many to many
|
||||
if (editableColumn.type === FieldType.LINK) {
|
||||
editableColumn.relationshipType = RelationshipType.MANY_TO_MANY
|
||||
} else if (editableColumn.type === FieldType.FORMULA) {
|
||||
editableColumn.formulaType = "dynamic"
|
||||
editableColumn.responseType = field?.responseType || FIELDS.STRING.type
|
||||
editableColumn.formulaType = FormulaType.DYNAMIC
|
||||
editableColumn.responseType =
|
||||
field && "responseType" in field
|
||||
? field.responseType
|
||||
: (FIELDS.STRING.type as FormulaResponseType)
|
||||
}
|
||||
}
|
||||
|
||||
function setRequired(req) {
|
||||
editableColumn.constraints.presence = req ? { allowEmpty: false } : false
|
||||
function setRequired(req: boolean) {
|
||||
editableColumn.constraints!.presence = req ? { allowEmpty: false } : false
|
||||
required = req
|
||||
}
|
||||
|
||||
function onChangeRequired(e) {
|
||||
function onChangeRequired(e: any) {
|
||||
setRequired(e.detail)
|
||||
}
|
||||
|
||||
|
@ -412,10 +508,21 @@
|
|||
deleteColName = ""
|
||||
}
|
||||
|
||||
function getAllowedTypes(datasource) {
|
||||
function getAllowedTypes(
|
||||
datasource: Datasource | undefined,
|
||||
table: Table | undefined
|
||||
): UIField[] {
|
||||
const isSqlTable = table?.sql
|
||||
const isGoogleSheet =
|
||||
table?.sourceType === DB_TYPE_EXTERNAL &&
|
||||
datasource?.source === SourceName.GOOGLE_SHEETS
|
||||
if (originalName) {
|
||||
let possibleTypes = SWITCHABLE_TYPES[field.type] || [editableColumn.type]
|
||||
if (helpers.schema.isDeprecatedSingleUserColumn(editableColumn)) {
|
||||
if (
|
||||
helpers.schema.isDeprecatedSingleUserColumn(
|
||||
editableColumn as FieldSchema
|
||||
)
|
||||
) {
|
||||
// This will handle old single users columns
|
||||
return [
|
||||
{
|
||||
|
@ -442,60 +549,43 @@
|
|||
.map(([_, fieldDefinition]) => fieldDefinition)
|
||||
}
|
||||
|
||||
if (!externalTable) {
|
||||
return [
|
||||
FIELDS.STRING,
|
||||
FIELDS.NUMBER,
|
||||
FIELDS.OPTIONS,
|
||||
FIELDS.ARRAY,
|
||||
FIELDS.BOOLEAN,
|
||||
FIELDS.DATETIME,
|
||||
FIELDS.LINK,
|
||||
...(aiEnabled ? [FIELDS.AI] : []),
|
||||
FIELDS.LONGFORM,
|
||||
if (!isExternalTable) {
|
||||
const fields = [
|
||||
...allTableFields,
|
||||
FIELDS.USER,
|
||||
FIELDS.USERS,
|
||||
FIELDS.ATTACHMENT_SINGLE,
|
||||
FIELDS.ATTACHMENTS,
|
||||
FIELDS.FORMULA,
|
||||
FIELDS.JSON,
|
||||
FIELDS.BARCODEQR,
|
||||
FIELDS.SIGNATURE_SINGLE,
|
||||
FIELDS.BIGINT,
|
||||
FIELDS.JSON,
|
||||
FIELDS.AUTO,
|
||||
]
|
||||
} else {
|
||||
let fields = [
|
||||
FIELDS.STRING,
|
||||
FIELDS.NUMBER,
|
||||
FIELDS.OPTIONS,
|
||||
FIELDS.ARRAY,
|
||||
FIELDS.BOOLEAN,
|
||||
FIELDS.DATETIME,
|
||||
FIELDS.LINK,
|
||||
FIELDS.LONGFORM,
|
||||
FIELDS.USER,
|
||||
FIELDS.USERS,
|
||||
FIELDS.FORMULA,
|
||||
FIELDS.BARCODEQR,
|
||||
FIELDS.BIGINT,
|
||||
]
|
||||
|
||||
// Filter out multiple users for google sheets
|
||||
if (datasource?.source === SourceName.GOOGLE_SHEETS) {
|
||||
fields = fields.filter(x => x !== FIELDS.USERS)
|
||||
if (aiEnabled) {
|
||||
fields.push(FIELDS.AI)
|
||||
}
|
||||
|
||||
// Filter out SQL-specific types for non-SQL datasources
|
||||
if (!table.sql) {
|
||||
fields = fields.filter(x => x !== FIELDS.LINK && x !== FIELDS.ARRAY)
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
if (isExternalTable && isSqlTable) {
|
||||
return [
|
||||
...allTableFields,
|
||||
FIELDS.USER,
|
||||
FIELDS.USERS,
|
||||
FIELDS.ATTACHMENT_SINGLE,
|
||||
FIELDS.ATTACHMENTS,
|
||||
FIELDS.SIGNATURE_SINGLE,
|
||||
]
|
||||
} else if (isExternalTable && isGoogleSheet) {
|
||||
// google-sheets supports minimum set (no attachments or user references)
|
||||
return allTableFields
|
||||
} else if (isExternalTable && !isSqlTable) {
|
||||
// filter out SQL-specific types for non-SQL datasources
|
||||
return allTableFields.filter(x => x !== FIELDS.LINK && x !== FIELDS.ARRAY)
|
||||
}
|
||||
|
||||
throw new Error("No valid allowed types found")
|
||||
}
|
||||
|
||||
function checkConstraints(fieldToCheck) {
|
||||
function checkConstraints(fieldToCheck: FieldSchema) {
|
||||
if (!fieldToCheck) {
|
||||
return
|
||||
}
|
||||
|
@ -526,11 +616,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
function checkErrors(fieldInfo) {
|
||||
function checkErrors(fieldInfo: FieldSchema) {
|
||||
if (!editableColumn) {
|
||||
return {}
|
||||
return
|
||||
}
|
||||
function inUse(tbl, column, ogName = null) {
|
||||
function inUse(tbl?: Table, column?: string, ogName?: string) {
|
||||
const parsedColumn = column ? column.toLowerCase().trim() : column
|
||||
|
||||
return Object.keys(tbl?.schema || {}).some(key => {
|
||||
|
@ -538,11 +628,12 @@
|
|||
return lowerKey !== ogName?.toLowerCase() && lowerKey === parsedColumn
|
||||
})
|
||||
}
|
||||
const newError = {}
|
||||
const prohibited = externalTable
|
||||
const newError: { name?: string; subtype?: string; relatedName?: string } =
|
||||
{}
|
||||
const prohibited = isExternalTable
|
||||
? PROTECTED_EXTERNAL_COLUMNS
|
||||
: PROTECTED_INTERNAL_COLUMNS
|
||||
if (!externalTable && fieldInfo.name?.startsWith("_")) {
|
||||
if (!isExternalTable && fieldInfo.name?.startsWith("_")) {
|
||||
newError.name = `Column name cannot start with an underscore.`
|
||||
} else if (fieldInfo.name && !fieldInfo.name.match(ValidColumnNameRegex)) {
|
||||
newError.name = `Illegal character; must be alpha-numeric.`
|
||||
|
@ -558,31 +649,52 @@
|
|||
newError.subtype = `Auto Column requires a type.`
|
||||
}
|
||||
|
||||
if (fieldInfo.fieldName && fieldInfo.tableId) {
|
||||
if (
|
||||
fieldInfo.type === FieldType.LINK &&
|
||||
fieldInfo.fieldName &&
|
||||
fieldInfo.tableId
|
||||
) {
|
||||
const relatedTable = $tables.list.find(
|
||||
tbl => tbl._id === fieldInfo.tableId
|
||||
)
|
||||
if (inUse(relatedTable, fieldInfo.fieldName) && !originalName) {
|
||||
newError.relatedName = `Column name already in use in table ${relatedTable.name}`
|
||||
newError.relatedName = `Column name already in use in table ${relatedTable?.name}`
|
||||
}
|
||||
}
|
||||
return newError
|
||||
}
|
||||
|
||||
const sanitiseDefaultValue = (type, options, defaultValue) => {
|
||||
const sanitiseDefaultValue = (
|
||||
type: FieldType,
|
||||
options: string[],
|
||||
defaultValue?: string[] | string
|
||||
) => {
|
||||
if (!defaultValue?.length) {
|
||||
return
|
||||
}
|
||||
// Delete default value for options fields if the option is no longer available
|
||||
if (type === FieldType.OPTIONS && !options.includes(defaultValue)) {
|
||||
if (
|
||||
type === FieldType.OPTIONS &&
|
||||
typeof defaultValue === "string" &&
|
||||
!options.includes(defaultValue)
|
||||
) {
|
||||
delete editableColumn.default
|
||||
}
|
||||
// Filter array default values to only valid options
|
||||
if (type === FieldType.ARRAY) {
|
||||
if (type === FieldType.ARRAY && Array.isArray(defaultValue)) {
|
||||
editableColumn.default = defaultValue.filter(x => options.includes(x))
|
||||
}
|
||||
}
|
||||
|
||||
function handleNameInput(evt: any) {
|
||||
if (
|
||||
!uneditable &&
|
||||
!(linkEditDisabled && editableColumn.type === FieldType.LINK)
|
||||
) {
|
||||
editableColumn.name = evt.target.value
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mounted = true
|
||||
})
|
||||
|
@ -593,25 +705,18 @@
|
|||
<Input
|
||||
value={editableColumn.name}
|
||||
autofocus
|
||||
on:input={e => {
|
||||
if (
|
||||
!uneditable &&
|
||||
!(linkEditDisabled && editableColumn.type === FieldType.LINK)
|
||||
) {
|
||||
editableColumn.name = e.target.value
|
||||
}
|
||||
}}
|
||||
on:input={handleNameInput}
|
||||
disabled={uneditable ||
|
||||
(linkEditDisabled && editableColumn.type === FieldType.LINK)}
|
||||
error={errors?.name}
|
||||
/>
|
||||
{/if}
|
||||
<Select
|
||||
placeholder={null}
|
||||
placeholder={undefined}
|
||||
disabled={!typeEnabled}
|
||||
bind:value={editableColumn.fieldId}
|
||||
on:change={onHandleTypeChange}
|
||||
options={allowedTypes}
|
||||
options={orderedAllowedTypes}
|
||||
getOptionLabel={field => field.name}
|
||||
getOptionValue={field => field.fieldId}
|
||||
getOptionIcon={field => field.icon}
|
||||
|
@ -623,7 +728,7 @@
|
|||
}}
|
||||
/>
|
||||
|
||||
{#if editableColumn.type === FieldType.STRING}
|
||||
{#if editableColumn.type === FieldType.STRING && editableColumn.constraints.length}
|
||||
<Input
|
||||
type="number"
|
||||
label="Max Length"
|
||||
|
@ -640,8 +745,8 @@
|
|||
<div class="tooltip-alignment">
|
||||
<Label size="M">Formatting</Label>
|
||||
<AbsTooltip
|
||||
position="top"
|
||||
type="info"
|
||||
position={TooltipPosition.Top}
|
||||
type={TooltipType.Info}
|
||||
text={"Rich text includes support for images, link"}
|
||||
>
|
||||
<Icon size="XS" name="InfoOutline" />
|
||||
|
@ -664,26 +769,30 @@
|
|||
<div class="label-length">
|
||||
<Label size="M">Earliest</Label>
|
||||
</div>
|
||||
<div class="input-length">
|
||||
<DatePicker
|
||||
bind:value={editableColumn.constraints.datetime.earliest}
|
||||
enableTime={!editableColumn.dateOnly}
|
||||
timeOnly={editableColumn.timeOnly}
|
||||
/>
|
||||
</div>
|
||||
{#if editableColumn.constraints.datetime}
|
||||
<div class="input-length">
|
||||
<DatePicker
|
||||
bind:value={editableColumn.constraints.datetime.earliest}
|
||||
enableTime={!editableColumn.dateOnly}
|
||||
timeOnly={editableColumn.timeOnly}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="split-label">
|
||||
<div class="label-length">
|
||||
<Label size="M">Latest</Label>
|
||||
</div>
|
||||
<div class="input-length">
|
||||
<DatePicker
|
||||
bind:value={editableColumn.constraints.datetime.latest}
|
||||
enableTime={!editableColumn.dateOnly}
|
||||
timeOnly={editableColumn.timeOnly}
|
||||
/>
|
||||
</div>
|
||||
{#if editableColumn.constraints.datetime}
|
||||
<div class="input-length">
|
||||
<DatePicker
|
||||
bind:value={editableColumn.constraints.datetime.latest}
|
||||
enableTime={!editableColumn.dateOnly}
|
||||
timeOnly={editableColumn.timeOnly}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !editableColumn.timeOnly}
|
||||
{#if datasource?.source !== SourceName.ORACLE && datasource?.source !== SourceName.SQL_SERVER && !editableColumn.dateOnly}
|
||||
|
@ -691,10 +800,10 @@
|
|||
<div class="row">
|
||||
<Label>Time zones</Label>
|
||||
<AbsTooltip
|
||||
position="top"
|
||||
type="info"
|
||||
position={TooltipPosition.Top}
|
||||
type={TooltipType.Info}
|
||||
text={isCreating
|
||||
? null
|
||||
? undefined
|
||||
: "We recommend not changing how timezones are handled for existing columns, as existing data will not be updated"}
|
||||
>
|
||||
<Icon size="XS" name="InfoOutline" />
|
||||
|
@ -713,25 +822,30 @@
|
|||
<div class="label-length">
|
||||
<Label size="M">Min Value</Label>
|
||||
</div>
|
||||
<div class="input-length">
|
||||
<Input
|
||||
type="number"
|
||||
bind:value={editableColumn.constraints.numericality
|
||||
.greaterThanOrEqualTo}
|
||||
/>
|
||||
</div>
|
||||
{#if editableColumn.constraints.numericality}
|
||||
<div class="input-length">
|
||||
<Input
|
||||
type="number"
|
||||
bind:value={editableColumn.constraints.numericality
|
||||
.greaterThanOrEqualTo}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="split-label">
|
||||
<div class="label-length">
|
||||
<Label size="M">Max Value</Label>
|
||||
</div>
|
||||
<div class="input-length">
|
||||
<Input
|
||||
type="number"
|
||||
bind:value={editableColumn.constraints.numericality.lessThanOrEqualTo}
|
||||
/>
|
||||
</div>
|
||||
{#if editableColumn.constraints.numericality}
|
||||
<div class="input-length">
|
||||
<Input
|
||||
type="number"
|
||||
bind:value={editableColumn.constraints.numericality
|
||||
.lessThanOrEqualTo}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if editableColumn.type === FieldType.LINK && !editableColumn.autocolumn}
|
||||
<RelationshipSelector
|
||||
|
@ -747,7 +861,7 @@
|
|||
{errors}
|
||||
/>
|
||||
{:else if editableColumn.type === FieldType.FORMULA}
|
||||
{#if !externalTable}
|
||||
{#if !isExternalTable}
|
||||
<div class="split-label">
|
||||
<div class="label-length">
|
||||
<Label size="M">Formula Type</Label>
|
||||
|
@ -797,9 +911,11 @@
|
|||
title="Formula"
|
||||
value={editableColumn.formula}
|
||||
on:change={e => {
|
||||
editableColumn = {
|
||||
...editableColumn,
|
||||
formula: e.detail,
|
||||
if (editableColumn.type === FieldType.FORMULA) {
|
||||
editableColumn = {
|
||||
...editableColumn,
|
||||
formula: e.detail,
|
||||
}
|
||||
}
|
||||
}}
|
||||
bindings={getBindings({ table })}
|
||||
|
@ -808,7 +924,7 @@
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if editableColumn.type === FieldType.AI}
|
||||
{:else if editableColumn.type === FieldType.AI && table}
|
||||
<AIFieldConfiguration
|
||||
aiField={editableColumn}
|
||||
context={rowGoldenSample}
|
||||
|
@ -816,9 +932,7 @@
|
|||
schema={table.schema}
|
||||
/>
|
||||
{:else if editableColumn.type === FieldType.JSON}
|
||||
<Button primary text on:click={openJsonSchemaEditor}>
|
||||
Open schema editor
|
||||
</Button>
|
||||
<Button primary on:click={openJsonSchemaEditor}>Open schema editor</Button>
|
||||
{/if}
|
||||
{#if editableColumn.type === FieldType.AUTO || editableColumn.autocolumn}
|
||||
<Select
|
||||
|
@ -839,8 +953,7 @@
|
|||
<Toggle
|
||||
value={required}
|
||||
on:change={onChangeRequired}
|
||||
disabled={primaryDisplay || hasDefault}
|
||||
thin
|
||||
disabled={hasPrimaryDisplay || hasDefault}
|
||||
text="Required"
|
||||
/>
|
||||
{/if}
|
||||
|
@ -895,7 +1008,7 @@
|
|||
|
||||
<div class="action-buttons">
|
||||
{#if !uneditable && originalName != null}
|
||||
<Button quiet warning text on:click={confirmDelete}>Delete</Button>
|
||||
<Button quiet warning on:click={confirmDelete}>Delete</Button>
|
||||
{/if}
|
||||
<Button secondary newStyles on:click={cancelEdit}>Cancel</Button>
|
||||
<Button
|
||||
|
|
|
@ -1,16 +1,18 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import { Modal, ModalContent, Body } from "@budibase/bbui"
|
||||
|
||||
export let title = ""
|
||||
export let body = ""
|
||||
export let okText = "Confirm"
|
||||
export let cancelText = "Cancel"
|
||||
export let onOk = undefined
|
||||
export let onCancel = undefined
|
||||
export let warning = true
|
||||
export let disabled = false
|
||||
export let title: string = ""
|
||||
export let body: string = ""
|
||||
export let okText: string = "Confirm"
|
||||
export let cancelText: string = "Cancel"
|
||||
export let size: "S" | "M" | "L" | "XL" | undefined = undefined
|
||||
export let onOk: (() => void) | undefined = undefined
|
||||
export let onCancel: (() => void) | undefined = undefined
|
||||
export let onClose: (() => void) | undefined = undefined
|
||||
export let warning: boolean = true
|
||||
export let disabled: boolean = false
|
||||
|
||||
let modal
|
||||
let modal: Modal
|
||||
|
||||
export const show = () => {
|
||||
modal.show()
|
||||
|
@ -20,14 +22,16 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:this={modal} on:hide={onCancel}>
|
||||
<Modal bind:this={modal} on:hide={onClose ?? onCancel}>
|
||||
<ModalContent
|
||||
onConfirm={onOk}
|
||||
{onCancel}
|
||||
{title}
|
||||
confirmText={okText}
|
||||
{cancelText}
|
||||
{warning}
|
||||
{disabled}
|
||||
{size}
|
||||
>
|
||||
<Body size="S">
|
||||
{body}
|
||||
|
|
|
@ -11,8 +11,8 @@
|
|||
export let errors
|
||||
export let relationshipOpts1
|
||||
export let relationshipOpts2
|
||||
export let primaryTableChanged
|
||||
export let secondaryTableChanged
|
||||
export let primaryTableChanged = undefined
|
||||
export let secondaryTableChanged = undefined
|
||||
export let primaryDisabled = true
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { goto, params } from "@roxi/routify"
|
||||
import { beforeUrlChange, goto, params } from "@roxi/routify"
|
||||
import { datasources, flags, integrations, queries } from "@/stores/builder"
|
||||
import { environment } from "@/stores/portal"
|
||||
import {
|
||||
|
@ -25,7 +25,7 @@
|
|||
EditorModes,
|
||||
} from "@/components/common/CodeMirrorEditor.svelte"
|
||||
import RestBodyInput from "./RestBodyInput.svelte"
|
||||
import { capitalise } from "@/helpers"
|
||||
import { capitalise, confirm } from "@/helpers"
|
||||
import { onMount } from "svelte"
|
||||
import restUtils from "@/helpers/data/utils"
|
||||
import {
|
||||
|
@ -50,6 +50,7 @@
|
|||
toBindingsArray,
|
||||
} from "@/dataBinding"
|
||||
import ConnectedQueryScreens from "./ConnectedQueryScreens.svelte"
|
||||
import AuthPicker from "./rest/AuthPicker.svelte"
|
||||
|
||||
export let queryId
|
||||
|
||||
|
@ -63,6 +64,7 @@
|
|||
let nestedSchemaFields = {}
|
||||
let saving
|
||||
let queryNameLabel
|
||||
let mounted = false
|
||||
|
||||
$: staticVariables = datasource?.config?.staticVariables || {}
|
||||
|
||||
|
@ -104,8 +106,10 @@
|
|||
|
||||
$: runtimeUrlQueries = readableToRuntimeMap(mergedBindings, breakQs)
|
||||
|
||||
$: originalQuery = originalQuery ?? cloneDeep(query)
|
||||
$: builtQuery = buildQuery(query, runtimeUrlQueries, requestBindings)
|
||||
$: originalQuery = mounted
|
||||
? originalQuery ?? cloneDeep(builtQuery)
|
||||
: undefined
|
||||
$: isModified = JSON.stringify(originalQuery) !== JSON.stringify(builtQuery)
|
||||
|
||||
function getSelectedQuery() {
|
||||
|
@ -208,11 +212,14 @@
|
|||
originalQuery = null
|
||||
|
||||
queryNameLabel.disableEditingState()
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
notifications.error(`Error saving query`)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
|
||||
return { ok: false }
|
||||
}
|
||||
|
||||
const validateQuery = async () => {
|
||||
|
@ -474,6 +481,38 @@
|
|||
staticVariables,
|
||||
restBindings
|
||||
)
|
||||
|
||||
mounted = true
|
||||
})
|
||||
|
||||
$beforeUrlChange(async () => {
|
||||
if (!isModified) {
|
||||
return true
|
||||
}
|
||||
|
||||
return await confirm({
|
||||
title: "Some updates are not saved",
|
||||
body: "Some of your changes are not yet saved. Do you want to save them before leaving?",
|
||||
okText: "Save and continue",
|
||||
cancelText: "Discard and continue",
|
||||
size: "M",
|
||||
onConfirm: async () => {
|
||||
const saveResult = await saveQuery()
|
||||
if (!saveResult.ok) {
|
||||
// We can't leave as the query was not properly saved
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
onCancel: () => {
|
||||
// Leave without saving anything
|
||||
return true
|
||||
},
|
||||
onClose: () => {
|
||||
return false
|
||||
},
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
@ -642,15 +681,13 @@
|
|||
<div class="auth-container">
|
||||
<div />
|
||||
<!-- spacer -->
|
||||
<div class="auth-select">
|
||||
<Select
|
||||
label="Auth"
|
||||
labelPosition="left"
|
||||
placeholder="None"
|
||||
bind:value={query.fields.authConfigId}
|
||||
options={authConfigs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AuthPicker
|
||||
bind:authConfigId={query.fields.authConfigId}
|
||||
bind:authConfigType={query.fields.authConfigType}
|
||||
{authConfigs}
|
||||
datasourceId={datasource._id}
|
||||
/>
|
||||
</div>
|
||||
</Tabs>
|
||||
</Layout>
|
||||
|
@ -853,10 +890,6 @@
|
|||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.auth-select {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
|
|
@ -0,0 +1,123 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
ActionButton,
|
||||
Body,
|
||||
Button,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
PopoverAlignment,
|
||||
} from "@budibase/bbui"
|
||||
import { goto } from "@roxi/routify"
|
||||
import { appStore, oauth2 } from "@/stores/builder"
|
||||
import DetailPopover from "@/components/common/DetailPopover.svelte"
|
||||
import { featureFlag } from "@/helpers"
|
||||
import { FeatureFlag, RestAuthType } from "@budibase/types"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
type Config = { label: string; value: string }
|
||||
|
||||
export let authConfigId: string | undefined
|
||||
export let authConfigType: RestAuthType | undefined
|
||||
export let authConfigs: Config[]
|
||||
export let datasourceId: string
|
||||
|
||||
let popover: DetailPopover
|
||||
let allConfigs: Config[]
|
||||
|
||||
$: allConfigs = [
|
||||
...authConfigs,
|
||||
...$oauth2.configs.map(c => ({
|
||||
label: c.name,
|
||||
value: c.id,
|
||||
})),
|
||||
]
|
||||
$: authConfig = allConfigs.find(c => c.value === authConfigId)
|
||||
|
||||
function addBasicConfiguration() {
|
||||
$goto(
|
||||
`/builder/app/${$appStore.appId}/data/datasource/${datasourceId}?&tab=Authentication`
|
||||
)
|
||||
}
|
||||
|
||||
function addOAuth2Configuration() {
|
||||
$goto(`/builder/app/${$appStore.appId}/settings/oauth2`)
|
||||
}
|
||||
|
||||
function selectConfiguration(id: string, type?: RestAuthType) {
|
||||
if (authConfigId === id) {
|
||||
authConfigId = undefined
|
||||
authConfigType = undefined
|
||||
} else {
|
||||
authConfigId = id
|
||||
authConfigType = type
|
||||
}
|
||||
popover.hide()
|
||||
}
|
||||
|
||||
$: title = !authConfig ? "Authentication" : `Auth: ${authConfig.label}`
|
||||
|
||||
$: oauth2Enabled = featureFlag.isEnabled(FeatureFlag.OAUTH2_CONFIG)
|
||||
|
||||
onMount(() => {
|
||||
oauth2.fetch()
|
||||
})
|
||||
</script>
|
||||
|
||||
<DetailPopover bind:this={popover} {title} align={PopoverAlignment.Right}>
|
||||
<div slot="anchor">
|
||||
<ActionButton icon="LockClosed" quiet selected>
|
||||
{#if !authConfig}
|
||||
Authentication
|
||||
{:else}
|
||||
Auth: {authConfig.label}
|
||||
{/if}
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<Body size="S" color="var(--spectrum-global-color-gray-700)">
|
||||
Basic & Bearer Authentication
|
||||
</Body>
|
||||
|
||||
{#if authConfigs.length}
|
||||
<List>
|
||||
{#each authConfigs as config}
|
||||
<ListItem
|
||||
title={config.label}
|
||||
on:click={() => selectConfiguration(config.value)}
|
||||
selected={config.value === authConfigId}
|
||||
/>
|
||||
{/each}
|
||||
</List>
|
||||
{/if}
|
||||
<div>
|
||||
<Button secondary icon="Add" on:click={addBasicConfiguration}
|
||||
>Add config</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if oauth2Enabled}
|
||||
<Divider />
|
||||
|
||||
<Body size="S" color="var(--spectrum-global-color-gray-700)">
|
||||
OAuth 2.0 (Token-Based Authentication)
|
||||
</Body>
|
||||
|
||||
{#if $oauth2.configs.length}
|
||||
<List>
|
||||
{#each $oauth2.configs as config}
|
||||
<ListItem
|
||||
title={config.name}
|
||||
on:click={() => selectConfiguration(config.id, RestAuthType.OAUTH2)}
|
||||
selected={config.id === authConfigId}
|
||||
/>
|
||||
{/each}
|
||||
</List>
|
||||
{/if}
|
||||
<div>
|
||||
<Button secondary icon="Add" on:click={addOAuth2Configuration}
|
||||
>Add OAuth2</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</DetailPopover>
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
export let title
|
||||
<script lang="ts">
|
||||
export let title = ""
|
||||
</script>
|
||||
|
||||
<div class="side-nav">
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
export let text
|
||||
export let url
|
||||
<script lang="ts">
|
||||
export let text = ""
|
||||
export let url = ""
|
||||
export let active = false
|
||||
export let disabled = false
|
||||
</script>
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
Hosting,
|
||||
} from "@budibase/types"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
import { UIField } from "@budibase/types"
|
||||
|
||||
const { TypeIconMap } = Constants
|
||||
|
||||
|
@ -27,7 +28,7 @@ export const AUTO_COLUMN_DISPLAY_NAMES: Record<
|
|||
UPDATED_AT: "Updated At",
|
||||
}
|
||||
|
||||
export const FIELDS = {
|
||||
export const FIELDS: Record<string, UIField> = {
|
||||
STRING: {
|
||||
name: "Text",
|
||||
type: FieldType.STRING,
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
|
||||
|
||||
export enum ConfirmOutput {}
|
||||
|
||||
export async function confirm(props: {
|
||||
title: string
|
||||
body?: string
|
||||
okText?: string
|
||||
cancelText?: string
|
||||
size?: "S" | "M" | "L" | "XL"
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
onClose?: () => void
|
||||
warning?: boolean
|
||||
}) {
|
||||
return await new Promise(resolve => {
|
||||
const dialog = new ConfirmDialog({
|
||||
target: document.body,
|
||||
props: {
|
||||
title: props.title,
|
||||
body: props.body,
|
||||
okText: props.okText,
|
||||
cancelText: props.cancelText,
|
||||
size: props.size,
|
||||
warning: props.warning,
|
||||
onOk: () => {
|
||||
dialog.$destroy()
|
||||
resolve(props.onConfirm?.() || true)
|
||||
},
|
||||
onCancel: () => {
|
||||
dialog.$destroy()
|
||||
resolve(props.onCancel?.() || false)
|
||||
},
|
||||
onClose: () => {
|
||||
dialog.$destroy()
|
||||
resolve(props.onClose?.() || false)
|
||||
},
|
||||
},
|
||||
})
|
||||
dialog.show()
|
||||
})
|
||||
}
|
|
@ -11,3 +11,4 @@ export {
|
|||
} from "./helpers"
|
||||
export * as featureFlag from "./featureFlags"
|
||||
export * as bindings from "./bindings"
|
||||
export * from "./confirm"
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<script>
|
||||
import { params } from "@roxi/routify"
|
||||
import { Tabs, Tab, Heading, Body, Layout } from "@budibase/bbui"
|
||||
import { datasources, integrations } from "@/stores/builder"
|
||||
import ICONS from "@/components/backend/DatasourceNavigator/icons"
|
||||
|
@ -15,7 +16,7 @@
|
|||
import { admin } from "@/stores/portal"
|
||||
import { IntegrationTypes } from "@/constants/backend"
|
||||
|
||||
let selectedPanel = null
|
||||
let selectedPanel = $params.tab ?? null
|
||||
let panelOptions = []
|
||||
|
||||
$: datasource = $datasources.selected
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import { Content, SideNav, SideNavItem } from "@/components/portal/page"
|
||||
import { Page, Layout, AbsTooltip, TooltipPosition } from "@budibase/bbui"
|
||||
import { url, isActive } from "@roxi/routify"
|
||||
import DeleteModal from "@/components/deploy/DeleteModal.svelte"
|
||||
import { isOnlyUser, appStore } from "@/stores/builder"
|
||||
import { featureFlag } from "@/helpers"
|
||||
import { FeatureFlag } from "@budibase/types"
|
||||
|
||||
let deleteModal
|
||||
let deleteModal: DeleteModal
|
||||
|
||||
$: oauth2Enabled = featureFlag.isEnabled(FeatureFlag.OAUTH2_CONFIG)
|
||||
</script>
|
||||
|
||||
<!-- routify:options index=4 -->
|
||||
|
@ -44,11 +48,18 @@
|
|||
url={$url("./version")}
|
||||
active={$isActive("./version")}
|
||||
/>
|
||||
{#if oauth2Enabled}
|
||||
<SideNavItem
|
||||
text="OAuth2"
|
||||
url={$url("./oauth2")}
|
||||
active={$isActive("./oauth2")}
|
||||
/>
|
||||
{/if}
|
||||
<div class="delete-action">
|
||||
<AbsTooltip
|
||||
position={TooltipPosition.Bottom}
|
||||
text={$isOnlyUser
|
||||
? null
|
||||
? undefined
|
||||
: "Unavailable - another user is editing this app"}
|
||||
>
|
||||
<SideNavItem
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { Button, Modal } from "@budibase/bbui"
|
||||
import OAuth2ConfigModalContent from "./OAuth2ConfigModalContent.svelte"
|
||||
|
||||
let modal: Modal
|
||||
|
||||
function openModal() {
|
||||
modal.show()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button cta size="M" on:click={openModal}>Add OAuth2</Button>
|
||||
<Modal bind:this={modal}>
|
||||
<OAuth2ConfigModalContent />
|
||||
</Modal>
|
|
@ -0,0 +1,53 @@
|
|||
<script lang="ts">
|
||||
import { oauth2 } from "@/stores/builder"
|
||||
import {
|
||||
ActionMenu,
|
||||
Icon,
|
||||
MenuItem,
|
||||
Modal,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import type { OAuth2Config } from "@budibase/types"
|
||||
import OAuth2ConfigModalContent from "./OAuth2ConfigModalContent.svelte"
|
||||
import { confirm } from "@/helpers"
|
||||
|
||||
export let row: OAuth2Config
|
||||
|
||||
let modal: Modal
|
||||
|
||||
function onEdit() {
|
||||
modal.show()
|
||||
}
|
||||
async function onDelete() {
|
||||
await confirm({
|
||||
title: "Confirm Deletion",
|
||||
body: `Deleting "${row.name}" cannot be undone. Are you sure?`,
|
||||
okText: "Delete Configuration",
|
||||
warning: true,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await oauth2.delete(row.id)
|
||||
notifications.success(`Config '${row.name}' deleted successfully`)
|
||||
} catch (e: any) {
|
||||
let message = "Error deleting config"
|
||||
if (e.message) {
|
||||
message += ` - ${e.message}`
|
||||
}
|
||||
notifications.error(message)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<ActionMenu align="right">
|
||||
<div slot="control" class="control icon">
|
||||
<Icon size="S" hoverable name="MoreSmallList" />
|
||||
</div>
|
||||
<MenuItem on:click={onEdit} icon="Edit">Edit</MenuItem>
|
||||
<MenuItem on:click={onDelete} icon="Delete">Delete</MenuItem>
|
||||
</ActionMenu>
|
||||
|
||||
<Modal bind:this={modal}>
|
||||
<OAuth2ConfigModalContent config={{ ...row }} />
|
||||
</Modal>
|
|
@ -0,0 +1,194 @@
|
|||
<script lang="ts">
|
||||
import { oauth2 } from "@/stores/builder"
|
||||
import type { OAuth2Config, UpsertOAuth2Config } from "@/types"
|
||||
import {
|
||||
Body,
|
||||
Divider,
|
||||
Heading,
|
||||
Input,
|
||||
keepOpen,
|
||||
Link,
|
||||
ModalContent,
|
||||
notifications,
|
||||
Select,
|
||||
} from "@budibase/bbui"
|
||||
import {
|
||||
OAuth2CredentialsMethod,
|
||||
PASSWORD_REPLACEMENT,
|
||||
} from "@budibase/types"
|
||||
import type { ZodType } from "zod"
|
||||
import { z } from "zod"
|
||||
|
||||
export let config: OAuth2Config | undefined = undefined
|
||||
|
||||
let errors: Record<string, string> = {}
|
||||
let hasBeenSubmitted = false
|
||||
|
||||
$: data = (config as Partial<OAuth2Config>) ?? {}
|
||||
|
||||
$: isCreation = !config
|
||||
$: title = isCreation
|
||||
? "Create new OAuth2 connection"
|
||||
: "Edit OAuth2 connection"
|
||||
|
||||
const methods = [
|
||||
{
|
||||
label: "Basic",
|
||||
value: OAuth2CredentialsMethod.HEADER,
|
||||
},
|
||||
{
|
||||
label: "POST",
|
||||
value: OAuth2CredentialsMethod.BODY,
|
||||
},
|
||||
]
|
||||
|
||||
const requiredString = (errorMessage: string) =>
|
||||
z.string({ required_error: errorMessage }).trim().min(1, errorMessage)
|
||||
|
||||
const validateConfig = (config: Partial<OAuth2Config>) => {
|
||||
const validator = z.object({
|
||||
name: requiredString("Name is required.").refine(
|
||||
val =>
|
||||
!$oauth2.configs
|
||||
.filter(c => c.id !== config.id)
|
||||
.map(c => c.name.toLowerCase())
|
||||
.includes(val.toLowerCase()),
|
||||
{
|
||||
message: "This name is already taken.",
|
||||
}
|
||||
),
|
||||
url: requiredString("Url is required.").url(),
|
||||
clientId: requiredString("Client ID is required."),
|
||||
clientSecret: requiredString("Client secret is required."),
|
||||
method: z.nativeEnum(OAuth2CredentialsMethod, {
|
||||
message: "Authentication method is required.",
|
||||
}),
|
||||
}) satisfies ZodType<UpsertOAuth2Config>
|
||||
|
||||
const validationResult = validator.safeParse(config)
|
||||
errors = {}
|
||||
if (!validationResult.success) {
|
||||
errors = Object.entries(
|
||||
validationResult.error.formErrors.fieldErrors
|
||||
).reduce<Record<string, string>>((acc, [field, errors]) => {
|
||||
if (errors[0]) {
|
||||
acc[field] = errors[0]
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
return validationResult
|
||||
}
|
||||
|
||||
$: saveOAuth2Config = async () => {
|
||||
hasBeenSubmitted = true
|
||||
const validationResult = validateConfig(data)
|
||||
if (validationResult.error) {
|
||||
return keepOpen
|
||||
}
|
||||
|
||||
const { data: configData } = validationResult
|
||||
try {
|
||||
const connectionValidation = await oauth2.validate({
|
||||
id: config?.id,
|
||||
url: configData.url,
|
||||
clientId: configData.clientId,
|
||||
clientSecret: configData.clientSecret,
|
||||
method: configData.method,
|
||||
})
|
||||
if (!connectionValidation.valid) {
|
||||
let message = "Connection settings could not be validated"
|
||||
if (connectionValidation.message) {
|
||||
message += `: ${connectionValidation.message}`
|
||||
}
|
||||
notifications.error(message)
|
||||
return keepOpen
|
||||
}
|
||||
|
||||
if (isCreation) {
|
||||
await oauth2.create(configData)
|
||||
notifications.success("Settings created.")
|
||||
} else {
|
||||
await oauth2.edit(config!.id, configData)
|
||||
notifications.success("Settings saved.")
|
||||
}
|
||||
} catch (e: any) {
|
||||
notifications.error(`Failed to save config - ${e.message}`)
|
||||
return keepOpen
|
||||
}
|
||||
}
|
||||
|
||||
$: hasBeenSubmitted && validateConfig(data)
|
||||
|
||||
$: isProtectedPassword = config?.clientSecret === PASSWORD_REPLACEMENT
|
||||
</script>
|
||||
|
||||
<ModalContent onConfirm={saveOAuth2Config} size="M">
|
||||
<Heading size="S">{title}</Heading>
|
||||
|
||||
<Body size="S">
|
||||
The OAuth 2 authentication below uses the Client Credentials (machine to
|
||||
machine) grant type.
|
||||
</Body>
|
||||
<Divider noGrid noMargin />
|
||||
<Input
|
||||
label="Name*"
|
||||
placeholder="Type here..."
|
||||
bind:value={data.name}
|
||||
error={errors.name}
|
||||
/>
|
||||
<Select
|
||||
label="Authentication method*"
|
||||
options={methods}
|
||||
getOptionLabel={o => o.label}
|
||||
getOptionValue={o => o.value}
|
||||
bind:value={data.method}
|
||||
error={errors.method}
|
||||
/>
|
||||
<div class="field-info">
|
||||
<Body size="XS" color="var(--spectrum-global-color-gray-700)">
|
||||
Basic will use the Authorisation Bearer header for each connection, while
|
||||
POST will include the credentials in the body of the request under the
|
||||
access_token property.
|
||||
</Body>
|
||||
</div>
|
||||
<Input
|
||||
label="Service URL*"
|
||||
placeholder="E.g. www.google.com"
|
||||
bind:value={data.url}
|
||||
error={errors.url}
|
||||
/>
|
||||
<div class="field-info">
|
||||
<Body size="XS" color="var(--spectrum-global-color-gray-700)">
|
||||
The location where the flow sends the credentials. This field should be a
|
||||
full URL.
|
||||
</Body>
|
||||
</div>
|
||||
<Input
|
||||
label="Client ID*"
|
||||
placeholder="Type here..."
|
||||
bind:value={data.clientId}
|
||||
error={errors.clientId}
|
||||
/>
|
||||
<Input
|
||||
type={!isProtectedPassword ? "password" : "text"}
|
||||
label="Client secret*"
|
||||
placeholder="Type here..."
|
||||
bind:value={data.clientSecret}
|
||||
error={errors.clientSecret}
|
||||
/>
|
||||
<Body size="S"
|
||||
>To learn how to configure OAuth2, our documentation <Link
|
||||
href="TODO"
|
||||
target="_blank"
|
||||
size="M">our documentation.</Link
|
||||
></Body
|
||||
>
|
||||
</ModalContent>
|
||||
|
||||
<style>
|
||||
.field-info {
|
||||
margin-top: calc(var(--spacing-xl) * -1 + var(--spacing-s));
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,61 @@
|
|||
<script lang="ts">
|
||||
import { Layout, Heading, Body, Divider, Table } from "@budibase/bbui"
|
||||
import { oauth2 } from "@/stores/builder"
|
||||
import AddButton from "./AddButton.svelte"
|
||||
import { onMount } from "svelte"
|
||||
import MoreMenuRenderer from "./MoreMenuRenderer.svelte"
|
||||
|
||||
const schema = {
|
||||
name: {
|
||||
sortable: false,
|
||||
},
|
||||
lastUsed: {
|
||||
displayName: "Last used",
|
||||
sortable: false,
|
||||
},
|
||||
more: {
|
||||
width: "auto",
|
||||
displayName: "",
|
||||
},
|
||||
}
|
||||
const customRenderers = [{ column: "more", component: MoreMenuRenderer }]
|
||||
|
||||
onMount(() => {
|
||||
oauth2.fetch()
|
||||
})
|
||||
|
||||
$: configs = $oauth2.configs.map(c => ({
|
||||
lastUsed: "Never used",
|
||||
...c,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<Layout noPadding>
|
||||
<Layout gap="XS" noPadding>
|
||||
<div class="header">
|
||||
<Heading>OAuth2</Heading>
|
||||
<AddButton />
|
||||
</div>
|
||||
<Body
|
||||
>Manage and configure OAuth 2.0 Client Credentials for secure API access.</Body
|
||||
>
|
||||
</Layout>
|
||||
<Divider />
|
||||
|
||||
<Table
|
||||
data={configs}
|
||||
loading={$oauth2.loading}
|
||||
{schema}
|
||||
{customRenderers}
|
||||
allowEditRows={false}
|
||||
allowEditColumns={false}
|
||||
allowClickRows={false}
|
||||
/>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
|
@ -37,6 +37,9 @@ import { flags } from "./flags"
|
|||
import { rowActions } from "./rowActions"
|
||||
import componentTreeNodesStore from "./componentTreeNodes"
|
||||
import { appPublished } from "./published"
|
||||
import { oauth2 } from "./oauth2"
|
||||
|
||||
import { FetchAppPackageResponse } from "@budibase/types"
|
||||
|
||||
export {
|
||||
componentTreeNodesStore,
|
||||
|
@ -77,6 +80,7 @@ export {
|
|||
screenComponentsList,
|
||||
screenComponentErrors,
|
||||
screenComponentErrorList,
|
||||
oauth2,
|
||||
}
|
||||
|
||||
export const reset = () => {
|
||||
|
@ -106,7 +110,7 @@ const resetBuilderHistory = () => {
|
|||
automationHistoryStore.reset()
|
||||
}
|
||||
|
||||
export const initialise = async pkg => {
|
||||
export const initialise = async (pkg: FetchAppPackageResponse) => {
|
||||
const { application } = pkg
|
||||
// must be first operation to make sure subsequent requests have correct app ID
|
||||
appStore.syncAppPackage(pkg)
|
|
@ -15,7 +15,7 @@ export class NavigationStore extends BudiStore<AppNavigation> {
|
|||
super(INITIAL_NAVIGATION_STATE)
|
||||
}
|
||||
|
||||
syncAppNavigation(nav: AppNavigation) {
|
||||
syncAppNavigation(nav?: AppNavigation) {
|
||||
this.update(state => ({
|
||||
...state,
|
||||
...nav,
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
import { API } from "@/api"
|
||||
import { BudiStore } from "@/stores/BudiStore"
|
||||
import { OAuth2Config, UpsertOAuth2Config } from "@/types"
|
||||
import { ValidateConfigRequest } from "@budibase/types"
|
||||
|
||||
interface OAuth2StoreState {
|
||||
configs: OAuth2Config[]
|
||||
loading: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class OAuth2Store extends BudiStore<OAuth2StoreState> {
|
||||
constructor() {
|
||||
super({
|
||||
configs: [],
|
||||
loading: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fetch() {
|
||||
this.store.update(store => ({
|
||||
...store,
|
||||
loading: true,
|
||||
}))
|
||||
try {
|
||||
const configs = await API.oauth2.fetch()
|
||||
this.store.update(store => ({
|
||||
...store,
|
||||
configs: configs.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
url: c.url,
|
||||
clientId: c.clientId,
|
||||
clientSecret: c.clientSecret,
|
||||
method: c.method,
|
||||
})),
|
||||
loading: false,
|
||||
}))
|
||||
} catch (e: any) {
|
||||
this.store.update(store => ({
|
||||
...store,
|
||||
loading: false,
|
||||
error: e.message,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async create(config: UpsertOAuth2Config) {
|
||||
await API.oauth2.create(config)
|
||||
await this.fetch()
|
||||
}
|
||||
|
||||
async edit(id: string, config: UpsertOAuth2Config) {
|
||||
await API.oauth2.update(id, config)
|
||||
await this.fetch()
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
await API.oauth2.delete(id)
|
||||
await this.fetch()
|
||||
}
|
||||
|
||||
async validate(config: ValidateConfigRequest) {
|
||||
return await API.oauth2.validate(config)
|
||||
}
|
||||
}
|
||||
|
||||
export const oauth2 = new OAuth2Store()
|
|
@ -148,13 +148,11 @@ export class TableStore extends DerivedBudiStore<
|
|||
async saveField({
|
||||
originalName,
|
||||
field,
|
||||
primaryDisplay = false,
|
||||
indexes,
|
||||
hasPrimaryDisplay = false,
|
||||
}: {
|
||||
originalName: string
|
||||
originalName?: string
|
||||
field: FieldSchema
|
||||
primaryDisplay: boolean
|
||||
indexes: Record<string, any>
|
||||
hasPrimaryDisplay: boolean
|
||||
}) {
|
||||
const draft: SaveTableRequest = cloneDeep(get(this.derivedStore).selected!)
|
||||
|
||||
|
@ -169,7 +167,7 @@ export class TableStore extends DerivedBudiStore<
|
|||
}
|
||||
|
||||
// Optionally set display column
|
||||
if (primaryDisplay) {
|
||||
if (hasPrimaryDisplay) {
|
||||
draft.primaryDisplay = field.name
|
||||
} else if (draft.primaryDisplay === originalName) {
|
||||
const fields = Object.keys(draft.schema)
|
||||
|
@ -178,9 +176,6 @@ export class TableStore extends DerivedBudiStore<
|
|||
name => name !== originalName || name !== field.name
|
||||
)[0]
|
||||
}
|
||||
if (indexes) {
|
||||
draft.indexes = indexes
|
||||
}
|
||||
draft.schema = {
|
||||
...draft.schema,
|
||||
[field.name]: cloneDeep(field),
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export * from "./bindings"
|
||||
export * from "./oauth2"
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import {
|
||||
UpsertOAuth2ConfigRequest,
|
||||
OAuth2ConfigResponse,
|
||||
} from "@budibase/types"
|
||||
|
||||
export interface OAuth2Config extends OAuth2ConfigResponse {}
|
||||
|
||||
export interface UpsertOAuth2Config extends UpsertOAuth2ConfigRequest {}
|
|
@ -1,10 +1,9 @@
|
|||
<script context="module" lang="ts">
|
||||
type ValueType = string | string[]
|
||||
type BasicRelatedRow = { _id: string; primaryDisplay: string }
|
||||
type OptionsMap = Record<string, BasicRelatedRow>
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
<script lang="ts" generics="ValueType extends string | string[]">
|
||||
import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
|
||||
import {
|
||||
BasicOperator,
|
||||
|
@ -67,7 +66,7 @@
|
|||
fieldSchema?.relationshipType !== "one-to-many"
|
||||
|
||||
// Get the proper string representation of the value
|
||||
$: realValue = fieldState?.value
|
||||
$: realValue = fieldState?.value as ValueType
|
||||
$: selectedValue = parseSelectedValue(realValue, multiselect)
|
||||
$: selectedIDs = getSelectedIDs(selectedValue)
|
||||
|
||||
|
|
|
@ -45,6 +45,7 @@ import { buildAuditLogEndpoints } from "./auditLogs"
|
|||
import { buildLogsEndpoints } from "./logs"
|
||||
import { buildMigrationEndpoints } from "./migrations"
|
||||
import { buildRowActionEndpoints } from "./rowActions"
|
||||
import { buildOAuth2Endpoints } from "./oauth2"
|
||||
|
||||
export type { APIClient } from "./types"
|
||||
|
||||
|
@ -290,5 +291,6 @@ export const createAPIClient = (config: APIClientConfig = {}): APIClient => {
|
|||
...buildMigrationEndpoints(API),
|
||||
viewV2: buildViewV2Endpoints(API),
|
||||
rowActions: buildRowActionEndpoints(API),
|
||||
oauth2: buildOAuth2Endpoints(API),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
import {
|
||||
FetchOAuth2ConfigsResponse,
|
||||
OAuth2ConfigResponse,
|
||||
UpsertOAuth2ConfigRequest,
|
||||
UpsertOAuth2ConfigResponse,
|
||||
ValidateConfigRequest,
|
||||
ValidateConfigResponse,
|
||||
} from "@budibase/types"
|
||||
import { BaseAPIClient } from "./types"
|
||||
|
||||
export interface OAuth2Endpoints {
|
||||
fetch: () => Promise<OAuth2ConfigResponse[]>
|
||||
create: (
|
||||
config: UpsertOAuth2ConfigRequest
|
||||
) => Promise<UpsertOAuth2ConfigResponse>
|
||||
update: (
|
||||
id: string,
|
||||
config: UpsertOAuth2ConfigRequest
|
||||
) => Promise<UpsertOAuth2ConfigResponse>
|
||||
delete: (id: string) => Promise<void>
|
||||
validate: (config: ValidateConfigRequest) => Promise<ValidateConfigResponse>
|
||||
}
|
||||
|
||||
export const buildOAuth2Endpoints = (API: BaseAPIClient): OAuth2Endpoints => ({
|
||||
/**
|
||||
* Gets all OAuth2 configurations for the app.
|
||||
*/
|
||||
fetch: async () => {
|
||||
return (
|
||||
await API.get<FetchOAuth2ConfigsResponse>({
|
||||
url: `/api/oauth2`,
|
||||
})
|
||||
).configs
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a OAuth2 configuration.
|
||||
*/
|
||||
create: async config => {
|
||||
return await API.post<
|
||||
UpsertOAuth2ConfigRequest,
|
||||
UpsertOAuth2ConfigResponse
|
||||
>({
|
||||
url: `/api/oauth2`,
|
||||
body: {
|
||||
...config,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates an existing OAuth2 configuration.
|
||||
*/
|
||||
update: async (id, config) => {
|
||||
return await API.put<UpsertOAuth2ConfigRequest, UpsertOAuth2ConfigResponse>(
|
||||
{
|
||||
url: `/api/oauth2/${id}`,
|
||||
body: {
|
||||
...config,
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes an OAuth2 configuration by its id.
|
||||
* @param id the ID of the OAuth2 config
|
||||
*/
|
||||
delete: async id => {
|
||||
return await API.delete<void, void>({
|
||||
url: `/api/oauth2/${id}`,
|
||||
})
|
||||
},
|
||||
validate: async function (
|
||||
config: ValidateConfigRequest
|
||||
): Promise<ValidateConfigResponse> {
|
||||
return await API.post<ValidateConfigRequest, ValidateConfigResponse>({
|
||||
url: `/api/oauth2/validate`,
|
||||
body: {
|
||||
...config,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
|
@ -16,6 +16,7 @@ import { LayoutEndpoints } from "./layouts"
|
|||
import { LicensingEndpoints } from "./licensing"
|
||||
import { LogEndpoints } from "./logs"
|
||||
import { MigrationEndpoints } from "./migrations"
|
||||
import { OAuth2Endpoints } from "./oauth2"
|
||||
import { OtherEndpoints } from "./other"
|
||||
import { PermissionEndpoints } from "./permissions"
|
||||
import { PluginEndpoins } from "./plugins"
|
||||
|
@ -132,4 +133,8 @@ export type APIClient = BaseAPIClient &
|
|||
TableEndpoints &
|
||||
TemplateEndpoints &
|
||||
UserEndpoints &
|
||||
ViewEndpoints & { rowActions: RowActionEndpoints; viewV2: ViewV2Endpoints }
|
||||
ViewEndpoints & {
|
||||
rowActions: RowActionEndpoints
|
||||
viewV2: ViewV2Endpoints
|
||||
oauth2: OAuth2Endpoints
|
||||
}
|
||||
|
|
|
@ -4,4 +4,5 @@ POSTGRES_SHA=sha256:bd0d8e485d1aca439d39e5ea99b931160bd28d862e74c786f7508e9d0053
|
|||
MONGODB_SHA=sha256:afa36bca12295b5f9dae68a493c706113922bdab520e901bd5d6c9d7247a1d8d
|
||||
MARIADB_SHA=sha256:e59ba8783bf7bc02a4779f103bb0d8751ac0e10f9471089709608377eded7aa8
|
||||
ELASTICSEARCH_SHA=sha256:9a6443f55243f6acbfeb4a112d15eb3b9aac74bf25e0e39fa19b3ddd3a6879d0
|
||||
DYNAMODB_SHA=sha256:cf8cebd061f988628c02daff10fdb950a54478feff9c52f6ddf84710fe3c3906
|
||||
DYNAMODB_SHA=sha256:cf8cebd061f988628c02daff10fdb950a54478feff9c52f6ddf84710fe3c3906
|
||||
KEYCLOAK_IMAGE=keycloak/keycloak@sha256:044a457e04987e1fff756be3d2fa325a4ef420fa356b7034ecc9f1b693c32761
|
|
@ -1,28 +1,107 @@
|
|||
import {
|
||||
CreateOAuth2ConfigRequest,
|
||||
UpsertOAuth2ConfigRequest,
|
||||
UpsertOAuth2ConfigResponse,
|
||||
Ctx,
|
||||
FetchOAuth2ConfigsResponse,
|
||||
OAuth2Config,
|
||||
RequiredKeys,
|
||||
OAuth2ConfigResponse,
|
||||
PASSWORD_REPLACEMENT,
|
||||
ValidateConfigResponse,
|
||||
ValidateConfigRequest,
|
||||
} from "@budibase/types"
|
||||
import sdk from "../../sdk"
|
||||
|
||||
function toFetchOAuth2ConfigsResponse(
|
||||
config: OAuth2Config
|
||||
): OAuth2ConfigResponse {
|
||||
return {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
url: config.url,
|
||||
clientId: config.clientId,
|
||||
clientSecret: PASSWORD_REPLACEMENT,
|
||||
method: config.method,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetch(ctx: Ctx<void, FetchOAuth2ConfigsResponse>) {
|
||||
const configs = await sdk.oauth2.fetch()
|
||||
|
||||
const response: FetchOAuth2ConfigsResponse = {
|
||||
configs: (configs || []).map(c => ({
|
||||
name: c.name,
|
||||
})),
|
||||
configs: (configs || []).map(toFetchOAuth2ConfigsResponse),
|
||||
}
|
||||
ctx.body = response
|
||||
}
|
||||
|
||||
export async function create(ctx: Ctx<CreateOAuth2ConfigRequest, void>) {
|
||||
const newConfig: RequiredKeys<OAuth2Config> = {
|
||||
name: ctx.request.body.name,
|
||||
export async function create(
|
||||
ctx: Ctx<UpsertOAuth2ConfigRequest, UpsertOAuth2ConfigResponse>
|
||||
) {
|
||||
const { body } = ctx.request
|
||||
const newConfig: RequiredKeys<Omit<OAuth2Config, "id">> = {
|
||||
name: body.name,
|
||||
url: body.url,
|
||||
clientId: body.clientId,
|
||||
clientSecret: body.clientSecret,
|
||||
method: body.method,
|
||||
}
|
||||
|
||||
await sdk.oauth2.create(newConfig)
|
||||
const config = await sdk.oauth2.create(newConfig)
|
||||
ctx.status = 201
|
||||
ctx.body = {
|
||||
config: toFetchOAuth2ConfigsResponse(config),
|
||||
}
|
||||
}
|
||||
|
||||
export async function edit(
|
||||
ctx: Ctx<UpsertOAuth2ConfigRequest, UpsertOAuth2ConfigResponse>
|
||||
) {
|
||||
const { body } = ctx.request
|
||||
const toUpdate: RequiredKeys<OAuth2Config> = {
|
||||
id: ctx.params.id,
|
||||
name: body.name,
|
||||
url: body.url,
|
||||
clientId: body.clientId,
|
||||
clientSecret: body.clientSecret,
|
||||
method: body.method,
|
||||
}
|
||||
|
||||
const config = await sdk.oauth2.update(toUpdate)
|
||||
ctx.body = {
|
||||
config: toFetchOAuth2ConfigsResponse(config),
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(
|
||||
ctx: Ctx<UpsertOAuth2ConfigRequest, UpsertOAuth2ConfigResponse>
|
||||
) {
|
||||
const configToRemove = ctx.params.id
|
||||
|
||||
await sdk.oauth2.remove(configToRemove)
|
||||
ctx.status = 204
|
||||
}
|
||||
|
||||
export async function validate(
|
||||
ctx: Ctx<ValidateConfigRequest, ValidateConfigResponse>
|
||||
) {
|
||||
const { body } = ctx.request
|
||||
const config = {
|
||||
url: body.url,
|
||||
clientId: body.clientId,
|
||||
clientSecret: body.clientSecret,
|
||||
method: body.method,
|
||||
}
|
||||
|
||||
if (config.clientSecret === PASSWORD_REPLACEMENT && body.id) {
|
||||
const existingConfig = await sdk.oauth2.get(body.id)
|
||||
if (!existingConfig) {
|
||||
ctx.throw(`OAuth2 config with id '${body.id}' not found.`, 404)
|
||||
}
|
||||
|
||||
config.clientSecret = existingConfig.clientSecret
|
||||
}
|
||||
|
||||
const validation = await sdk.oauth2.validateConfig(config)
|
||||
ctx.status = 201
|
||||
ctx.body = validation
|
||||
}
|
||||
|
|
|
@ -1,8 +1,39 @@
|
|||
import Router from "@koa/router"
|
||||
import { PermissionType } from "@budibase/types"
|
||||
import { OAuth2CredentialsMethod, PermissionType } from "@budibase/types"
|
||||
import { middleware } from "@budibase/backend-core"
|
||||
import authorized from "../../middleware/authorized"
|
||||
|
||||
import * as controller from "../controllers/oauth2"
|
||||
import Joi from "joi"
|
||||
|
||||
const baseValidation = {
|
||||
url: Joi.string().required(),
|
||||
clientId: Joi.string().required(),
|
||||
clientSecret: Joi.string().required(),
|
||||
method: Joi.string()
|
||||
.required()
|
||||
.valid(...Object.values(OAuth2CredentialsMethod)),
|
||||
}
|
||||
|
||||
function oAuth2ConfigValidator() {
|
||||
return middleware.joiValidator.body(
|
||||
Joi.object({
|
||||
name: Joi.string().required(),
|
||||
...baseValidation,
|
||||
}),
|
||||
{ allowUnknown: false }
|
||||
)
|
||||
}
|
||||
|
||||
function oAuth2ConfigValidationValidator() {
|
||||
return middleware.joiValidator.body(
|
||||
Joi.object({
|
||||
id: Joi.string().required(),
|
||||
...baseValidation,
|
||||
}),
|
||||
{ allowUnknown: false }
|
||||
)
|
||||
}
|
||||
|
||||
const router: Router = new Router()
|
||||
|
||||
|
@ -10,7 +41,25 @@ router.get("/api/oauth2", authorized(PermissionType.BUILDER), controller.fetch)
|
|||
router.post(
|
||||
"/api/oauth2",
|
||||
authorized(PermissionType.BUILDER),
|
||||
oAuth2ConfigValidator(),
|
||||
controller.create
|
||||
)
|
||||
router.put(
|
||||
"/api/oauth2/:id",
|
||||
authorized(PermissionType.BUILDER),
|
||||
oAuth2ConfigValidator(),
|
||||
controller.edit
|
||||
)
|
||||
router.delete(
|
||||
"/api/oauth2/:id",
|
||||
authorized(PermissionType.BUILDER),
|
||||
controller.remove
|
||||
)
|
||||
router.post(
|
||||
"/api/oauth2/validate",
|
||||
authorized(PermissionType.BUILDER),
|
||||
oAuth2ConfigValidationValidator(),
|
||||
controller.validate
|
||||
)
|
||||
|
||||
export default router
|
||||
|
|
|
@ -1,13 +1,24 @@
|
|||
import { CreateOAuth2ConfigRequest } from "@budibase/types"
|
||||
import {
|
||||
OAuth2Config,
|
||||
OAuth2CredentialsMethod,
|
||||
PASSWORD_REPLACEMENT,
|
||||
UpsertOAuth2ConfigRequest,
|
||||
VirtualDocumentType,
|
||||
} from "@budibase/types"
|
||||
import * as setup from "./utilities"
|
||||
import { generator } from "@budibase/backend-core/tests"
|
||||
import _ from "lodash/fp"
|
||||
|
||||
describe("/oauth2", () => {
|
||||
let config = setup.getConfig()
|
||||
|
||||
function makeOAuth2Config(): CreateOAuth2ConfigRequest {
|
||||
function makeOAuth2Config(): UpsertOAuth2ConfigRequest {
|
||||
return {
|
||||
name: generator.guid(),
|
||||
url: generator.url(),
|
||||
clientId: generator.guid(),
|
||||
clientSecret: generator.hash(),
|
||||
method: generator.pickone(Object.values(OAuth2CredentialsMethod)),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -15,6 +26,10 @@ describe("/oauth2", () => {
|
|||
|
||||
beforeEach(async () => await config.newTenant())
|
||||
|
||||
const expectOAuth2ConfigId = expect.stringMatching(
|
||||
`^${VirtualDocumentType.OAUTH2_CONFIG}_.+$`
|
||||
)
|
||||
|
||||
describe("fetch", () => {
|
||||
it("returns empty when no oauth are created", async () => {
|
||||
const response = await config.api.oauth2.fetch()
|
||||
|
@ -22,6 +37,30 @@ describe("/oauth2", () => {
|
|||
configs: [],
|
||||
})
|
||||
})
|
||||
|
||||
it("returns all created configs", async () => {
|
||||
const existingConfigs = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const oauth2Config = makeOAuth2Config()
|
||||
const result = await config.api.oauth2.create(oauth2Config)
|
||||
existingConfigs.push({ ...oauth2Config, id: result.config.id })
|
||||
}
|
||||
|
||||
const response = await config.api.oauth2.fetch()
|
||||
expect(response.configs).toHaveLength(existingConfigs.length)
|
||||
expect(response).toEqual({
|
||||
configs: expect.arrayContaining(
|
||||
existingConfigs.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
url: c.url,
|
||||
clientId: c.clientId,
|
||||
clientSecret: PASSWORD_REPLACEMENT,
|
||||
method: c.method,
|
||||
}))
|
||||
),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("create", () => {
|
||||
|
@ -33,7 +72,12 @@ describe("/oauth2", () => {
|
|||
expect(response).toEqual({
|
||||
configs: [
|
||||
{
|
||||
id: expectOAuth2ConfigId,
|
||||
name: oauth2Config.name,
|
||||
url: oauth2Config.url,
|
||||
clientId: oauth2Config.clientId,
|
||||
clientSecret: PASSWORD_REPLACEMENT,
|
||||
method: oauth2Config.method,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
@ -48,12 +92,23 @@ describe("/oauth2", () => {
|
|||
const response = await config.api.oauth2.fetch()
|
||||
expect(response.configs).toEqual([
|
||||
{
|
||||
id: expectOAuth2ConfigId,
|
||||
name: oauth2Config.name,
|
||||
url: oauth2Config.url,
|
||||
clientId: oauth2Config.clientId,
|
||||
clientSecret: PASSWORD_REPLACEMENT,
|
||||
method: oauth2Config.method,
|
||||
},
|
||||
{
|
||||
id: expectOAuth2ConfigId,
|
||||
name: oauth2Config2.name,
|
||||
url: oauth2Config2.url,
|
||||
clientId: oauth2Config2.clientId,
|
||||
clientSecret: PASSWORD_REPLACEMENT,
|
||||
method: oauth2Config2.method,
|
||||
},
|
||||
])
|
||||
expect(response.configs[0].id).not.toEqual(response.configs[1].id)
|
||||
})
|
||||
|
||||
it("cannot create configurations with already existing names", async () => {
|
||||
|
@ -71,9 +126,109 @@ describe("/oauth2", () => {
|
|||
const response = await config.api.oauth2.fetch()
|
||||
expect(response.configs).toEqual([
|
||||
{
|
||||
id: expectOAuth2ConfigId,
|
||||
name: oauth2Config.name,
|
||||
url: oauth2Config.url,
|
||||
clientId: oauth2Config.clientId,
|
||||
clientSecret: PASSWORD_REPLACEMENT,
|
||||
method: oauth2Config.method,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("update", () => {
|
||||
let existingConfigs: OAuth2Config[] = []
|
||||
|
||||
beforeEach(async () => {
|
||||
existingConfigs = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const oauth2Config = makeOAuth2Config()
|
||||
const result = await config.api.oauth2.create(oauth2Config)
|
||||
|
||||
existingConfigs.push({ ...oauth2Config, id: result.config.id })
|
||||
}
|
||||
})
|
||||
|
||||
it("can update an existing configuration", async () => {
|
||||
const { id: configId, ...configData } = _.sample(existingConfigs)!
|
||||
|
||||
await config.api.oauth2.update(configId, {
|
||||
...configData,
|
||||
name: "updated name",
|
||||
})
|
||||
|
||||
const response = await config.api.oauth2.fetch()
|
||||
expect(response.configs).toHaveLength(existingConfigs.length)
|
||||
expect(response.configs).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
id: configId,
|
||||
name: "updated name",
|
||||
url: configData.url,
|
||||
clientId: configData.clientId,
|
||||
clientSecret: PASSWORD_REPLACEMENT,
|
||||
method: configData.method,
|
||||
},
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("throw if config not found", async () => {
|
||||
await config.api.oauth2.update("unexisting", makeOAuth2Config(), {
|
||||
status: 404,
|
||||
body: { message: "OAuth2 config with id 'unexisting' not found." },
|
||||
})
|
||||
})
|
||||
|
||||
it("throws if trying to use an existing name", async () => {
|
||||
const [config1, config2] = _.sampleSize(2, existingConfigs)
|
||||
const { id: configId, ...configData } = config1
|
||||
|
||||
await config.api.oauth2.update(
|
||||
configId,
|
||||
{
|
||||
...configData,
|
||||
name: config2.name,
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
body: {
|
||||
message: `OAuth2 config with name '${config2.name}' is already taken.`,
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("delete", () => {
|
||||
let existingConfigs: OAuth2Config[] = []
|
||||
|
||||
beforeEach(async () => {
|
||||
existingConfigs = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const oauth2Config = makeOAuth2Config()
|
||||
const result = await config.api.oauth2.create(oauth2Config)
|
||||
|
||||
existingConfigs.push({ ...oauth2Config, id: result.config.id })
|
||||
}
|
||||
})
|
||||
|
||||
it("can delete an existing configuration", async () => {
|
||||
const { id: configId } = _.sample(existingConfigs)!
|
||||
|
||||
await config.api.oauth2.delete(configId, { status: 204 })
|
||||
|
||||
const response = await config.api.oauth2.fetch()
|
||||
expect(response.configs).toHaveLength(existingConfigs.length - 1)
|
||||
expect(response.configs.find(c => c.id === configId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("throw if config not found", async () => {
|
||||
await config.api.oauth2.delete("unexisting", {
|
||||
status: 404,
|
||||
body: { message: "OAuth2 config with id 'unexisting' not found." },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -2195,95 +2195,94 @@ if (descriptions.length) {
|
|||
})
|
||||
})
|
||||
|
||||
isInternal &&
|
||||
describe("attachments and signatures", () => {
|
||||
const coreAttachmentEnrichment = async (
|
||||
schema: TableSchema,
|
||||
field: string,
|
||||
attachmentCfg: string | string[]
|
||||
) => {
|
||||
const testTable = await config.api.table.save(
|
||||
defaultTable({
|
||||
schema,
|
||||
})
|
||||
)
|
||||
const attachmentToStoreKey = (attachmentId: string) => {
|
||||
return {
|
||||
key: `${config.getAppId()}/attachments/${attachmentId}`,
|
||||
}
|
||||
}
|
||||
const draftRow = {
|
||||
name: "test",
|
||||
description: "test",
|
||||
[field]:
|
||||
typeof attachmentCfg === "string"
|
||||
? attachmentToStoreKey(attachmentCfg)
|
||||
: attachmentCfg.map(attachmentToStoreKey),
|
||||
tableId: testTable._id,
|
||||
}
|
||||
const row = await config.api.row.save(testTable._id!, draftRow)
|
||||
|
||||
await withEnv({ SELF_HOSTED: "true" }, async () => {
|
||||
return context.doInAppContext(config.getAppId(), async () => {
|
||||
const enriched: Row[] = await outputProcessing(testTable, [row])
|
||||
const [targetRow] = enriched
|
||||
const attachmentEntries = Array.isArray(targetRow[field])
|
||||
? targetRow[field]
|
||||
: [targetRow[field]]
|
||||
|
||||
for (const entry of attachmentEntries) {
|
||||
const attachmentId = entry.key.split("/").pop()
|
||||
expect(entry.url.split("?")[0]).toBe(
|
||||
`/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}`
|
||||
)
|
||||
}
|
||||
})
|
||||
describe("attachments and signatures", () => {
|
||||
const coreAttachmentEnrichment = async (
|
||||
schema: TableSchema,
|
||||
field: string,
|
||||
attachmentCfg: string | string[]
|
||||
) => {
|
||||
const testTable = await config.api.table.save(
|
||||
defaultTable({
|
||||
schema,
|
||||
})
|
||||
)
|
||||
const attachmentToStoreKey = (attachmentId: string) => {
|
||||
return {
|
||||
key: `${config.getAppId()}/attachments/${attachmentId}`,
|
||||
}
|
||||
}
|
||||
const draftRow = {
|
||||
name: "test",
|
||||
description: "test",
|
||||
[field]:
|
||||
typeof attachmentCfg === "string"
|
||||
? attachmentToStoreKey(attachmentCfg)
|
||||
: attachmentCfg.map(attachmentToStoreKey),
|
||||
tableId: testTable._id,
|
||||
}
|
||||
const row = await config.api.row.save(testTable._id!, draftRow)
|
||||
|
||||
it("should allow enriching single attachment rows", async () => {
|
||||
await coreAttachmentEnrichment(
|
||||
{
|
||||
attachment: {
|
||||
type: FieldType.ATTACHMENT_SINGLE,
|
||||
name: "attachment",
|
||||
constraints: { presence: false },
|
||||
},
|
||||
},
|
||||
"attachment",
|
||||
`${uuid.v4()}.csv`
|
||||
)
|
||||
})
|
||||
await withEnv({ SELF_HOSTED: "true" }, async () => {
|
||||
return context.doInAppContext(config.getAppId(), async () => {
|
||||
const enriched: Row[] = await outputProcessing(testTable, [row])
|
||||
const [targetRow] = enriched
|
||||
const attachmentEntries = Array.isArray(targetRow[field])
|
||||
? targetRow[field]
|
||||
: [targetRow[field]]
|
||||
|
||||
it("should allow enriching attachment list rows", async () => {
|
||||
await coreAttachmentEnrichment(
|
||||
{
|
||||
attachments: {
|
||||
type: FieldType.ATTACHMENTS,
|
||||
name: "attachments",
|
||||
constraints: { type: "array", presence: false },
|
||||
},
|
||||
},
|
||||
"attachments",
|
||||
[`${uuid.v4()}.csv`]
|
||||
)
|
||||
for (const entry of attachmentEntries) {
|
||||
const attachmentId = entry.key.split("/").pop()
|
||||
expect(entry.url.split("?")[0]).toBe(
|
||||
`/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}`
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
it("should allow enriching signature rows", async () => {
|
||||
await coreAttachmentEnrichment(
|
||||
{
|
||||
signature: {
|
||||
type: FieldType.SIGNATURE_SINGLE,
|
||||
name: "signature",
|
||||
constraints: { presence: false },
|
||||
},
|
||||
it("should allow enriching single attachment rows", async () => {
|
||||
await coreAttachmentEnrichment(
|
||||
{
|
||||
attachment: {
|
||||
type: FieldType.ATTACHMENT_SINGLE,
|
||||
name: "attachment",
|
||||
constraints: { presence: false },
|
||||
},
|
||||
"signature",
|
||||
`${uuid.v4()}.png`
|
||||
)
|
||||
})
|
||||
},
|
||||
"attachment",
|
||||
`${uuid.v4()}.csv`
|
||||
)
|
||||
})
|
||||
|
||||
it("should allow enriching attachment list rows", async () => {
|
||||
await coreAttachmentEnrichment(
|
||||
{
|
||||
attachments: {
|
||||
type: FieldType.ATTACHMENTS,
|
||||
name: "attachments",
|
||||
constraints: { type: "array", presence: false },
|
||||
},
|
||||
},
|
||||
"attachments",
|
||||
[`${uuid.v4()}.csv`]
|
||||
)
|
||||
})
|
||||
|
||||
it("should allow enriching signature rows", async () => {
|
||||
await coreAttachmentEnrichment(
|
||||
{
|
||||
signature: {
|
||||
type: FieldType.SIGNATURE_SINGLE,
|
||||
name: "signature",
|
||||
constraints: { presence: false },
|
||||
},
|
||||
},
|
||||
"signature",
|
||||
`${uuid.v4()}.png`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("exportRows", () => {
|
||||
beforeEach(async () => {
|
||||
table = await config.api.table.save(defaultTable())
|
||||
|
|
|
@ -8,8 +8,6 @@ import {
|
|||
PaginationValues,
|
||||
QueryType,
|
||||
RestAuthType,
|
||||
RestBasicAuthConfig,
|
||||
RestBearerAuthConfig,
|
||||
RestConfig,
|
||||
RestQueryFields as RestQuery,
|
||||
} from "@budibase/types"
|
||||
|
@ -28,6 +26,8 @@ import { parse } from "content-disposition"
|
|||
import path from "path"
|
||||
import { Builder as XmlBuilder } from "xml2js"
|
||||
import { getAttachmentHeaders } from "./utils/restUtils"
|
||||
import { utils } from "@budibase/shared-core"
|
||||
import sdk from "../sdk"
|
||||
|
||||
const coreFields = {
|
||||
path: {
|
||||
|
@ -377,29 +377,41 @@ export class RestIntegration implements IntegrationBase {
|
|||
return input
|
||||
}
|
||||
|
||||
getAuthHeaders(authConfigId?: string): { [key: string]: any } {
|
||||
let headers: any = {}
|
||||
async getAuthHeaders(
|
||||
authConfigId?: string,
|
||||
authConfigType?: RestAuthType
|
||||
): Promise<{ [key: string]: any }> {
|
||||
if (!authConfigId) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (this.config.authConfigs && authConfigId) {
|
||||
const authConfig = this.config.authConfigs.filter(
|
||||
c => c._id === authConfigId
|
||||
)[0]
|
||||
// check the config still exists before proceeding
|
||||
// if not - do nothing
|
||||
if (authConfig) {
|
||||
let config
|
||||
switch (authConfig.type) {
|
||||
case RestAuthType.BASIC:
|
||||
config = authConfig.config as RestBasicAuthConfig
|
||||
headers.Authorization = `Basic ${Buffer.from(
|
||||
`${config.username}:${config.password}`
|
||||
).toString("base64")}`
|
||||
break
|
||||
case RestAuthType.BEARER:
|
||||
config = authConfig.config as RestBearerAuthConfig
|
||||
headers.Authorization = `Bearer ${config.token}`
|
||||
break
|
||||
}
|
||||
if (authConfigType === RestAuthType.OAUTH2) {
|
||||
return { Authorization: await sdk.oauth2.generateToken(authConfigId) }
|
||||
}
|
||||
|
||||
if (!this.config.authConfigs) {
|
||||
return {}
|
||||
}
|
||||
|
||||
let headers: any = {}
|
||||
const authConfig = this.config.authConfigs.filter(
|
||||
c => c._id === authConfigId
|
||||
)[0]
|
||||
// check the config still exists before proceeding
|
||||
// if not - do nothing
|
||||
if (authConfig) {
|
||||
const { type, config } = authConfig
|
||||
switch (type) {
|
||||
case RestAuthType.BASIC:
|
||||
headers.Authorization = `Basic ${Buffer.from(
|
||||
`${config.username}:${config.password}`
|
||||
).toString("base64")}`
|
||||
break
|
||||
case RestAuthType.BEARER:
|
||||
headers.Authorization = `Bearer ${config.token}`
|
||||
break
|
||||
default:
|
||||
throw utils.unreachable(type)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -416,10 +428,11 @@ export class RestIntegration implements IntegrationBase {
|
|||
bodyType = BodyType.NONE,
|
||||
requestBody,
|
||||
authConfigId,
|
||||
authConfigType,
|
||||
pagination,
|
||||
paginationValues,
|
||||
} = query
|
||||
const authHeaders = this.getAuthHeaders(authConfigId)
|
||||
const authHeaders = await this.getAuthHeaders(authConfigId, authConfigType)
|
||||
|
||||
this.headers = {
|
||||
...(this.config.defaultHeaders || {}),
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
import nock from "nock"
|
||||
import { RestIntegration } from "../rest"
|
||||
import { BodyType, RestAuthType } from "@budibase/types"
|
||||
import { Response } from "node-fetch"
|
||||
import TestConfiguration from "../../../src/tests/utilities/TestConfiguration"
|
||||
import { RestIntegration } from "../rest"
|
||||
import {
|
||||
BasicRestAuthConfig,
|
||||
BearerRestAuthConfig,
|
||||
BodyType,
|
||||
OAuth2CredentialsMethod,
|
||||
RestAuthType,
|
||||
} from "@budibase/types"
|
||||
import { Response } from "node-fetch"
|
||||
import { createServer } from "http"
|
||||
import { AddressInfo } from "net"
|
||||
import { generator } from "@budibase/backend-core/tests"
|
||||
|
||||
const UUID_REGEX =
|
||||
"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
|
||||
|
@ -224,7 +231,7 @@ describe("REST Integration", () => {
|
|||
})
|
||||
|
||||
describe("authentication", () => {
|
||||
const basicAuth = {
|
||||
const basicAuth: BasicRestAuthConfig = {
|
||||
_id: "c59c14bd1898a43baa08da68959b24686",
|
||||
name: "basic-1",
|
||||
type: RestAuthType.BASIC,
|
||||
|
@ -234,7 +241,7 @@ describe("REST Integration", () => {
|
|||
},
|
||||
}
|
||||
|
||||
const bearerAuth = {
|
||||
const bearerAuth: BearerRestAuthConfig = {
|
||||
_id: "0d91d732f34e4befabeff50b392a8ff3",
|
||||
name: "bearer-1",
|
||||
type: RestAuthType.BEARER,
|
||||
|
@ -269,6 +276,85 @@ describe("REST Integration", () => {
|
|||
const { data } = await integration.read({ authConfigId: bearerAuth._id })
|
||||
expect(data).toEqual({ foo: "bar" })
|
||||
})
|
||||
|
||||
it("adds OAuth2 auth (via header)", async () => {
|
||||
const oauth2Url = generator.url()
|
||||
const secret = generator.hash()
|
||||
const { config: oauthConfig } = await config.api.oauth2.create({
|
||||
name: generator.guid(),
|
||||
url: oauth2Url,
|
||||
clientId: generator.guid(),
|
||||
clientSecret: secret,
|
||||
method: OAuth2CredentialsMethod.HEADER,
|
||||
})
|
||||
|
||||
const token = generator.guid()
|
||||
|
||||
const url = new URL(oauth2Url)
|
||||
nock(url.origin)
|
||||
.post(url.pathname, {
|
||||
grant_type: "client_credentials",
|
||||
})
|
||||
.basicAuth({ user: oauthConfig.clientId, pass: secret })
|
||||
.reply(200, { token_type: "Bearer", access_token: token })
|
||||
|
||||
nock("https://example.com", {
|
||||
reqheaders: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.get("/")
|
||||
.reply(200, { foo: "bar" })
|
||||
const { data } = await config.doInContext(
|
||||
config.appId,
|
||||
async () =>
|
||||
await integration.read({
|
||||
authConfigId: oauthConfig.id,
|
||||
authConfigType: RestAuthType.OAUTH2,
|
||||
})
|
||||
)
|
||||
expect(data).toEqual({ foo: "bar" })
|
||||
})
|
||||
|
||||
it("adds OAuth2 auth (via body)", async () => {
|
||||
const oauth2Url = generator.url()
|
||||
const secret = generator.hash()
|
||||
const { config: oauthConfig } = await config.api.oauth2.create({
|
||||
name: generator.guid(),
|
||||
url: oauth2Url,
|
||||
clientId: generator.guid(),
|
||||
clientSecret: secret,
|
||||
method: OAuth2CredentialsMethod.BODY,
|
||||
})
|
||||
|
||||
const token = generator.guid()
|
||||
|
||||
const url = new URL(oauth2Url)
|
||||
nock(url.origin, {
|
||||
reqheaders: {
|
||||
"content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
})
|
||||
.post(url.pathname, {
|
||||
grant_type: "client_credentials",
|
||||
client_id: oauthConfig.clientId,
|
||||
client_secret: secret,
|
||||
})
|
||||
.reply(200, { token_type: "Bearer", access_token: token })
|
||||
|
||||
nock("https://example.com", {
|
||||
reqheaders: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.get("/")
|
||||
.reply(200, { foo: "bar" })
|
||||
const { data } = await config.doInContext(
|
||||
config.appId,
|
||||
async () =>
|
||||
await integration.read({
|
||||
authConfigId: oauthConfig.id,
|
||||
authConfigType: RestAuthType.OAUTH2,
|
||||
})
|
||||
)
|
||||
expect(data).toEqual({ foo: "bar" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("page based pagination", () => {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import dotenv from "dotenv"
|
||||
import { join } from "path"
|
||||
|
||||
const path = join(__dirname, "..", "..", "..", "..", "datasource-sha.env")
|
||||
const path = join(__dirname, "..", "..", "..", "..", "images-sha.env")
|
||||
dotenv.config({
|
||||
path,
|
||||
})
|
||||
|
@ -14,3 +14,4 @@ export const MONGODB_IMAGE = `mongo@${process.env.MONGODB_SHA}`
|
|||
export const MARIADB_IMAGE = `mariadb@${process.env.MARIADB_SHA}`
|
||||
export const ELASTICSEARCH_IMAGE = `elasticsearch@${process.env.ELASTICSEARCH_SHA}`
|
||||
export const DYNAMODB_IMAGE = `amazon/dynamodb-local@${process.env.DYNAMODB_SHA}`
|
||||
export const KEYCLOAK_IMAGE = process.env.KEYCLOAK_IMAGE || ""
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
import { context, HTTPError } from "@budibase/backend-core"
|
||||
import { DocumentType, OAuth2Config, OAuth2Configs } from "@budibase/types"
|
||||
|
||||
export async function fetch(): Promise<OAuth2Config[]> {
|
||||
const db = context.getAppDB()
|
||||
const result = await db.tryGet<OAuth2Configs>(DocumentType.OAUTH2_CONFIG)
|
||||
if (!result) {
|
||||
return []
|
||||
}
|
||||
return Object.values(result.configs)
|
||||
}
|
||||
|
||||
export async function create(config: OAuth2Config) {
|
||||
const db = context.getAppDB()
|
||||
const doc: OAuth2Configs = (await db.tryGet<OAuth2Configs>(
|
||||
DocumentType.OAUTH2_CONFIG
|
||||
)) ?? {
|
||||
_id: DocumentType.OAUTH2_CONFIG,
|
||||
configs: {},
|
||||
}
|
||||
|
||||
if (doc.configs[config.name]) {
|
||||
throw new HTTPError("Name already used", 400)
|
||||
}
|
||||
|
||||
doc.configs[config.name] = config
|
||||
await db.put(doc)
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
import { context, HTTPError, utils } from "@budibase/backend-core"
|
||||
import {
|
||||
Database,
|
||||
DocumentType,
|
||||
OAuth2Config,
|
||||
OAuth2Configs,
|
||||
PASSWORD_REPLACEMENT,
|
||||
SEPARATOR,
|
||||
VirtualDocumentType,
|
||||
} from "@budibase/types"
|
||||
|
||||
async function getDocument(db: Database = context.getAppDB()) {
|
||||
const result = await db.tryGet<OAuth2Configs>(DocumentType.OAUTH2_CONFIG)
|
||||
return result
|
||||
}
|
||||
|
||||
export async function fetch(): Promise<OAuth2Config[]> {
|
||||
const result = await getDocument()
|
||||
if (!result) {
|
||||
return []
|
||||
}
|
||||
return Object.values(result.configs)
|
||||
}
|
||||
|
||||
export async function create(
|
||||
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(
|
||||
`OAuth2 config with name '${config.name}' is already taken.`,
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
doc.configs[config.id] = {
|
||||
...config,
|
||||
clientSecret:
|
||||
config.clientSecret === PASSWORD_REPLACEMENT
|
||||
? doc.configs[config.id].clientSecret
|
||||
: config.clientSecret,
|
||||
}
|
||||
|
||||
await db.put(doc)
|
||||
return doc.configs[config.id]
|
||||
}
|
||||
|
||||
export async function remove(configId: string): Promise<void> {
|
||||
const db = context.getAppDB()
|
||||
const doc: OAuth2Configs = (await getDocument(db)) ?? {
|
||||
_id: DocumentType.OAUTH2_CONFIG,
|
||||
configs: {},
|
||||
}
|
||||
|
||||
if (!doc.configs[configId]) {
|
||||
throw new HTTPError(`OAuth2 config with id '${configId}' not found.`, 404)
|
||||
}
|
||||
|
||||
delete doc.configs[configId]
|
||||
|
||||
await db.put(doc)
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./crud"
|
||||
export * from "./utils"
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"id": "myrealm",
|
||||
"realm": "myrealm",
|
||||
"enabled": true,
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "my-client",
|
||||
"secret": "my-secret",
|
||||
"directAccessGrantsEnabled": true,
|
||||
"serviceAccountsEnabled": true
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
import { generator } from "@budibase/backend-core/tests"
|
||||
import { GenericContainer, Wait } from "testcontainers"
|
||||
import sdk from "../../.."
|
||||
import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
|
||||
import { generateToken } from "../utils"
|
||||
import path from "path"
|
||||
import { KEYCLOAK_IMAGE } from "../../../../integrations/tests/utils/images"
|
||||
import { startContainer } from "../../../../integrations/tests/utils"
|
||||
import { OAuth2CredentialsMethod } from "@budibase/types"
|
||||
|
||||
const config = new TestConfiguration()
|
||||
|
||||
const volumePath = path.resolve(__dirname, "docker-volume")
|
||||
|
||||
jest.setTimeout(60000)
|
||||
|
||||
describe("oauth2 utils", () => {
|
||||
let keycloakUrl: string
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
|
||||
const ports = await startContainer(
|
||||
new GenericContainer(KEYCLOAK_IMAGE)
|
||||
.withName("keycloak_testcontainer")
|
||||
.withExposedPorts(8080)
|
||||
.withBindMounts([
|
||||
{ source: volumePath, target: "/opt/keycloak/data/import/" },
|
||||
])
|
||||
.withCommand(["start-dev", "--import-realm"])
|
||||
.withWaitStrategy(
|
||||
Wait.forLogMessage("Listening on: http://0.0.0.0:8080")
|
||||
)
|
||||
.withStartupTimeout(60000)
|
||||
)
|
||||
|
||||
const port = ports.find(x => x.container === 8080)?.host
|
||||
if (!port) {
|
||||
throw new Error("Keycloak port not found")
|
||||
}
|
||||
|
||||
keycloakUrl = `http://127.0.0.1:${port}`
|
||||
})
|
||||
|
||||
describe.each(Object.values(OAuth2CredentialsMethod))(
|
||||
"generateToken (in %s)",
|
||||
method => {
|
||||
it("successfully generates tokens", async () => {
|
||||
const response = await config.doInContext(config.appId, async () => {
|
||||
const oauthConfig = await sdk.oauth2.create({
|
||||
name: generator.guid(),
|
||||
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
|
||||
clientId: "my-client",
|
||||
clientSecret: "my-secret",
|
||||
method,
|
||||
})
|
||||
|
||||
const response = await generateToken(oauthConfig.id)
|
||||
return response
|
||||
})
|
||||
|
||||
expect(response).toEqual(expect.stringMatching(/^Bearer .+/))
|
||||
})
|
||||
|
||||
it("handles wrong urls", async () => {
|
||||
await expect(
|
||||
config.doInContext(config.appId, async () => {
|
||||
const oauthConfig = await sdk.oauth2.create({
|
||||
name: generator.guid(),
|
||||
url: `${keycloakUrl}/realms/wrong/protocol/openid-connect/token`,
|
||||
clientId: "my-client",
|
||||
clientSecret: "my-secret",
|
||||
method,
|
||||
})
|
||||
|
||||
await generateToken(oauthConfig.id)
|
||||
})
|
||||
).rejects.toThrow("Error fetching oauth2 token: Not Found")
|
||||
})
|
||||
|
||||
it("handles wrong client ids", async () => {
|
||||
await expect(
|
||||
config.doInContext(config.appId, async () => {
|
||||
const oauthConfig = await sdk.oauth2.create({
|
||||
name: generator.guid(),
|
||||
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
|
||||
clientId: "wrong-client-id",
|
||||
clientSecret: "my-secret",
|
||||
method,
|
||||
})
|
||||
|
||||
await generateToken(oauthConfig.id)
|
||||
})
|
||||
).rejects.toThrow(
|
||||
"Error fetching oauth2 token: Invalid client or Invalid client credentials"
|
||||
)
|
||||
})
|
||||
|
||||
it("handles wrong secrets", async () => {
|
||||
await expect(
|
||||
config.doInContext(config.appId, async () => {
|
||||
const oauthConfig = await sdk.oauth2.create({
|
||||
name: generator.guid(),
|
||||
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
|
||||
clientId: "my-client",
|
||||
clientSecret: "wrong-secret",
|
||||
method,
|
||||
})
|
||||
|
||||
await generateToken(oauthConfig.id)
|
||||
})
|
||||
).rejects.toThrow(
|
||||
"Error fetching oauth2 token: Invalid client or Invalid client credentials"
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
|
@ -0,0 +1,81 @@
|
|||
import fetch, { RequestInit } from "node-fetch"
|
||||
import { HttpError } from "koa"
|
||||
import { get } from "../oauth2"
|
||||
import { OAuth2CredentialsMethod } from "@budibase/types"
|
||||
|
||||
async function fetchToken(config: {
|
||||
url: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
method: OAuth2CredentialsMethod
|
||||
}) {
|
||||
const fetchConfig: RequestInit = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
}),
|
||||
redirect: "follow",
|
||||
}
|
||||
|
||||
if (config.method === OAuth2CredentialsMethod.HEADER) {
|
||||
fetchConfig.headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
`${config.clientId}:${config.clientSecret}`,
|
||||
"utf-8"
|
||||
).toString("base64")}`,
|
||||
}
|
||||
} else {
|
||||
fetchConfig.body = new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
})
|
||||
}
|
||||
|
||||
const resp = await fetch(config.url, fetchConfig)
|
||||
return resp
|
||||
}
|
||||
|
||||
// TODO: check if caching is worth
|
||||
export async function generateToken(id: string) {
|
||||
const config = await get(id)
|
||||
if (!config) {
|
||||
throw new HttpError(`oAuth config ${id} count not be found`)
|
||||
}
|
||||
|
||||
const resp = await fetchToken(config)
|
||||
|
||||
const jsonResponse = await resp.json()
|
||||
if (!resp.ok) {
|
||||
const message = jsonResponse.error_description ?? resp.statusText
|
||||
|
||||
throw new Error(`Error fetching oauth2 token: ${message}`)
|
||||
}
|
||||
|
||||
return `${jsonResponse.token_type} ${jsonResponse.access_token}`
|
||||
}
|
||||
|
||||
export async function validateConfig(config: {
|
||||
url: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
method: OAuth2CredentialsMethod
|
||||
}): Promise<{ valid: boolean; message?: string }> {
|
||||
try {
|
||||
const resp = await fetchToken(config)
|
||||
|
||||
const jsonResponse = await resp.json()
|
||||
if (!resp.ok) {
|
||||
const message = jsonResponse.error_description ?? resp.statusText
|
||||
return { valid: false, message }
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
} catch (e: any) {
|
||||
return { valid: false, message: e.message }
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
CreateOAuth2ConfigRequest,
|
||||
UpsertOAuth2ConfigRequest,
|
||||
UpsertOAuth2ConfigResponse,
|
||||
FetchOAuth2ConfigsResponse,
|
||||
} from "@budibase/types"
|
||||
import { Expectations, TestAPI } from "./base"
|
||||
|
@ -12,12 +13,32 @@ export class OAuth2API extends TestAPI {
|
|||
}
|
||||
|
||||
create = async (
|
||||
body: CreateOAuth2ConfigRequest,
|
||||
body: UpsertOAuth2ConfigRequest,
|
||||
expectations?: Expectations
|
||||
) => {
|
||||
return await this._post<CreateOAuth2ConfigRequest>("/api/oauth2", {
|
||||
return await this._post<UpsertOAuth2ConfigResponse>("/api/oauth2", {
|
||||
body,
|
||||
expectations: {
|
||||
status: expectations?.status ?? 201,
|
||||
...expectations,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
update = async (
|
||||
id: string,
|
||||
body: UpsertOAuth2ConfigRequest,
|
||||
expectations?: Expectations
|
||||
) => {
|
||||
return await this._put<UpsertOAuth2ConfigResponse>(`/api/oauth2/${id}`, {
|
||||
body,
|
||||
expectations,
|
||||
})
|
||||
}
|
||||
|
||||
delete = async (id: string, expectations?: Expectations) => {
|
||||
return await this._delete<void>(`/api/oauth2/${id}`, {
|
||||
expectations,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,39 @@
|
|||
interface OAuth2Config {
|
||||
import { OAuth2CredentialsMethod } from "@budibase/types"
|
||||
|
||||
export interface OAuth2ConfigResponse {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
method: OAuth2CredentialsMethod
|
||||
}
|
||||
|
||||
export interface FetchOAuth2ConfigsResponse {
|
||||
configs: OAuth2Config[]
|
||||
configs: OAuth2ConfigResponse[]
|
||||
}
|
||||
|
||||
export interface CreateOAuth2ConfigRequest extends OAuth2Config {}
|
||||
export interface UpsertOAuth2ConfigRequest {
|
||||
name: string
|
||||
url: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
method: OAuth2CredentialsMethod
|
||||
}
|
||||
|
||||
export interface UpsertOAuth2ConfigResponse {
|
||||
config: OAuth2ConfigResponse
|
||||
}
|
||||
|
||||
export interface ValidateConfigRequest {
|
||||
id?: string
|
||||
url: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
method: OAuth2CredentialsMethod
|
||||
}
|
||||
|
||||
export interface ValidateConfigResponse {
|
||||
valid: boolean
|
||||
message?: string
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ export interface Datasource extends Document {
|
|||
export enum RestAuthType {
|
||||
BASIC = "basic",
|
||||
BEARER = "bearer",
|
||||
OAUTH2 = "oauth2",
|
||||
}
|
||||
|
||||
export interface RestBasicAuthConfig {
|
||||
|
@ -30,13 +31,22 @@ export interface RestBearerAuthConfig {
|
|||
token: string
|
||||
}
|
||||
|
||||
export interface RestAuthConfig {
|
||||
export interface BasicRestAuthConfig {
|
||||
_id: string
|
||||
name: string
|
||||
type: RestAuthType
|
||||
config: RestBasicAuthConfig | RestBearerAuthConfig
|
||||
type: RestAuthType.BASIC
|
||||
config: RestBasicAuthConfig
|
||||
}
|
||||
|
||||
export interface BearerRestAuthConfig {
|
||||
_id: string
|
||||
name: string
|
||||
type: RestAuthType.BEARER
|
||||
config: RestBearerAuthConfig
|
||||
}
|
||||
|
||||
export type RestAuthConfig = BasicRestAuthConfig | BearerRestAuthConfig
|
||||
|
||||
export interface DynamicVariable {
|
||||
name: string
|
||||
queryId: string
|
||||
|
|
|
@ -1,7 +1,17 @@
|
|||
import { Document } from "../document"
|
||||
|
||||
export enum OAuth2CredentialsMethod {
|
||||
HEADER = "HEADER",
|
||||
BODY = "BODY",
|
||||
}
|
||||
|
||||
export interface OAuth2Config {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
method: OAuth2CredentialsMethod
|
||||
}
|
||||
|
||||
export interface OAuth2Configs extends Document {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Document } from "../document"
|
||||
import { RestAuthType } from "./datasource"
|
||||
import { Row } from "./row"
|
||||
|
||||
export interface QuerySchema {
|
||||
|
@ -56,6 +57,7 @@ export interface RestQueryFields {
|
|||
bodyType?: BodyType
|
||||
method?: string
|
||||
authConfigId?: string
|
||||
authConfigType?: RestAuthType
|
||||
pagination?: PaginationConfig
|
||||
paginationValues?: PaginationValues
|
||||
}
|
||||
|
|
|
@ -156,8 +156,8 @@ export interface FieldConstraints {
|
|||
message?: string
|
||||
}
|
||||
numericality?: {
|
||||
greaterThanOrEqualTo: string | null
|
||||
lessThanOrEqualTo: string | null
|
||||
greaterThanOrEqualTo?: string | null
|
||||
lessThanOrEqualTo?: string | null
|
||||
}
|
||||
presence?:
|
||||
| boolean
|
||||
|
@ -165,8 +165,8 @@ export interface FieldConstraints {
|
|||
allowEmpty?: boolean
|
||||
}
|
||||
datetime?: {
|
||||
latest: string
|
||||
earliest: string
|
||||
latest?: string
|
||||
earliest?: string
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -197,7 +197,7 @@ export interface BigIntFieldMetadata extends BaseFieldSchema {
|
|||
default?: string
|
||||
}
|
||||
|
||||
interface BaseFieldSchema extends UIFieldMetadata {
|
||||
export interface BaseFieldSchema extends UIFieldMetadata {
|
||||
type: FieldType
|
||||
name: string
|
||||
sortable?: boolean
|
||||
|
|
|
@ -82,6 +82,7 @@ export enum InternalTable {
|
|||
export enum VirtualDocumentType {
|
||||
VIEW = "view",
|
||||
ROW_ACTION = "row_action",
|
||||
OAUTH2_CONFIG = "oauth2",
|
||||
}
|
||||
|
||||
// Because VirtualDocumentTypes can overlap, we need to make sure that we search
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
export enum FeatureFlag {
|
||||
USE_ZOD_VALIDATOR = "USE_ZOD_VALIDATOR",
|
||||
AI_JS_GENERATION = "AI_JS_GENERATION",
|
||||
OAUTH2_CONFIG = "OAUTH2_CONFIG",
|
||||
|
||||
// Account-portal
|
||||
DIRECT_LOGIN_TO_ACCOUNT_PORTAL = "DIRECT_LOGIN_TO_ACCOUNT_PORTAL",
|
||||
|
||||
AI_JS_GENERATION = "AI_JS_GENERATION",
|
||||
}
|
||||
|
||||
export const FeatureFlagDefaults: Record<FeatureFlag, boolean> = {
|
||||
[FeatureFlag.USE_ZOD_VALIDATOR]: false,
|
||||
[FeatureFlag.AI_JS_GENERATION]: false,
|
||||
[FeatureFlag.OAUTH2_CONFIG]: false,
|
||||
|
||||
// Account-portal
|
||||
[FeatureFlag.DIRECT_LOGIN_TO_ACCOUNT_PORTAL]: false,
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
import {
|
||||
FieldType,
|
||||
FieldConstraints,
|
||||
type FieldSchema,
|
||||
type FormulaResponseType,
|
||||
} from "../"
|
||||
|
||||
export interface UIField {
|
||||
name: string
|
||||
type: FieldType
|
||||
subtype?: string
|
||||
icon: string
|
||||
constraints?: {
|
||||
type?: string
|
||||
presence?: boolean
|
||||
length?: any
|
||||
inclusion?: string[]
|
||||
numericality?: {
|
||||
greaterThanOrEqualTo?: string
|
||||
lessThanOrEqualTo?: string
|
||||
}
|
||||
datetime?: {
|
||||
latest?: string
|
||||
earliest?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// an empty/partial field schema which is used when building new columns in the UI
|
||||
// the current construction process of a column means that it is never certain what
|
||||
// this object contains, or what type it is currently set to, meaning that our
|
||||
// strict FieldSchema isn't really usable here, the strict fieldSchema only occurs
|
||||
// when the table is saved, but in the UI in can be in a real mix of states
|
||||
export type FieldSchemaConfig = FieldSchema & {
|
||||
constraints: FieldConstraints
|
||||
fieldName?: string
|
||||
responseType?: FormulaResponseType
|
||||
default?: any
|
||||
fieldId?: string
|
||||
optionColors?: string[]
|
||||
schema?: any
|
||||
json?: string
|
||||
}
|
Loading…
Reference in New Issue