Merge branch 'develop' into user-table-changes

This commit is contained in:
Andrew Kingston 2023-09-25 19:33:35 +01:00 committed by GitHub
commit f010cf2d53
81 changed files with 1399 additions and 268 deletions

View File

@ -12,7 +12,8 @@ jobs:
- uses: actions/checkout@v3
- uses: passeidireto/trigger-external-workflow-action@main
env:
BRANCH: ${{ github.head_ref }}
PAYLOAD_BRANCH: ${{ github.head_ref }}
PAYLOAD_PR_NUMBER: ${{ github.ref }}
with:
repository: budibase/budibase-deploys
event: featurebranch-qa-deploy

View File

@ -55,7 +55,7 @@ http {
set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com";
set $csp_object "object-src 'none'";
set $csp_base_uri "base-uri 'self'";
set $csp_connect "connect-src 'self' https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com";
set $csp_connect "connect-src 'self' https://*.budibase.app https://*.budibase.qa https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com";
set $csp_font "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com https://js.intercomcdn.com https://fonts.intercomcdn.com";
set $csp_frame "frame-src 'self' https:";
set $csp_img "img-src http: https: data: blob:";

View File

@ -1,5 +1,5 @@
{
"version": "2.10.9-alpha.2",
"version": "2.10.12-alpha.11",
"npmClient": "yarn",
"packages": [
"packages/*"

View File

@ -5,7 +5,7 @@
"@esbuild-plugins/tsconfig-paths": "^0.1.2",
"@nx/js": "16.4.3",
"@rollup/plugin-json": "^4.0.2",
"@typescript-eslint/parser": "5.45.0",
"@typescript-eslint/parser": "6.7.2",
"esbuild": "^0.18.17",
"esbuild-node-externals": "^1.8.0",
"eslint": "^8.44.0",
@ -22,7 +22,7 @@
"rimraf": "^3.0.2",
"rollup-plugin-replace": "^2.2.0",
"svelte": "^3.38.2",
"typescript": "4.7.3",
"typescript": "5.2.2",
"@babel/core": "^7.22.5",
"@babel/eslint-parser": "^7.22.5",
"@babel/preset-env": "^7.22.5",

View File

@ -87,7 +87,7 @@
"timekeeper": "2.2.0",
"ts-node": "10.8.1",
"tsconfig-paths": "4.0.0",
"typescript": "4.7.3"
"typescript": "5.2.2"
},
"nx": {
"targets": {

View File

@ -0,0 +1,145 @@
import { User } from "@budibase/types"
import { generator, structures } from "../../../tests"
import { DBTestConfiguration } from "../../../tests/extra"
import { getUsers } from "../user"
import { getGlobalDB } from "../../context"
import _ from "lodash"
import * as redis from "../../redis/init"
import { UserDB } from "../../users"
const config = new DBTestConfiguration()
describe("user cache", () => {
describe("getUsers", () => {
const users: User[] = []
beforeAll(async () => {
const userCount = 10
const userIds = generator.arrayOf(() => generator.guid(), {
min: userCount,
max: userCount,
})
await config.doInTenant(async () => {
const db = getGlobalDB()
for (const userId of userIds) {
const user = structures.users.user({ _id: userId })
await db.put(user)
users.push(user)
}
})
})
beforeEach(async () => {
jest.clearAllMocks()
const redisClient = await redis.getUserClient()
await redisClient.clear()
})
it("when no user is in cache, all of them are retrieved from db", async () => {
const usersToRequest = _.sampleSize(users, 5)
const userIdsToRequest = usersToRequest.map(x => x._id!)
jest.spyOn(UserDB, "bulkGet")
const results = await config.doInTenant(() => getUsers(userIdsToRequest))
expect(results.users).toHaveLength(5)
expect(results).toEqual({
users: usersToRequest.map(u => ({
...u,
budibaseAccess: true,
_rev: expect.any(String),
})),
})
expect(UserDB.bulkGet).toBeCalledTimes(1)
expect(UserDB.bulkGet).toBeCalledWith(userIdsToRequest)
})
it("on a second all, all of them are retrieved from cache", async () => {
const usersToRequest = _.sampleSize(users, 5)
const userIdsToRequest = usersToRequest.map(x => x._id!)
jest.spyOn(UserDB, "bulkGet")
await config.doInTenant(() => getUsers(userIdsToRequest))
const resultsFromCache = await config.doInTenant(() =>
getUsers(userIdsToRequest)
)
expect(resultsFromCache.users).toHaveLength(5)
expect(resultsFromCache).toEqual({
users: expect.arrayContaining(
usersToRequest.map(u => ({
...u,
budibaseAccess: true,
_rev: expect.any(String),
}))
),
})
expect(UserDB.bulkGet).toBeCalledTimes(1)
})
it("when some users are cached, only the missing ones are retrieved from db", async () => {
const usersToRequest = _.sampleSize(users, 5)
const userIdsToRequest = usersToRequest.map(x => x._id!)
jest.spyOn(UserDB, "bulkGet")
await config.doInTenant(() =>
getUsers([userIdsToRequest[0], userIdsToRequest[3]])
)
;(UserDB.bulkGet as jest.Mock).mockClear()
const results = await config.doInTenant(() => getUsers(userIdsToRequest))
expect(results.users).toHaveLength(5)
expect(results).toEqual({
users: expect.arrayContaining(
usersToRequest.map(u => ({
...u,
budibaseAccess: true,
_rev: expect.any(String),
}))
),
})
expect(UserDB.bulkGet).toBeCalledTimes(1)
expect(UserDB.bulkGet).toBeCalledWith([
userIdsToRequest[1],
userIdsToRequest[2],
userIdsToRequest[4],
])
})
it("requesting existing and unexisting ids will return found ones", async () => {
const usersToRequest = _.sampleSize(users, 3)
const missingIds = [generator.guid(), generator.guid()]
const userIdsToRequest = _.shuffle([
...missingIds,
...usersToRequest.map(x => x._id!),
])
const results = await config.doInTenant(() => getUsers(userIdsToRequest))
expect(results.users).toHaveLength(3)
expect(results).toEqual({
users: expect.arrayContaining(
usersToRequest.map(u => ({
...u,
budibaseAccess: true,
_rev: expect.any(String),
}))
),
notFoundIds: expect.arrayContaining(missingIds),
})
})
})
})

View File

@ -6,6 +6,7 @@ import env from "../environment"
import * as accounts from "../accounts"
import { UserDB } from "../users"
import { sdk } from "@budibase/shared-core"
import { User } from "@budibase/types"
const EXPIRY_SECONDS = 3600
@ -27,6 +28,35 @@ async function populateFromDB(userId: string, tenantId: string) {
return user
}
async function populateUsersFromDB(
userIds: string[]
): Promise<{ users: User[]; notFoundIds?: string[] }> {
const getUsersResponse = await UserDB.bulkGet(userIds)
// Handle missed user ids
const notFoundIds = userIds.filter((uid, i) => !getUsersResponse[i])
const users = getUsersResponse.filter(x => x)
await Promise.all(
users.map(async (user: any) => {
user.budibaseAccess = true
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const account = await accounts.getAccount(user.email)
if (account) {
user.account = account
user.accountPortalAccess = true
}
}
})
)
if (notFoundIds.length) {
return { users, notFoundIds }
}
return { users }
}
/**
* Get the requested user by id.
* Use redis cache to first read the user.
@ -77,6 +107,36 @@ export async function getUser(
return user
}
/**
* Get the requested users by id.
* Use redis cache to first read the users.
* If not present fallback to loading the users directly and re-caching.
* @param {*} userIds the ids of the user to get
* @param {*} tenantId the tenant of the users to get
* @returns
*/
export async function getUsers(
userIds: string[]
): Promise<{ users: User[]; notFoundIds?: string[] }> {
const client = await redis.getUserClient()
// try cache
let usersFromCache = await client.bulkGet(userIds)
const missingUsersFromCache = userIds.filter(uid => !usersFromCache[uid])
const users = Object.values(usersFromCache)
let notFoundIds
if (missingUsersFromCache.length) {
const usersFromDb = await populateUsersFromDB(missingUsersFromCache)
notFoundIds = usersFromDb.notFoundIds
for (const userToCache of usersFromDb.users) {
await client.store(userToCache._id!, userToCache, EXPIRY_SECONDS)
}
users.push(...usersFromDb.users)
}
return { users, notFoundIds: notFoundIds }
}
export async function invalidateUser(userId: string) {
const client = await redis.getUserClient()
await client.delete(userId)

View File

@ -102,6 +102,7 @@ describe("sso", () => {
// modified external id to match user format
ssoUser._id = "us_" + details.userId
delete ssoUser.userId
// new sso user won't have a password
delete ssoUser.password

View File

@ -250,7 +250,7 @@ class RedisWrapper {
const prefixedKeys = keys.map(key => addDbPrefix(db, key))
let response = await this.getClient().mget(prefixedKeys)
if (Array.isArray(response)) {
let final: any = {}
let final: Record<string, any> = {}
let count = 0
for (let result of response) {
if (result) {

View File

@ -86,8 +86,8 @@ export const useAuditLogs = () => {
return useFeature(Feature.AUDIT_LOGS)
}
export const usePublicApiUserRoles = () => {
return useFeature(Feature.USER_ROLE_PUBLIC_API)
export const useExpandedPublicApi = () => {
return useFeature(Feature.EXPANDED_PUBLIC_API)
}
export const useScimIntegration = () => {

View File

@ -1,19 +0,0 @@
import { User } from "@budibase/types"
import { generator } from "./generator"
import { uuid } from "./common"
export const newEmail = () => {
return `${uuid()}@test.com`
}
export const user = (userProps?: any): User => {
return {
email: newEmail(),
password: "test",
roles: { app_test: "admin" },
firstName: generator.first(),
lastName: generator.last(),
pictureUrl: "http://test.com",
...userProps,
}
}

View File

@ -13,8 +13,7 @@ import {
} from "@budibase/types"
import { generator } from "./generator"
import { email, uuid } from "./common"
import * as shared from "./shared"
import { user } from "./shared"
import * as users from "./users"
import sample from "lodash/sample"
export function OAuth(): OAuth2 {
@ -26,7 +25,7 @@ export function OAuth(): OAuth2 {
export function authDetails(userDoc?: User): SSOAuthDetails {
if (!userDoc) {
userDoc = user()
userDoc = users.user()
}
const userId = userDoc._id || uuid()
@ -52,7 +51,7 @@ export function providerType(): SSOProviderType {
export function ssoProfile(user?: User): SSOProfile {
if (!user) {
user = shared.user()
user = users.user()
}
return {
id: user._id!,

View File

@ -4,11 +4,32 @@ import {
BuilderUser,
SSOAuthDetails,
SSOUser,
User,
} from "@budibase/types"
import { user } from "./shared"
import { authDetails } from "./sso"
import { uuid } from "./common"
import { generator } from "./generator"
import { tenant } from "."
export { user, newEmail } from "./shared"
export const newEmail = () => {
return `${uuid()}@test.com`
}
export const user = (userProps?: Partial<Omit<User, "userId">>): User => {
const userId = userProps?._id
return {
_id: userId,
userId,
email: newEmail(),
password: "test",
roles: { app_test: "admin" },
firstName: generator.first(),
lastName: generator.last(),
pictureUrl: "http://test.com",
tenantId: tenant.id(),
...userProps,
}
}
export const adminUser = (userProps?: any): AdminUser => {
return {
@ -31,7 +52,7 @@ export const adminOnlyUser = (userProps?: any): AdminOnlyUser => {
}
}
export const builderUser = (userProps?: any): BuilderUser => {
export const builderUser = (userProps?: Partial<User>): BuilderUser => {
return {
...user(userProps),
builder: {

View File

@ -126,8 +126,9 @@
transition: top 130ms ease-out, left 130ms ease-out;
}
.spectrum-Tooltip-label {
text-overflow: ellipsis;
white-space: nowrap;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 12px;
font-weight: 600;

View File

@ -108,7 +108,7 @@
"svelte-jester": "^1.3.2",
"ts-node": "10.8.1",
"tsconfig-paths": "4.0.0",
"typescript": "4.7.3",
"typescript": "5.2.2",
"vite": "^3.0.8",
"vite-plugin-static-copy": "^0.16.0",
"vitest": "^0.29.2"

View File

@ -83,8 +83,7 @@
if (Array.isArray(value)) {
return value
}
return [value]
return value.split(",").map(x => x.trim())
}
if (type === "json") {
@ -146,7 +145,7 @@
placeholder={placeholders[schema.type]}
panel={AutomationBindingPanel}
value={Array.isArray(value[field])
? value[field].join(" ")
? value[field].join(",")
: value[field]}
on:change={e => onChange(e, field, schema.type)}
label={field}

View File

@ -55,9 +55,14 @@
bind:value={value[field]}
label={field}
options={schema.constraints.inclusion}
on:change={e => onChange(e, field)}
/>
{:else if schema.type === "longform"}
<TextArea label={field} bind:value={value[field]} />
<TextArea
label={field}
bind:value={value[field]}
on:change={e => onChange(e, field)}
/>
{:else if schema.type === "json"}
<span>
<Label>{field}</Label>
@ -73,7 +78,11 @@
/>
</span>
{:else if schema.type === "link"}
<LinkedRowSelector bind:linkedRows={value[field]} {schema} />
<LinkedRowSelector
bind:linkedRows={value[field]}
{schema}
on:change={e => onChange(e, field)}
/>
{:else if schema.type === "string" || schema.type === "number"}
<svelte:component
this={isTestModal ? ModalBindableInput : DrawerBindableInput}

View File

@ -502,7 +502,7 @@
</div>
{#if datasource?.source !== "ORACLE" && datasource?.source !== "SQL_SERVER"}
<div>
<div>
<div class="row">
<Label>Time zones</Label>
<AbsTooltip
position="top"
@ -690,18 +690,19 @@
display: flex;
align-items: center;
}
.tooltip-alignment {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.label-length {
flex-basis: 40%;
}
.input-length {
flex-grow: 1;
}
.row {
gap: 8px;
display: flex;
}
</style>

View File

@ -235,7 +235,7 @@
const baseExtensions = buildBaseExtensions()
editor = new EditorView({
doc: value,
doc: value?.toString(),
extensions: buildExtensions(baseExtensions),
parent: textarea,
})

View File

@ -3,10 +3,13 @@
import { API } from "api"
import { Select, Label, Multiselect } from "@budibase/bbui"
import { capitalise } from "../../helpers"
import { createEventDispatcher } from "svelte"
export let schema
export let linkedRows = []
const dispatch = createEventDispatcher()
let rows = []
let linkedIds = (Array.isArray(linkedRows) ? linkedRows : [])?.map(
row => row?._id || row
@ -44,7 +47,10 @@
options={rows}
getOptionLabel={getPrettyName}
getOptionValue={row => row._id}
on:change={e => (linkedIds = e.detail ? [e.detail] : [])}
on:change={e => {
linkedIds = e.detail ? [e.detail] : []
dispatch("change", linkedIds)
}}
{label}
sort
/>
@ -56,5 +62,6 @@
getOptionLabel={getPrettyName}
getOptionValue={row => row._id}
sort
on:change={() => dispatch("change", linkedIds)}
/>
{/if}

View File

@ -38,14 +38,12 @@
hoverable
on:click={store.undo}
disabled={!$store.canUndo}
tooltip="Undo latest change"
/>
<Icon
name="Redo"
hoverable
on:click={store.redo}
disabled={!$store.canRedo}
tooltip="Redo latest undo"
/>
</div>

View File

@ -0,0 +1,102 @@
<script>
import { notifications, Button, Icon, Body } from "@budibase/bbui"
import { admin, auth } from "stores/portal"
$: user = $auth.user
let loading = false
let complete = false
const resetPassword = async () => {
if (loading || complete) return
loading = true
try {
await fetch(`${$admin.accountPortalUrl}/api/auth/reset`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: user.email }),
})
complete = true
} catch (e) {
notifications.error("There was an issue sending your validation email.")
} finally {
loading = false
}
}
</script>
{#if user?.account?.verified === false}
<section class="banner">
<div class="icon">
<Icon name="Info" />
</div>
<div class="copy">
<Body size="S">
Please verify your account. We've sent the verification link to <span
class="email">{user.email}</span
></Body
>
</div>
<div class="button" class:disabled={loading || complete}>
<Button on:click={resetPassword}
>{complete ? "Email sent" : "Resend email"}</Button
>
</div>
</section>
{/if}
<style>
.banner {
flex-shrink: 0;
display: flex;
background-color: var(--grey-2);
height: 48px;
align-items: center;
padding: 0 15px;
}
.icon {
margin-right: 15px;
color: var(--ink);
display: flex;
}
.copy {
flex-grow: 1;
overflow: hidden;
}
.copy :global(p) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.email {
font-weight: 600;
}
.button {
margin-left: 15px;
}
.button :global(button) {
color: var(--ink);
background-color: var(--grey-3);
border: none;
}
.button :global(button):hover {
background-color: var(--grey-4);
}
.disabled :global(button) {
pointer-events: none;
color: var(--ink);
background-color: var(--grey-4);
border: none;
}
</style>

View File

@ -224,10 +224,10 @@
<span
class="app-link"
on:click={() => {
appActionPopover.hide()
if (isPublished) {
viewApp()
} else {
appActionPopover.hide()
updateAppModal.show()
}
}}

View File

@ -3,7 +3,6 @@
import { createEventDispatcher } from "svelte"
import { notifications } from "@budibase/bbui"
import ButtonActionDrawer from "./ButtonActionDrawer.svelte"
import { automationStore } from "builderStore"
import { cloneDeep } from "lodash/fp"
const dispatch = createEventDispatcher()
@ -24,47 +23,11 @@
}
const saveEventData = async () => {
// any automations that need created from event triggers
const automationsToCreate = tmpValue.filter(
action => action["##eventHandlerType"] === "Trigger Automation"
)
for (let action of automationsToCreate) {
await createAutomation(action.parameters)
}
dispatch("change", tmpValue)
notifications.success("Component actions saved.")
drawer.hide()
}
// called by the parent modal when actions are saved
const createAutomation = async parameters => {
if (parameters.automationId || !parameters.newAutomationName) {
return
}
try {
let trigger = automationStore.actions.constructBlock(
"TRIGGER",
"APP",
$automationStore.blockDefinitions.TRIGGER.APP
)
trigger.inputs = {
fields: Object.keys(parameters.fields ?? {}).reduce((fields, key) => {
fields[key] = "string"
return fields
}, {}),
}
const automation = await automationStore.actions.create(
parameters.newAutomationName,
trigger
)
parameters.automationId = automation._id
delete parameters.newAutomationName
} catch (error) {
notifications.error("Error creating automation")
}
}
$: actionCount = value?.length
$: actionText = `${actionCount || "No"} action${
actionCount !== 1 ? "s" : ""

View File

@ -1,6 +1,5 @@
<script>
import { currentAsset, store } from "builderStore"
import { onMount } from "svelte"
import { Label, Combobox, Select } from "@budibase/bbui"
import {
getActionProviderComponents,
@ -10,12 +9,6 @@
export let parameters
onMount(() => {
if (!parameters.type) {
parameters.type = "top"
}
})
$: formComponent = findComponent($currentAsset.props, parameters.componentId)
$: formSchema = buildFormSchema(formComponent)
$: fieldOptions = Object.keys(formSchema || {})

View File

@ -0,0 +1,69 @@
<script>
import {
ModalContent,
Toggle,
Input,
Layout,
Dropzone,
notifications,
Body,
} from "@budibase/bbui"
import { API } from "api"
import { automationStore, store } from "../../builderStore"
export let app
$: disabled = (encrypted && !password) || !file
let encrypted = false,
password
let file
async function updateApp() {
try {
let data = new FormData()
data.append("appExport", file)
if (encrypted) {
data.append("encryptionPassword", password.trim())
}
const appId = app.devId
await API.updateAppFromExport(appId, data)
const pkg = await API.fetchAppPackage(appId)
await store.actions.initialise(pkg)
await automationStore.actions.fetch()
notifications.success("App updated successfully")
} catch (err) {
notifications.error(`Failed to update app - ${err.message || err}`)
}
}
</script>
<ModalContent
title={`Update ${app.name}`}
confirmText="Update"
onConfirm={updateApp}
bind:disabled
>
<Body size="S"
>Updating an app using an app export will replace all tables, datasources,
queries, screens and automations. It is recommended to perform a backup
before running this operation.</Body
>
<Layout noPadding gap="XS">
<Dropzone
gallery={false}
label="App export"
on:change={e => {
file = e.detail?.[0]
}}
/>
<Toggle text="Encrypted" bind:value={encrypted} />
{#if encrypted}
<Input
type="password"
label="Password"
placeholder="Type here..."
bind:value={password}
/>
{/if}
</Layout>
</ModalContent>

View File

@ -3,7 +3,6 @@
import { admin, auth, licensing } from "stores/portal"
import { onMount } from "svelte"
import { CookieUtils, Constants } from "@budibase/frontend-core"
import { banner, BANNER_TYPES } from "@budibase/bbui"
import { API } from "api"
import Branding from "./Branding.svelte"
@ -17,32 +16,6 @@
$: user = $auth.user
$: useAccountPortal = cloud && !$admin.disableAccountPortal
let showVerificationPrompt = false
const checkVerification = user => {
if (!showVerificationPrompt && user?.account?.verified === false) {
showVerificationPrompt = true
banner.queue([
{
message: `Please verify your account. We've sent the verification link to ${user.email}`,
type: BANNER_TYPES.NEUTRAL,
showCloseButton: false,
extraButtonAction: () => {
fetch(`${$admin.accountPortalUrl}/api/auth/reset`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: user.email }),
})
},
extraButtonText: "Resend email",
},
])
}
}
$: checkVerification(user)
const validateTenantId = async () => {
const host = window.location.host

View File

@ -22,6 +22,7 @@
import { isActive, goto, layout, redirect } from "@roxi/routify"
import { capitalise } from "helpers"
import { onMount, onDestroy } from "svelte"
import VerificationPromptBanner from "components/common/VerificationPromptBanner.svelte"
import CommandPalette from "components/commandPalette/CommandPalette.svelte"
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
import TourPopover from "components/portal/onboarding/TourPopover.svelte"
@ -136,6 +137,7 @@
{/if}
<div class="root" class:blur={$store.showPreview}>
<VerificationPromptBanner />
<div class="top-nav">
{#if $store.initialised}
<div class="topleftnav">

View File

@ -61,10 +61,12 @@
key: "_css",
type: "text",
})
$: settingOptions = settings.map(setting => ({
label: makeLabel(setting),
value: setting.key,
}))
$: settingOptions = settings
.filter(setting => setting.supportsConditions !== false)
.map(setting => ({
label: makeLabel(setting),
value: setting.key,
}))
$: conditions.forEach(link => {
if (!link.id) {
link.id = generate()

View File

@ -36,10 +36,7 @@
</script>
<DetailSummary name={"Conditions"} collapsible={false}>
<div class="conditionCount">{conditionText}</div>
<div>
<ActionButton on:click={openDrawer}>Configure conditions</ActionButton>
</div>
<ActionButton on:click={openDrawer}>{conditionText}</ActionButton>
</DetailSummary>
<Drawer bind:this={drawer} title="Conditions">
<svelte:fragment slot="description">
@ -48,10 +45,3 @@
<Button cta slot="buttons" on:click={() => save()}>Save</Button>
<ConditionalUIDrawer slot="body" bind:conditions={tempValue} {bindings} />
</Drawer>
<style>
.conditionCount {
font-weight: 600;
margin-top: -5px;
}
</style>

View File

@ -32,9 +32,9 @@
active={$isActive("./embed")}
/>
<SideNavItem
text="Export"
url={$url("./export")}
active={$isActive("./export")}
text="Export/Import"
url={$url("./exportImport")}
active={$isActive("./exportImport")}
/>
<SideNavItem
text="Name and URL"

View File

@ -11,31 +11,39 @@
import { apps } from "stores/portal"
import { store } from "builderStore"
import ExportAppModal from "components/start/ExportAppModal.svelte"
import ImportAppModal from "components/start/ImportAppModal.svelte"
$: filteredApps = $apps.filter(app => app.devId == $store.appId)
$: app = filteredApps.length ? filteredApps[0] : {}
$: appDeployed = app?.status === AppStatus.DEPLOYED
let exportModal
let exportModal, importModal
let exportPublishedVersion = false
const exportApp = opts => {
exportPublishedVersion = !!opts?.published
exportModal.show()
}
const importApp = () => {
importModal.show()
}
</script>
<Modal bind:this={exportModal} padding={false}>
<ExportAppModal {app} published={exportPublishedVersion} />
</Modal>
<Modal bind:this={importModal} padding={false}>
<ImportAppModal {app} />
</Modal>
<Layout noPadding>
<Layout gap="XS" noPadding>
<Heading>Export your app</Heading>
<Body>Export your latest edited or published app</Body>
</Layout>
<Divider />
<div class="export-body">
<div class="body">
<ActionButton secondary on:click={() => exportApp({ published: false })}>
Export latest edited app
</ActionButton>
@ -47,10 +55,20 @@
Export latest published app
</ActionButton>
</div>
<Divider />
<Layout gap="XS" noPadding>
<Heading>Import your app</Heading>
<Body>Import an export to update this app</Body>
</Layout>
<div class="body">
<ActionButton secondary on:click={() => importApp()}>
Import app
</ActionButton>
</div>
</Layout>
<style>
.export-body {
.body {
display: flex;
gap: var(--spacing-l);
}

View File

@ -8,6 +8,7 @@
import Logo from "./_components/Logo.svelte"
import UserDropdown from "./_components/UserDropdown.svelte"
import HelpMenu from "components/common/HelpMenu.svelte"
import VerificationPromptBanner from "components/common/VerificationPromptBanner.svelte"
import { sdk } from "@budibase/shared-core"
let loaded = false
@ -55,6 +56,7 @@
{:else}
<HelpMenu />
<div class="container">
<VerificationPromptBanner />
<div class="nav">
<div class="branding">
<Logo />

View File

@ -62,6 +62,6 @@
"eslint": "^7.20.0",
"renamer": "^4.0.0",
"ts-node": "^10.9.1",
"typescript": "4.7.3"
"typescript": "5.2.2"
}
}

View File

@ -49,8 +49,8 @@ async function askAboutTopLevel(name: string) {
}
}
async function init(opts: PluginOpts) {
const type = opts["init"] || (opts as PluginType)
async function init(opts: PluginOpts | PluginType) {
const type = (opts as PluginOpts).init || (opts as PluginType)
if (!type || !PLUGIN_TYPE_ARR.includes(type)) {
console.log(
error(

View File

@ -2539,7 +2539,8 @@
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
"key": "defaultValue",
"supportsConditions": false
},
{
"type": "event",
@ -2629,7 +2630,8 @@
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
"key": "defaultValue",
"supportsConditions": false
},
{
"type": "event",
@ -2685,7 +2687,8 @@
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
"key": "defaultValue",
"supportsConditions": false
},
{
"type": "event",
@ -2736,7 +2739,8 @@
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
"key": "defaultValue",
"supportsConditions": false
},
{
"type": "event",
@ -2841,7 +2845,8 @@
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
"key": "defaultValue",
"supportsConditions": false
},
{
"type": "boolean",
@ -2960,7 +2965,8 @@
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
"key": "defaultValue",
"supportsConditions": false
},
{
"type": "event",
@ -3143,7 +3149,8 @@
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
"key": "defaultValue",
"supportsConditions": false
},
{
"type": "event",
@ -3200,7 +3207,8 @@
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
"key": "defaultValue",
"supportsConditions": false
},
{
"type": "event",
@ -3301,7 +3309,8 @@
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
"key": "defaultValue",
"supportsConditions": false
},
{
"type": "event",
@ -3355,7 +3364,8 @@
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
"key": "defaultValue",
"supportsConditions": false
},
{
"type": "boolean",
@ -3622,7 +3632,8 @@
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
"key": "defaultValue",
"supportsConditions": false
},
{
"type": "event",
@ -3689,7 +3700,8 @@
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
"key": "defaultValue",
"supportsConditions": false
},
{
"type": "event",

View File

@ -230,10 +230,16 @@
// We want to validate every field (even if validation fails early) to
// ensure that all fields are populated with errors if invalid
let valid = true
let hasScrolled = false
stepFields.forEach(field => {
const fieldValid = get(field).fieldApi.validate()
valid = valid && fieldValid
if (!valid && !hasScrolled) {
handleScrollToField({ field: get(field) })
hasScrolled = true
}
})
return valid
},
reset: () => {
@ -409,10 +415,15 @@
}
const handleScrollToField = ({ field }) => {
const fieldId = get(getField(field)).fieldState.fieldId
if (!field.fieldState) {
field = get(getField(field))
}
const fieldId = field.fieldState.fieldId
const fieldElement = document.getElementById(fieldId)
fieldElement.focus({ preventScroll: true })
const label = document.querySelector(`label[for="${fieldId}"]`)
document.getElementById(fieldId).focus({ preventScroll: true })
label.scrollIntoView({ behavior: "smooth" })
label.style.scrollMargin = "100px"
label.scrollIntoView({ behavior: "smooth", block: "nearest" })
}
// Action context to pass to children

View File

@ -17,6 +17,13 @@
let fieldApi
let localFiles = []
$: {
// If the field state is reset, clear the local files
if (!fieldState?.value?.length) {
localFiles = []
}
}
const { API, notificationStore, uploadStore } = getContext("sdk")
const component = getContext("component")

View File

@ -1,3 +1,5 @@
import { sdk } from "@budibase/shared-core"
export const buildAppEndpoints = API => ({
/**
* Fetches screen definition for an app.
@ -81,6 +83,22 @@ export const buildAppEndpoints = API => ({
})
},
/**
* Update an application using an export - the body
* should be of type FormData, with a "file" and a "password" if encrypted.
* @param appId The ID of the app to update - this will always be
* converted to development ID.
* @param body a FormData body with a file and password.
*/
updateAppFromExport: async (appId, body) => {
const devId = sdk.applications.getDevAppID(appId)
return await API.post({
url: `/api/applications/${devId}/import`,
body,
json: false,
})
},
/**
* Imports an export of all apps.
* @param apps the FormData containing the apps to import

@ -1 +1 @@
Subproject commit 4638ae916e55ce89166095578cbd01745d0ee9ee
Subproject commit 30385682141e5ba9d98de7d71d5be1672109cd15

View File

@ -153,7 +153,6 @@
"@types/server-destroy": "1.0.1",
"@types/supertest": "2.0.12",
"@types/tar": "6.1.5",
"@typescript-eslint/parser": "5.45.0",
"apidoc": "0.50.4",
"babel-jest": "29.6.2",
"copyfiles": "2.4.1",
@ -174,7 +173,7 @@
"timekeeper": "2.2.0",
"ts-node": "10.8.1",
"tsconfig-paths": "4.0.0",
"typescript": "4.7.3",
"typescript": "5.2.2",
"update-dotenv": "1.1.1"
},
"optionalDependencies": {

View File

@ -9,6 +9,7 @@ CREATE TABLE Persons (
Address varchar(255),
City varchar(255) DEFAULT 'Belfast',
Age INTEGER DEFAULT 20 NOT NULL,
Year INTEGER,
Type person_job
);
CREATE TABLE Tasks (
@ -49,9 +50,10 @@ CREATE TABLE CompositeTable (
Name varchar(255),
PRIMARY KEY (KeyPartOne, KeyPartTwo)
);
INSERT INTO Persons (FirstName, LastName, Address, City, Type) VALUES ('Mike', 'Hughes', '123 Fake Street', 'Belfast', 'qa');
INSERT INTO Persons (FirstName, LastName, Address, City, Type) VALUES ('John', 'Smith', '64 Updown Road', 'Dublin', 'programmer');
INSERT INTO Persons (FirstName, LastName, Address, City, Type, Age) VALUES ('Foo', 'Bar', 'Foo Street', 'Bartown', 'support', 0);
INSERT INTO Persons (FirstName, LastName, Address, City, Type, Year) VALUES ('Mike', 'Hughes', '123 Fake Street', 'Belfast', 'qa', 1999);
INSERT INTO Persons (FirstName, LastName, Address, City, Type, Year) VALUES ('John', 'Smith', '64 Updown Road', 'Dublin', 'programmer', 1996);
INSERT INTO Persons (FirstName, LastName, Address, City, Type, Age, Year) VALUES ('Foo', 'Bar', 'Foo Street', 'Bartown', 'support', 0, 1993);
INSERT INTO Persons (FirstName, LastName, Address, City, Type) VALUES ('Jonny', 'Muffin', 'Muffin Street', 'Cork', 'support');
INSERT INTO Tasks (ExecutorID, QaID, TaskName, Completed) VALUES (1, 2, 'assembling', TRUE);
INSERT INTO Tasks (ExecutorID, QaID, TaskName, Completed) VALUES (2, 1, 'processing', FALSE);
INSERT INTO Products (ProductName) VALUES ('Computers');

View File

@ -613,6 +613,23 @@
"data"
]
},
"appExport": {
"type": "object",
"properties": {
"encryptPassword": {
"description": "An optional password used to encrypt the export.",
"type": "string"
},
"excludeRows": {
"description": "Set whether the internal table rows should be excluded from the export.",
"type": "boolean"
}
},
"required": [
"encryptPassword",
"excludeRows"
]
},
"row": {
"description": "The row to be created/updated, based on the table schema.",
"type": "object",
@ -2163,6 +2180,87 @@
}
}
},
"/applications/{appId}/import": {
"post": {
"operationId": "appImport",
"summary": "Import an app to an existing app 🔒",
"description": "This endpoint is only available on a business or enterprise license.",
"tags": [
"applications"
],
"parameters": [
{
"$ref": "#/components/parameters/appIdUrl"
}
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"encryptedPassword": {
"description": "Password for the export if it is encrypted.",
"type": "string"
},
"appExport": {
"description": "The app export to import.",
"type": "string",
"format": "binary"
}
},
"required": [
"appExport"
]
}
}
}
},
"responses": {
"204": {
"description": "Application has been updated."
}
}
}
},
"/applications/{appId}/export": {
"post": {
"operationId": "appExport",
"summary": "Export an app 🔒",
"description": "This endpoint is only available on a business or enterprise license.",
"tags": [
"applications"
],
"parameters": [
{
"$ref": "#/components/parameters/appIdUrl"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/appExport"
}
}
}
},
"responses": {
"200": {
"description": "A gzip tarball containing the app export, encrypted if password provided.",
"content": {
"application/gzip": {
"schema": {
"type": "string",
"format": "binary",
"example": "Tarball containing database and object store contents..."
}
}
}
}
}
}
},
"/applications/search": {
"post": {
"operationId": "appSearch",

View File

@ -587,6 +587,19 @@ components:
- appUrl
required:
- data
appExport:
type: object
properties:
encryptPassword:
description: An optional password used to encrypt the export.
type: string
excludeRows:
description: Set whether the internal table rows should be excluded from the
export.
type: boolean
required:
- encryptPassword
- excludeRows
row:
description: The row to be created/updated, based on the table schema.
type: object
@ -1763,6 +1776,57 @@ paths:
examples:
deployment:
$ref: "#/components/examples/deploymentOutput"
"/applications/{appId}/import":
post:
operationId: appImport
summary: Import an app to an existing app 🔒
description: This endpoint is only available on a business or enterprise license.
tags:
- applications
parameters:
- $ref: "#/components/parameters/appIdUrl"
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
encryptedPassword:
description: Password for the export if it is encrypted.
type: string
appExport:
description: The app export to import.
type: string
format: binary
required:
- appExport
responses:
"204":
description: Application has been updated.
"/applications/{appId}/export":
post:
operationId: appExport
summary: Export an app 🔒
description: This endpoint is only available on a business or enterprise license.
tags:
- applications
parameters:
- $ref: "#/components/parameters/appIdUrl"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/appExport"
responses:
"200":
description: A gzip tarball containing the app export, encrypted if password
provided.
content:
application/gzip:
schema:
type: string
format: binary
example: Tarball containing database and object store contents...
/applications/search:
post:
operationId: appSearch

View File

@ -134,4 +134,15 @@ export default new Resource()
deploymentOutput: object({
data: deploymentOutputSchema,
}),
appExport: object({
encryptPassword: {
description: "An optional password used to encrypt the export.",
type: "string",
},
excludeRows: {
description:
"Set whether the internal table rows should be excluded from the export.",
type: "boolean",
},
}),
})

View File

@ -39,9 +39,8 @@ import {
} from "../../db/defaultData/datasource_bb_default"
import { removeAppFromUserRoles } from "../../utilities/workerRequests"
import { stringToReadStream } from "../../utilities"
import { doesUserHaveLock, getLocksById } from "../../utilities/redis"
import { doesUserHaveLock } from "../../utilities/redis"
import { cleanupAutomations } from "../../automations/utils"
import { checkAppMetadata } from "../../automations/logging"
import { getUniqueRows } from "../../utilities/usageQuota/rows"
import { groups, licensing, quotas } from "@budibase/pro"
import {
@ -51,7 +50,6 @@ import {
PlanType,
Screen,
UserCtx,
ContextUser,
} from "@budibase/types"
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
import sdk from "../../sdk"
@ -575,6 +573,28 @@ export async function sync(ctx: UserCtx) {
}
}
export async function importToApp(ctx: UserCtx) {
const { appId } = ctx.params
const appExport = ctx.request.files?.appExport
const password = ctx.request.body.encryptionPassword as string
if (!appExport) {
ctx.throw(400, "Must supply app export to import")
}
if (Array.isArray(appExport)) {
ctx.throw(400, "Must only supply one app export")
}
const fileAttributes = { type: appExport.type!, path: appExport.path! }
try {
await sdk.applications.updateWithExport(appId, fileAttributes, password)
} catch (err: any) {
ctx.throw(
500,
`Unable to perform update, please retry - ${err?.message || err}`
)
}
ctx.body = { message: "app updated" }
}
export async function updateAppPackage(appPackage: any, appId: any) {
return context.doInAppContext(appId, async () => {
const db = context.getAppDB()

View File

@ -2,9 +2,11 @@ import { db as dbCore, context } from "@budibase/backend-core"
import { search as stringSearch, addRev } from "./utils"
import * as controller from "../application"
import * as deployController from "../deploy"
import * as backupController from "../backup"
import { Application } from "../../../definitions/common"
import { UserCtx } from "@budibase/types"
import { Next } from "koa"
import { sdk as proSdk } from "@budibase/pro"
function fixAppID(app: Application, params: any) {
if (!params) {
@ -80,6 +82,8 @@ export async function destroy(ctx: UserCtx, next: Next) {
export async function unpublish(ctx: UserCtx, next: Next) {
await context.doInAppContext(ctx.params.appId, async () => {
await controller.unpublish(ctx)
ctx.body = undefined
ctx.status = 204
await next()
})
}
@ -91,12 +95,22 @@ export async function publish(ctx: UserCtx, next: Next) {
})
}
// get licensed endpoints from pro
export const importToApp = proSdk.publicApi.applications.buildImportFn(
controller.importToApp
)
export const exportApp = proSdk.publicApi.applications.buildExportFn(
backupController.exportAppDump
)
export default {
create,
update,
read,
destroy,
search,
publish,
unpublish,
publish,
importToApp,
exportApp,
}

View File

@ -28,7 +28,7 @@ export async function handleRequest(
) {
// make sure the filters are cleaned up, no empty strings for equals, fuzzy or string
if (opts && opts.filters) {
opts.filters = utils.removeEmptyFilters(opts.filters)
opts.filters = sdk.rows.removeEmptyFilters(opts.filters)
}
if (
!dataFilters.hasFilters(opts?.filters) &&

View File

@ -72,6 +72,11 @@ export const save = async (ctx: UserCtx<Row, Row>) => {
const tableId = utils.getTableId(ctx)
const body = ctx.request.body
// user metadata doesn't exist yet - don't allow creation
if (utils.isUserMetadataTable(tableId) && !body._rev) {
ctx.throw(400, "Cannot create new user entry.")
}
// if it has an ID already then its a patch
if (body && body._id) {
return patch(ctx as UserCtx<PatchRowRequest, PatchRowResponse>)

View File

@ -175,3 +175,7 @@ export function removeEmptyFilters(filters: SearchFilters) {
}
return filters
}
export function isUserMetadataTable(tableId: string) {
return tableId === InternalTables.USER_METADATA
}

View File

@ -4,6 +4,7 @@ import * as deploymentController from "../controllers/deploy"
import authorized from "../../middleware/authorized"
import { permissions } from "@budibase/backend-core"
import { applicationValidator } from "./utils/validators"
import { importToApp } from "../controllers/application"
const router: Router = new Router()
@ -58,5 +59,10 @@ router
authorized(permissions.GLOBAL_BUILDER),
controller.destroy
)
.post(
"/api/applications/:appId/import",
authorized(permissions.BUILDER),
controller.importToApp
)
export default router

View File

@ -137,6 +137,70 @@ write.push(
new Endpoint("post", "/applications/:appId/publish", controller.publish)
)
/**
* @openapi
* /applications/{appId}/import:
* post:
* operationId: appImport
* summary: Import an app to an existing app 🔒
* description: This endpoint is only available on a business or enterprise license.
* tags:
* - applications
* parameters:
* - $ref: '#/components/parameters/appIdUrl'
* requestBody:
* content:
* multipart/form-data:
* schema:
* type: object
* properties:
* encryptedPassword:
* description: Password for the export if it is encrypted.
* type: string
* appExport:
* description: The app export to import.
* type: string
* format: binary
* required:
* - appExport
* responses:
* 204:
* description: Application has been updated.
*/
write.push(
new Endpoint("post", "/applications/:appId/import", controller.importToApp)
)
/**
* @openapi
* /applications/{appId}/export:
* post:
* operationId: appExport
* summary: Export an app 🔒
* description: This endpoint is only available on a business or enterprise license.
* tags:
* - applications
* parameters:
* - $ref: '#/components/parameters/appIdUrl'
* requestBody:
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/appExport'
* responses:
* 200:
* description: A gzip tarball containing the app export, encrypted if password provided.
* content:
* application/gzip:
* schema:
* type: string
* format: binary
* example: Tarball containing database and object store contents...
*/
read.push(
new Endpoint("post", "/applications/:appId/export", controller.exportApp)
)
/**
* @openapi
* /applications/{appId}:

View File

@ -1,3 +1,4 @@
import { Ctx } from "@budibase/types"
import mapping from "../../../controllers/public/mapping"
enum Resources {
@ -9,11 +10,19 @@ enum Resources {
SEARCH = "search",
}
function isArrayResponse(ctx: any) {
function isAttachment(ctx: Ctx) {
return ctx.body?.path && ctx.body?.flags && ctx.body?.mode
}
function isArrayResponse(ctx: Ctx) {
return ctx.url.endsWith(Resources.SEARCH) || Array.isArray(ctx.body)
}
function processApplications(ctx: any) {
function noResponse(ctx: Ctx) {
return !Array.isArray(ctx.body) && Object.keys(ctx.body).length === 0
}
function processApplications(ctx: Ctx) {
if (isArrayResponse(ctx)) {
return mapping.mapApplications(ctx)
} else {
@ -21,7 +30,7 @@ function processApplications(ctx: any) {
}
}
function processTables(ctx: any) {
function processTables(ctx: Ctx) {
if (isArrayResponse(ctx)) {
return mapping.mapTables(ctx)
} else {
@ -29,7 +38,7 @@ function processTables(ctx: any) {
}
}
function processRows(ctx: any) {
function processRows(ctx: Ctx) {
if (isArrayResponse(ctx)) {
return mapping.mapRowSearch(ctx)
} else {
@ -37,7 +46,7 @@ function processRows(ctx: any) {
}
}
function processUsers(ctx: any) {
function processUsers(ctx: Ctx) {
if (isArrayResponse(ctx)) {
return mapping.mapUsers(ctx)
} else {
@ -45,7 +54,7 @@ function processUsers(ctx: any) {
}
}
function processQueries(ctx: any) {
function processQueries(ctx: Ctx) {
if (isArrayResponse(ctx)) {
return mapping.mapQueries(ctx)
} else {
@ -53,8 +62,8 @@ function processQueries(ctx: any) {
}
}
export default async (ctx: any, next: any) => {
if (!ctx.body) {
export default async (ctx: Ctx, next: any) => {
if (!ctx.body || noResponse(ctx) || isAttachment(ctx)) {
return await next()
}
let urlParts = ctx.url.split("/")

View File

@ -0,0 +1,91 @@
import * as setup from "../../tests/utilities"
import {
generateMakeRequest,
generateMakeRequestWithFormData,
MakeRequestResponse,
MakeRequestWithFormDataResponse,
} from "./utils"
import { User } from "@budibase/types"
import { join } from "path"
import { mocks } from "@budibase/backend-core/tests"
const PASSWORD = "testtest"
const NO_LICENSE_MSG = "Endpoint unavailable, license required."
let config = setup.getConfig()
let apiKey: string,
globalUser: User,
makeRequest: MakeRequestResponse,
makeRequestFormData: MakeRequestWithFormDataResponse
beforeAll(async () => {
await config.init()
globalUser = await config.globalUser()
apiKey = await config.generateApiKey(globalUser._id)
makeRequest = generateMakeRequest(apiKey)
makeRequestFormData = generateMakeRequestWithFormData(apiKey)
})
afterAll(setup.afterAll)
describe("check export/import", () => {
async function runExport() {
return await makeRequest("post", `/applications/${config.appId}/export`, {
encryptionPassword: PASSWORD,
excludeRows: true,
})
}
async function runImport() {
const pathToExport = join(
__dirname,
"..",
"..",
"tests",
"assets",
"export.tar.gz"
)
return await makeRequestFormData(
"post",
`/applications/${config.appId}/import`,
{
encryptionPassword: PASSWORD,
appExport: { path: pathToExport },
}
)
}
it("check licensing for export", async () => {
const res = await runExport()
expect(res.status).toBe(403)
expect(res.body.message).toBe(NO_LICENSE_MSG)
})
it("check licensing for import", async () => {
const res = await runImport()
expect(res.status).toBe(403)
expect(res.body.message).toBe(NO_LICENSE_MSG)
})
it("should be able to export app", async () => {
mocks.licenses.useExpandedPublicApi()
const res = await runExport()
expect(res.headers["content-disposition"]).toMatch(
/attachment; filename=".*-export-.*\.tar.gz"/g
)
expect(res.body instanceof Buffer).toBe(true)
expect(res.status).toBe(200)
})
it("should be able to import app", async () => {
mocks.licenses.useExpandedPublicApi()
const res = await runImport()
expect(Object.keys(res.body).length).toBe(0)
// check screens imported correctly
const screens = await config.api.screen.list()
expect(screens.length).toBe(2)
expect(screens[0].routing.route).toBe("/derp")
expect(screens[1].routing.route).toBe("/blank")
expect(res.status).toBe(204)
})
})

View File

@ -92,7 +92,7 @@ describe("no user role update in free", () => {
describe("no user role update in business", () => {
beforeAll(() => {
updateMock()
mocks.licenses.usePublicApiUserRoles()
mocks.licenses.useExpandedPublicApi()
})
it("should allow 'roles' to be updated", async () => {
@ -105,7 +105,7 @@ describe("no user role update in business", () => {
})
it("should allow 'admin' to be updated", async () => {
mocks.licenses.usePublicApiUserRoles()
mocks.licenses.useExpandedPublicApi()
const res = await makeRequest("post", "/users", {
...base(),
admin: { global: true },
@ -115,7 +115,7 @@ describe("no user role update in business", () => {
})
it("should allow 'builder' to be updated", async () => {
mocks.licenses.usePublicApiUserRoles()
mocks.licenses.useExpandedPublicApi()
const res = await makeRequest("post", "/users", {
...base(),
builder: { global: true },

View File

@ -11,6 +11,32 @@ export type MakeRequestResponse = (
intAppId?: string
) => Promise<supertest.Response>
export type MakeRequestWithFormDataResponse = (
method: HttpMethod,
endpoint: string,
fields: Record<string, string | { path: string }>,
intAppId?: string
) => Promise<supertest.Response>
function base(
apiKey: string,
endpoint: string,
intAppId: string | null,
isInternal: boolean
) {
const extraHeaders: any = {
"x-budibase-api-key": apiKey,
}
if (intAppId) {
extraHeaders["x-budibase-app-id"] = intAppId
}
const url = isInternal
? endpoint
: checkSlashesInUrl(`/api/public/v1/${endpoint}`)
return { headers: extraHeaders, url }
}
export function generateMakeRequest(
apiKey: string,
isInternal = false
@ -23,18 +49,8 @@ export function generateMakeRequest(
body?: any,
intAppId: string | null = config.getAppId()
) => {
const extraHeaders: any = {
"x-budibase-api-key": apiKey,
}
if (intAppId) {
extraHeaders["x-budibase-app-id"] = intAppId
}
const url = isInternal
? endpoint
: checkSlashesInUrl(`/api/public/v1/${endpoint}`)
const req = request[method](url).set(config.defaultHeaders(extraHeaders))
const { headers, url } = base(apiKey, endpoint, intAppId, isInternal)
const req = request[method](url).set(config.defaultHeaders(headers))
if (body) {
req.send(body)
}
@ -43,3 +59,30 @@ export function generateMakeRequest(
return res
}
}
export function generateMakeRequestWithFormData(
apiKey: string,
isInternal = false
): MakeRequestWithFormDataResponse {
const request = setup.getRequest()!
const config = setup.getConfig()!
return async (
method: HttpMethod,
endpoint: string,
fields: Record<string, string | { path: string }>,
intAppId: string | null = config.getAppId()
) => {
const { headers, url } = base(apiKey, endpoint, intAppId, isInternal)
const req = request[method](url).set(config.defaultHeaders(headers))
for (let [field, value] of Object.entries(fields)) {
if (typeof value === "string") {
req.field(field, value)
} else {
req.attach(field, value.path)
}
}
const res = await req
expect(res.body).toBeDefined()
return res
}
}

View File

@ -0,0 +1,32 @@
import * as setup from "./utilities"
import path from "path"
jest.setTimeout(15000)
const PASSWORD = "testtest"
describe("/applications/:appId/import", () => {
let request = setup.getRequest()
let config = setup.getConfig()
afterAll(setup.afterAll)
beforeAll(async () => {
await config.init()
})
it("should be able to perform import", async () => {
const appId = config.getAppId()
const res = await request
.post(`/api/applications/${appId}/import`)
.field("encryptionPassword", PASSWORD)
.attach("appExport", path.join(__dirname, "assets", "export.tar.gz"))
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toBe("app updated")
const screens = await config.api.screen.list()
expect(screens.length).toBe(2)
expect(screens[0].routing.route).toBe("/derp")
expect(screens[1].routing.route).toBe("/blank")
})
})

View File

@ -3,7 +3,7 @@ import { databaseTestProviders } from "../../../integrations/tests/utils"
import tk from "timekeeper"
import { outputProcessing } from "../../../utilities/rowProcessor"
import * as setup from "./utilities"
import { context, roles, tenancy } from "@budibase/backend-core"
import { context, InternalTable, roles, tenancy } from "@budibase/backend-core"
import { quotas } from "@budibase/pro"
import {
FieldType,
@ -1415,6 +1415,23 @@ describe.each([
})
})
isInternal &&
it("doesn't allow creating in user table", async () => {
const userTableId = InternalTable.USER_METADATA
const response = await config.api.row.save(
userTableId,
{
tableId: userTableId,
firstName: "Joe",
lastName: "Joe",
email: "joe@joe.com",
roles: {},
},
{ expectStatus: 400 }
)
expect(response.message).toBe("Cannot create new user entry.")
})
describe("permissions", () => {
let viewId: string
let tableId: string

View File

@ -18,6 +18,14 @@ export interface paths {
"/applications/{appId}/publish": {
post: operations["appPublish"];
};
"/applications/{appId}/import": {
/** This endpoint is only available on a business or enterprise license. */
post: operations["appImport"];
};
"/applications/{appId}/export": {
/** This endpoint is only available on a business or enterprise license. */
post: operations["appExport"];
};
"/applications/search": {
/** Based on application properties (currently only name) search for applications. */
post: operations["appSearch"];
@ -158,6 +166,12 @@ export interface components {
appUrl: string;
};
};
appExport: {
/** @description An optional password used to encrypt the export. */
encryptPassword: string;
/** @description Set whether the internal table rows should be excluded from the export. */
excludeRows: boolean;
};
/** @description The row to be created/updated, based on the table schema. */
row: { [key: string]: unknown };
searchOutput: {
@ -889,6 +903,54 @@ export interface operations {
};
};
};
/** This endpoint is only available on a business or enterprise license. */
appImport: {
parameters: {
path: {
/** The ID of the app which this request is targeting. */
appId: components["parameters"]["appIdUrl"];
};
};
responses: {
/** Application has been updated. */
204: never;
};
requestBody: {
content: {
"multipart/form-data": {
/** @description Password for the export if it is encrypted. */
encryptedPassword?: string;
/**
* Format: binary
* @description The app export to import.
*/
appExport: string;
};
};
};
};
/** This endpoint is only available on a business or enterprise license. */
appExport: {
parameters: {
path: {
/** The ID of the app which this request is targeting. */
appId: components["parameters"]["appIdUrl"];
};
};
responses: {
/** A gzip tarball containing the app export, encrypted if password provided. */
200: {
content: {
"application/gzip": string;
};
};
};
requestBody: {
content: {
"application/json": components["schemas"]["appExport"];
};
};
};
/** Based on application properties (currently only name) search for applications. */
appSearch: {
responses: {

View File

@ -1,4 +1,8 @@
import { Knex, knex } from "knex"
import { db as dbCore } from "@budibase/backend-core"
import { QueryOptions } from "../../definitions/datasource"
import { isIsoDateString, SqlClient } from "../utils"
import SqlTableQueryBuilder from "./sqlTable"
import {
Operation,
QueryJson,
@ -6,11 +10,8 @@ import {
SearchFilters,
SortDirection,
} from "@budibase/types"
import { db as dbCore } from "@budibase/backend-core"
import { QueryOptions } from "../../definitions/datasource"
import { isIsoDateString, SqlClient } from "../utils"
import SqlTableQueryBuilder from "./sqlTable"
import environment from "../../environment"
import { isValidFilter } from "../utils"
const envLimit = environment.SQL_MAX_ROWS
? parseInt(environment.SQL_MAX_ROWS)
@ -261,15 +262,17 @@ class InternalBuilder {
if (isEmptyObject(value.high)) {
value.high = ""
}
if (value.low && value.high) {
const lowValid = isValidFilter(value.low),
highValid = isValidFilter(value.high)
if (lowValid && highValid) {
// Use a between operator if we have 2 valid range values
const fnc = allOr ? "orWhereBetween" : "whereBetween"
query = query[fnc](key, [value.low, value.high])
} else if (value.low) {
} else if (lowValid) {
// Use just a single greater than operator if we only have a low
const fnc = allOr ? "orWhere" : "where"
query = query[fnc](key, ">", value.low)
} else if (value.high) {
} else if (highValid) {
// Use just a single less than operator if we only have a high
const fnc = allOr ? "orWhere" : "where"
query = query[fnc](key, "<", value.high)

View File

@ -158,6 +158,12 @@ class MySQLIntegration extends Sql implements DatasourcePlus {
) {
config.ssl.rejectUnauthorized = config.rejectUnauthorized
}
// The MySQL library we use doesn't directly document the parameters that can be passed in the ssl
// object, it instead points to an older library that it says it is mostly API compatible with, that
// older library actually documents what parameters can be passed in the ssl object.
// https://github.com/sidorares/node-mysql2#api-and-configuration
// https://github.com/mysqljs/mysql#ssl-options
// @ts-ignore
delete config.rejectUnauthorized
this.config = {

View File

@ -1,6 +1,11 @@
import { SourceName, SqlQuery, Datasource, Table } from "@budibase/types"
import { SqlQuery, Table, SearchFilters } from "@budibase/types"
import { DocumentType, SEPARATOR } from "../db/utils"
import { FieldTypes, BuildSchemaErrors, InvalidColumns } from "../constants"
import {
FieldTypes,
BuildSchemaErrors,
InvalidColumns,
NoEmptyFilterStrings,
} from "../constants"
import { helpers } from "@budibase/shared-core"
const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}`
@ -343,3 +348,36 @@ export function getPrimaryDisplay(testValue: unknown): string | undefined {
}
return testValue as string
}
export function isValidFilter(value: any) {
return value != null && value !== ""
}
// don't do a pure falsy check, as 0 is included
// https://github.com/Budibase/budibase/issues/10118
export function removeEmptyFilters(filters: SearchFilters) {
for (let filterField of NoEmptyFilterStrings) {
if (!filters[filterField]) {
continue
}
for (let filterType of Object.keys(filters)) {
if (filterType !== filterField) {
continue
}
// don't know which one we're checking, type could be anything
const value = filters[filterType] as unknown
if (typeof value === "object") {
for (let [key, value] of Object.entries(
filters[filterType] as object
)) {
if (value == null || value === "") {
// @ts-ignore
delete filters[filterField][key]
}
}
}
}
}
return filters
}

View File

@ -1,13 +1,14 @@
import { AppStatus } from "../../../db/utils"
import { App, ContextUser } from "@budibase/types"
import { App, ContextUser, User } from "@budibase/types"
import { getLocksById } from "../../../utilities/redis"
import { enrichApps } from "../../users/sessions"
import { checkAppMetadata } from "../../../automations/logging"
import { db as dbCore, users } from "@budibase/backend-core"
import { groups } from "@budibase/pro"
export function filterAppList(user: ContextUser, apps: App[]) {
export function filterAppList(user: User, apps: App[]) {
let appList: string[] = []
const roleApps = Object.keys(user.roles || {})
const roleApps = Object.keys(user.roles)
if (users.hasAppBuilderPermissions(user)) {
appList = user.builder?.apps || []
appList = appList.concat(roleApps)
@ -23,7 +24,12 @@ export async function fetch(status: AppStatus, user: ContextUser) {
const dev = status === AppStatus.DEV
const all = status === AppStatus.ALL
let apps = (await dbCore.getAllApps({ dev, all })) as App[]
apps = filterAppList(user, apps)
const enrichedUser = await groups.enrichUserRolesFromGroups({
...user,
roles: user.roles || {},
})
apps = filterAppList(enrichedUser, apps)
const appIds = apps
.filter((app: any) => app.status === "development")

View File

@ -0,0 +1,102 @@
import { db as dbCore } from "@budibase/backend-core"
import {
DocumentTypesToImport,
Document,
Database,
RowValue,
} from "@budibase/types"
import backups from "../backups"
export type FileAttributes = {
type: string
path: string
}
function mergeUpdateAndDeleteDocuments(
updateDocs: Document[],
deleteDocs: Document[]
) {
// compress the documents to create and to delete (if same ID, then just update the rev)
const finalToDelete = []
for (let deleteDoc of deleteDocs) {
const found = updateDocs.find(doc => doc._id === deleteDoc._id)
if (found) {
found._rev = deleteDoc._rev
} else {
finalToDelete.push(deleteDoc)
}
}
return [...updateDocs, ...finalToDelete]
}
async function removeImportableDocuments(db: Database) {
// get the references to the documents, not the whole document
const docPromises = []
for (let docType of DocumentTypesToImport) {
docPromises.push(db.allDocs(dbCore.getDocParams(docType)))
}
let documentRefs: { _id: string; _rev: string }[] = []
for (let response of await Promise.all(docPromises)) {
documentRefs = documentRefs.concat(
response.rows.map(row => ({
_id: row.id,
_rev: (row.value as RowValue).rev,
}))
)
}
// add deletion key
return documentRefs.map(ref => ({ _deleted: true, ...ref }))
}
async function getImportableDocuments(db: Database) {
// get the whole document
const docPromises = []
for (let docType of DocumentTypesToImport) {
docPromises.push(
db.allDocs(dbCore.getDocParams(docType, null, { include_docs: true }))
)
}
// map the responses to the document itself
let documents: Document[] = []
for (let response of await Promise.all(docPromises)) {
documents = documents.concat(response.rows.map(row => row.doc))
}
// remove the _rev, stops it being written
documents.forEach(doc => {
delete doc._rev
})
return documents
}
export async function updateWithExport(
appId: string,
file: FileAttributes,
password?: string
) {
const devId = dbCore.getDevAppID(appId)
const tempAppName = `temp_${devId}`
const tempDb = dbCore.getDB(tempAppName)
const appDb = dbCore.getDB(devId)
try {
const template = {
file: {
type: file.type!,
path: file.path!,
password,
},
}
// get a temporary version of the import
// don't need obj store, the existing app already has everything we need
await backups.importApp(devId, tempDb, template, {
importObjStoreContents: false,
})
// get the documents to copy
const toUpdate = await getImportableDocuments(tempDb)
// clear out the old documents
const toDelete = await removeImportableDocuments(appDb)
// now bulk update documents - add new ones, delete old ones and update common ones
await appDb.bulkDocs(mergeUpdateAndDeleteDocuments(toUpdate, toDelete))
} finally {
await tempDb.destroy()
}
}

View File

@ -1,9 +1,11 @@
import * as sync from "./sync"
import * as utils from "./utils"
import * as applications from "./applications"
import * as imports from "./import"
export default {
...sync,
...utils,
...applications,
...imports,
}

View File

@ -8,11 +8,7 @@ import {
TABLE_ROW_PREFIX,
USER_METDATA_PREFIX,
} from "../../../db/utils"
import {
DB_EXPORT_FILE,
GLOBAL_DB_EXPORT_FILE,
STATIC_APP_FILES,
} from "./constants"
import { DB_EXPORT_FILE, STATIC_APP_FILES } from "./constants"
import fs from "fs"
import { join } from "path"
import env from "../../../environment"

View File

@ -151,7 +151,8 @@ export function getListOfAppsInMulti(tmpPath: string) {
export async function importApp(
appId: string,
db: Database,
template: TemplateType
template: TemplateType,
opts: { importObjStoreContents: boolean } = { importObjStoreContents: true }
) {
let prodAppId = dbCore.getProdAppID(appId)
let dbStream: any
@ -165,7 +166,7 @@ export async function importApp(
}
const contents = fs.readdirSync(tmpPath)
// have to handle object import
if (contents.length) {
if (contents.length && opts.importObjStoreContents) {
let promises = []
let excludedFiles = [GLOBAL_DB_EXPORT_FILE, DB_EXPORT_FILE]
for (let filename of contents) {

View File

@ -3,6 +3,7 @@ import { isExternalTable } from "../../../integrations/utils"
import * as internal from "./search/internal"
import * as external from "./search/external"
import { Format } from "../../../api/controllers/view/exporters"
export { isValidFilter, removeEmptyFilters } from "../../../integrations/utils"
export interface ViewParams {
calculation: string

View File

@ -1,8 +1,8 @@
import * as utils from "../utils"
import * as search from "../../app/rows/search"
describe("removeEmptyFilters", () => {
it("0 should not be removed", () => {
const filters = utils.removeEmptyFilters({
const filters = search.removeEmptyFilters({
equal: {
column: 0,
},
@ -11,7 +11,7 @@ describe("removeEmptyFilters", () => {
})
it("empty string should be removed", () => {
const filters = utils.removeEmptyFilters({
const filters = search.removeEmptyFilters({
equal: {
column: "",
},

View File

@ -53,7 +53,6 @@ import {
View,
FieldType,
RelationshipType,
ViewV2,
CreateViewRequest,
} from "@budibase/types"

View File

@ -5,6 +5,7 @@ import { TableAPI } from "./table"
import { ViewV2API } from "./viewV2"
import { DatasourceAPI } from "./datasource"
import { LegacyViewAPI } from "./legacyView"
import { ScreenAPI } from "./screen"
export default class API {
table: TableAPI
@ -13,6 +14,7 @@ export default class API {
row: RowAPI
permission: PermissionAPI
datasource: DatasourceAPI
screen: ScreenAPI
constructor(config: TestConfiguration) {
this.table = new TableAPI(config)
@ -21,5 +23,6 @@ export default class API {
this.row = new RowAPI(config)
this.permission = new PermissionAPI(config)
this.datasource = new DatasourceAPI(config)
this.screen = new ScreenAPI(config)
}
}

View File

@ -0,0 +1,18 @@
import TestConfiguration from "../TestConfiguration"
import { Screen } from "@budibase/types"
import { TestAPI } from "./base"
export class ScreenAPI extends TestAPI {
constructor(config: TestConfiguration) {
super(config)
}
list = async (): Promise<Screen[]> => {
const res = await this.request
.get(`/api/screens`)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
return res.body as Screen[]
}
}

View File

@ -19,7 +19,7 @@
"devDependencies": {
"concurrently": "^7.6.0",
"rimraf": "3.0.2",
"typescript": "4.7.3"
"typescript": "5.2.2"
},
"nx": {
"targets": {
@ -43,7 +43,6 @@
}
]
}
}
}
}

View File

@ -45,6 +45,6 @@
"rollup-plugin-node-globals": "^1.4.0",
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-terser": "^7.0.2",
"typescript": "4.7.3"
"typescript": "5.2.2"
}
}

View File

@ -24,7 +24,7 @@
"concurrently": "^7.6.0",
"koa-body": "4.2.0",
"rimraf": "3.0.2",
"typescript": "4.7.3"
"typescript": "5.2.2"
},
"dependencies": {
"scim-patch": "^0.7.0"

View File

@ -39,6 +39,25 @@ export enum DocumentType {
AUDIT_LOG = "al",
}
// these are the core documents that make up the data, design
// and automation sections of an app. This excludes any internal
// rows as we shouldn't import data.
export const DocumentTypesToImport: DocumentType[] = [
DocumentType.ROLE,
DocumentType.DATASOURCE,
DocumentType.DATASOURCE_PLUS,
DocumentType.TABLE,
DocumentType.AUTOMATION,
DocumentType.WEBHOOK,
DocumentType.SCREEN,
DocumentType.QUERY,
DocumentType.METADATA,
DocumentType.MEM_VIEW,
// Deprecated but still copied
DocumentType.INSTANCE,
DocumentType.LAYOUT,
]
// these documents don't really exist, they are part of other
// documents or enriched into existence as part of get requests
export enum VirtualDocumentType {

View File

@ -11,7 +11,7 @@ export enum Feature {
SYNC_AUTOMATIONS = "syncAutomations",
APP_BUILDERS = "appBuilders",
OFFLINE = "offline",
USER_ROLE_PUBLIC_API = "userRolePublicApi",
EXPANDED_PUBLIC_API = "expandedPublicApi",
VIEW_PERMISSIONS = "viewPermissions",
}

View File

@ -88,7 +88,6 @@
"@types/server-destroy": "1.0.1",
"@types/supertest": "2.0.12",
"@types/uuid": "8.3.4",
"@typescript-eslint/parser": "5.45.0",
"copyfiles": "2.4.1",
"eslint": "6.8.0",
"jest": "29.6.2",
@ -100,7 +99,7 @@
"timekeeper": "2.2.0",
"ts-node": "10.8.1",
"tsconfig-paths": "4.0.0",
"typescript": "4.7.3",
"typescript": "5.2.2",
"update-dotenv": "1.1.1"
},
"nx": {

View File

@ -38,7 +38,7 @@
"ts-jest": "29.1.1",
"ts-node": "10.8.1",
"tsconfig-paths": "4.0.0",
"typescript": "4.7.3"
"typescript": "5.2.2"
},
"dependencies": {
"@budibase/backend-core": "^2.3.17",

View File

@ -6696,51 +6696,52 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/parser@5.45.0":
version "5.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.45.0.tgz#b18a5f6b3cf1c2b3e399e9d2df4be40d6b0ddd0e"
integrity sha512-brvs/WSM4fKUmF5Ot/gEve6qYiCMjm6w4HkHPfS6ZNmxTS0m0iNN4yOChImaCkqc1hRwFGqUyanMXuGal6oyyQ==
"@typescript-eslint/parser@6.7.2":
version "6.7.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.7.2.tgz#e0ae93771441b9518e67d0660c79e3a105497af4"
integrity sha512-KA3E4ox0ws+SPyxQf9iSI25R6b4Ne78ORhNHeVKrPQnoYsb9UhieoiRoJgrzgEeKGOXhcY1i8YtOeCHHTDa6Fw==
dependencies:
"@typescript-eslint/scope-manager" "5.45.0"
"@typescript-eslint/types" "5.45.0"
"@typescript-eslint/typescript-estree" "5.45.0"
"@typescript-eslint/scope-manager" "6.7.2"
"@typescript-eslint/types" "6.7.2"
"@typescript-eslint/typescript-estree" "6.7.2"
"@typescript-eslint/visitor-keys" "6.7.2"
debug "^4.3.4"
"@typescript-eslint/scope-manager@5.45.0":
version "5.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.45.0.tgz#7a4ac1bfa9544bff3f620ab85947945938319a96"
integrity sha512-noDMjr87Arp/PuVrtvN3dXiJstQR1+XlQ4R1EvzG+NMgXi8CuMCXpb8JqNtFHKceVSQ985BZhfRdowJzbv4yKw==
"@typescript-eslint/scope-manager@6.7.2":
version "6.7.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.7.2.tgz#cf59a2095d2f894770c94be489648ad1c78dc689"
integrity sha512-bgi6plgyZjEqapr7u2mhxGR6E8WCzKNUFWNh6fkpVe9+yzRZeYtDTbsIBzKbcxI+r1qVWt6VIoMSNZ4r2A+6Yw==
dependencies:
"@typescript-eslint/types" "5.45.0"
"@typescript-eslint/visitor-keys" "5.45.0"
"@typescript-eslint/types" "6.7.2"
"@typescript-eslint/visitor-keys" "6.7.2"
"@typescript-eslint/types@4.33.0":
version "4.33.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.33.0.tgz#a1e59036a3b53ae8430ceebf2a919dc7f9af6d72"
integrity sha512-zKp7CjQzLQImXEpLt2BUw1tvOMPfNoTAfb8l51evhYbOEEzdWyQNmHWWGPR6hwKJDAi+1VXSBmnhL9kyVTTOuQ==
"@typescript-eslint/types@5.45.0":
version "5.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.45.0.tgz#794760b9037ee4154c09549ef5a96599621109c5"
integrity sha512-QQij+u/vgskA66azc9dCmx+rev79PzX8uDHpsqSjEFtfF2gBUTRCpvYMh2gw2ghkJabNkPlSUCimsyBEQZd1DA==
"@typescript-eslint/types@5.53.0":
version "5.53.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.53.0.tgz#f79eca62b97e518ee124086a21a24f3be267026f"
integrity sha512-5kcDL9ZUIP756K6+QOAfPkigJmCPHcLN7Zjdz76lQWWDdzfOhZDTj1irs6gPBKiXx5/6O3L0+AvupAut3z7D2A==
"@typescript-eslint/typescript-estree@5.45.0":
version "5.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.45.0.tgz#f70a0d646d7f38c0dfd6936a5e171a77f1e5291d"
integrity sha512-maRhLGSzqUpFcZgXxg1qc/+H0bT36lHK4APhp0AEUVrpSwXiRAomm/JGjSG+kNUio5kAa3uekCYu/47cnGn5EQ==
"@typescript-eslint/types@6.7.2":
version "6.7.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.7.2.tgz#75a615a6dbeca09cafd102fe7f465da1d8a3c066"
integrity sha512-flJYwMYgnUNDAN9/GAI3l8+wTmvTYdv64fcH8aoJK76Y+1FCZ08RtI5zDerM/FYT5DMkAc+19E4aLmd5KqdFyg==
"@typescript-eslint/typescript-estree@6.7.2":
version "6.7.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.7.2.tgz#ce5883c23b581a5caf878af641e49dd0349238c7"
integrity sha512-kiJKVMLkoSciGyFU0TOY0fRxnp9qq1AzVOHNeN1+B9erKFCJ4Z8WdjAkKQPP+b1pWStGFqezMLltxO+308dJTQ==
dependencies:
"@typescript-eslint/types" "5.45.0"
"@typescript-eslint/visitor-keys" "5.45.0"
"@typescript-eslint/types" "6.7.2"
"@typescript-eslint/visitor-keys" "6.7.2"
debug "^4.3.4"
globby "^11.1.0"
is-glob "^4.0.3"
semver "^7.3.7"
tsutils "^3.21.0"
semver "^7.5.4"
ts-api-utils "^1.0.1"
"@typescript-eslint/typescript-estree@^4.33.0":
version "4.33.0"
@ -6776,14 +6777,6 @@
"@typescript-eslint/types" "4.33.0"
eslint-visitor-keys "^2.0.0"
"@typescript-eslint/visitor-keys@5.45.0":
version "5.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.45.0.tgz#e0d160e9e7fdb7f8da697a5b78e7a14a22a70528"
integrity sha512-jc6Eccbn2RtQPr1s7th6jJWQHBHI6GBVQkCHoJFQ5UreaKm59Vxw+ynQUPPY2u2Amquc+7tmEoC2G52ApsGNNg==
dependencies:
"@typescript-eslint/types" "5.45.0"
eslint-visitor-keys "^3.3.0"
"@typescript-eslint/visitor-keys@5.53.0":
version "5.53.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.53.0.tgz#8a5126623937cdd909c30d8fa72f79fa56cc1a9f"
@ -6792,6 +6785,14 @@
"@typescript-eslint/types" "5.53.0"
eslint-visitor-keys "^3.3.0"
"@typescript-eslint/visitor-keys@6.7.2":
version "6.7.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.7.2.tgz#4cb2bd786f1f459731b0ad1584c9f73e1c7a4d5c"
integrity sha512-uVw9VIMFBUTz8rIeaUT3fFe8xIUx8r4ywAdlQv1ifH+6acn/XF8Y6rwJ7XNmkNMDrTW+7+vxFFPIF40nJCVsMQ==
dependencies:
"@typescript-eslint/types" "6.7.2"
eslint-visitor-keys "^3.4.1"
"@vitest/expect@0.29.8":
version "0.29.8"
resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-0.29.8.tgz#6ecdd031b4ea8414717d10b65ccd800908384612"
@ -21927,6 +21928,13 @@ semver@^7.3.2, semver@^7.3.7, semver@^7.3.8:
dependencies:
lru-cache "^6.0.0"
semver@^7.5.4:
version "7.5.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
dependencies:
lru-cache "^6.0.0"
semver@~2.3.1:
version "2.3.2"
resolved "https://registry.yarnpkg.com/semver/-/semver-2.3.2.tgz#b9848f25d6cf36333073ec9ef8856d42f1233e52"
@ -23882,6 +23890,11 @@ triple-beam@^1.3.0:
resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9"
integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
ts-api-utils@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.0.3.tgz#f12c1c781d04427313dbac808f453f050e54a331"
integrity sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==
ts-graphviz@^1.5.0:
version "1.5.4"
resolved "https://registry.yarnpkg.com/ts-graphviz/-/ts-graphviz-1.5.4.tgz#61a3059afeac4f6d4be3c6729a4d88546ca9e095"
@ -24120,10 +24133,10 @@ typeof@^1.0.0:
resolved "https://registry.yarnpkg.com/typeof/-/typeof-1.0.0.tgz#9c84403f2323ae5399167275497638ea1d2f2440"
integrity sha512-Pze0mIxYXhaJdpw1ayMzOA7rtGr1OmsTY/Z+FWtRKIqXFz6aoDLjqdbWE/tcIBSC8nhnVXiRrEXujodR/xiFAA==
typescript@4.7.3:
version "4.7.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.3.tgz#8364b502d5257b540f9de4c40be84c98e23a129d"
integrity sha512-WOkT3XYvrpXx4vMMqlD+8R8R37fZkjyLGlxavMc4iB8lrl8L0DeTcHbYgw/v0N/z9wAFsgBhcsF0ruoySS22mA==
typescript@5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78"
integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==
"typescript@>=3 < 6":
version "5.0.4"