Merge branch 'develop' of github.com:Budibase/budibase into cheeks-lab-day-spreadsheet
This commit is contained in:
commit
b8e7e0b701
|
@ -11,7 +11,6 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- develop
|
- develop
|
||||||
- release
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
@ -20,9 +19,53 @@ env:
|
||||||
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Use Node.js 14.x
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: 14.x
|
||||||
|
- run: yarn
|
||||||
|
- run: yarn lint
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Use Node.js 14.x
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: 14.x
|
||||||
|
- name: Install Pro
|
||||||
|
run: yarn install:pro $BRANCH $BASE_BRANCH
|
||||||
|
- run: yarn
|
||||||
|
- run: yarn bootstrap
|
||||||
|
- run: yarn build
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Use Node.js 14.x
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: 14.x
|
||||||
|
- name: Install Pro
|
||||||
|
run: yarn install:pro $BRANCH $BASE_BRANCH
|
||||||
|
- run: yarn
|
||||||
|
- run: yarn bootstrap
|
||||||
|
- run: yarn test
|
||||||
|
- uses: codecov/codecov-action@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos
|
||||||
|
files: ./packages/server/coverage/clover.xml,./packages/worker/coverage/clover.xml,./packages/backend-core/coverage/clover.xml
|
||||||
|
name: codecov-umbrella
|
||||||
|
verbose: true
|
||||||
|
|
||||||
|
integration-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
services:
|
services:
|
||||||
couchdb:
|
couchdb:
|
||||||
image: ibmcom/couchdb3
|
image: ibmcom/couchdb3
|
||||||
|
@ -31,39 +74,18 @@ jobs:
|
||||||
COUCHDB_USER: budibase
|
COUCHDB_USER: budibase
|
||||||
ports:
|
ports:
|
||||||
- 4567:5984
|
- 4567:5984
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [14.x]
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
- name: Use Node.js 14.x
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: 14.x
|
||||||
|
|
||||||
- name: Install Pro
|
- name: Install Pro
|
||||||
run: yarn install:pro $BRANCH $BASE_BRANCH
|
run: yarn install:pro $BRANCH $BASE_BRANCH
|
||||||
|
|
||||||
- run: yarn
|
- run: yarn
|
||||||
- run: yarn bootstrap
|
- run: yarn bootstrap
|
||||||
- run: yarn lint
|
|
||||||
- run: yarn build
|
- run: yarn build
|
||||||
- run: yarn test
|
- run: |
|
||||||
env:
|
|
||||||
CI: true
|
|
||||||
name: Budibase CI
|
|
||||||
- uses: codecov/codecov-action@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos
|
|
||||||
files: ./packages/server/coverage/clover.xml,./packages/worker/coverage/clover.xml,./packages/backend-core/coverage/clover.xml
|
|
||||||
name: codecov-umbrella
|
|
||||||
verbose: true
|
|
||||||
|
|
||||||
- name: QA Core Integration Tests
|
|
||||||
run: |
|
|
||||||
cd qa-core
|
cd qa-core
|
||||||
yarn
|
yarn
|
||||||
yarn api:test:ci
|
yarn api:test:ci
|
|
@ -1,4 +1,2 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
yarn run lint
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "2.3.17-alpha.4",
|
"version": "2.3.18-alpha.8",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
|
|
|
@ -9,15 +9,9 @@ const baseConfig: Config.InitialProjectOptions = {
|
||||||
transform: {
|
transform: {
|
||||||
"^.+\\.ts?$": "@swc/jest",
|
"^.+\\.ts?$": "@swc/jest",
|
||||||
},
|
},
|
||||||
}
|
moduleNameMapper: {
|
||||||
|
|
||||||
if (!process.env.CI) {
|
|
||||||
// use sources when not in CI
|
|
||||||
baseConfig.moduleNameMapper = {
|
|
||||||
"@budibase/types": "<rootDir>/../types/src",
|
"@budibase/types": "<rootDir>/../types/src",
|
||||||
}
|
},
|
||||||
} else {
|
|
||||||
console.log("Running tests with compiled dependency sources")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const config: Config.InitialOptions = {
|
const config: Config.InitialOptions = {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@budibase/backend-core",
|
"name": "@budibase/backend-core",
|
||||||
"version": "2.3.17-alpha.4",
|
"version": "2.3.18-alpha.8",
|
||||||
"description": "Budibase backend core libraries used in server and worker",
|
"description": "Budibase backend core libraries used in server and worker",
|
||||||
"main": "dist/src/index.js",
|
"main": "dist/src/index.js",
|
||||||
"types": "dist/src/index.d.ts",
|
"types": "dist/src/index.d.ts",
|
||||||
|
@ -18,13 +18,13 @@
|
||||||
"build:pro": "../../scripts/pro/build.sh",
|
"build:pro": "../../scripts/pro/build.sh",
|
||||||
"postbuild": "yarn run build:pro",
|
"postbuild": "yarn run build:pro",
|
||||||
"build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput",
|
"build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput",
|
||||||
"test": "jest --coverage --maxWorkers=2",
|
"test": "bash scripts/test.sh",
|
||||||
"test:watch": "jest --watchAll"
|
"test:watch": "jest --watchAll"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@budibase/nano": "10.1.1",
|
"@budibase/nano": "10.1.1",
|
||||||
"@budibase/pouchdb-replication-stream": "1.2.10",
|
"@budibase/pouchdb-replication-stream": "1.2.10",
|
||||||
"@budibase/types": "2.3.17-alpha.4",
|
"@budibase/types": "2.3.18-alpha.8",
|
||||||
"@shopify/jest-koa-mocks": "5.0.1",
|
"@shopify/jest-koa-mocks": "5.0.1",
|
||||||
"@techpass/passport-openidconnect": "0.3.2",
|
"@techpass/passport-openidconnect": "0.3.2",
|
||||||
"aws-cloudfront-sign": "2.2.0",
|
"aws-cloudfront-sign": "2.2.0",
|
||||||
|
@ -62,7 +62,7 @@
|
||||||
"@trendyol/jest-testcontainers": "^2.1.1",
|
"@trendyol/jest-testcontainers": "^2.1.1",
|
||||||
"@types/chance": "1.1.3",
|
"@types/chance": "1.1.3",
|
||||||
"@types/ioredis": "4.28.0",
|
"@types/ioredis": "4.28.0",
|
||||||
"@types/jest": "27.5.1",
|
"@types/jest": "28.1.1",
|
||||||
"@types/koa": "2.13.4",
|
"@types/koa": "2.13.4",
|
||||||
"@types/koa-pino-logger": "3.0.0",
|
"@types/koa-pino-logger": "3.0.0",
|
||||||
"@types/lodash": "4.14.180",
|
"@types/lodash": "4.14.180",
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
if [[ -n $CI ]]
|
||||||
|
then
|
||||||
|
# --runInBand performs better in ci where resources are limited
|
||||||
|
echo "jest --coverage --runInBand"
|
||||||
|
jest --coverage --runInBand
|
||||||
|
else
|
||||||
|
# --maxWorkers performs better in development
|
||||||
|
echo "jest --coverage"
|
||||||
|
jest --coverage
|
||||||
|
fi
|
|
@ -1,13 +1,24 @@
|
||||||
import API from "./api"
|
import API from "./api"
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
import { Header } from "../constants"
|
import { Header } from "../constants"
|
||||||
import { CloudAccount } from "@budibase/types"
|
import { CloudAccount, HealthStatusResponse } from "@budibase/types"
|
||||||
|
|
||||||
const api = new API(env.ACCOUNT_PORTAL_URL)
|
const api = new API(env.ACCOUNT_PORTAL_URL)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This client is intended to be used in a cloud hosted deploy only.
|
||||||
|
* Rather than relying on each consumer to perform the necessary environmental checks
|
||||||
|
* we use the following check to exit early with a undefined response which should be
|
||||||
|
* handled by the caller.
|
||||||
|
*/
|
||||||
|
const EXIT_EARLY = env.SELF_HOSTED || env.DISABLE_ACCOUNT_PORTAL
|
||||||
|
|
||||||
export const getAccount = async (
|
export const getAccount = async (
|
||||||
email: string
|
email: string
|
||||||
): Promise<CloudAccount | undefined> => {
|
): Promise<CloudAccount | undefined> => {
|
||||||
|
if (EXIT_EARLY) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
email,
|
email,
|
||||||
}
|
}
|
||||||
|
@ -29,6 +40,9 @@ export const getAccount = async (
|
||||||
export const getAccountByTenantId = async (
|
export const getAccountByTenantId = async (
|
||||||
tenantId: string
|
tenantId: string
|
||||||
): Promise<CloudAccount | undefined> => {
|
): Promise<CloudAccount | undefined> => {
|
||||||
|
if (EXIT_EARLY) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
tenantId,
|
tenantId,
|
||||||
}
|
}
|
||||||
|
@ -47,7 +61,12 @@ export const getAccountByTenantId = async (
|
||||||
return json[0]
|
return json[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getStatus = async () => {
|
export const getStatus = async (): Promise<
|
||||||
|
HealthStatusResponse | undefined
|
||||||
|
> => {
|
||||||
|
if (EXIT_EARLY) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const response = await api.get(`/api/status`, {
|
const response = await api.get(`/api/status`, {
|
||||||
headers: {
|
headers: {
|
||||||
[Header.API_KEY]: env.ACCOUNT_PORTAL_API_KEY,
|
[Header.API_KEY]: env.ACCOUNT_PORTAL_API_KEY,
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./accounts"
|
|
@ -1,10 +1,11 @@
|
||||||
const _passport = require("koa-passport")
|
const _passport = require("koa-passport")
|
||||||
const LocalStrategy = require("passport-local").Strategy
|
const LocalStrategy = require("passport-local").Strategy
|
||||||
const JwtStrategy = require("passport-jwt").Strategy
|
const JwtStrategy = require("passport-jwt").Strategy
|
||||||
import { getGlobalDB } from "../tenancy"
|
import { getGlobalDB } from "../context"
|
||||||
const refresh = require("passport-oauth2-refresh")
|
const refresh = require("passport-oauth2-refresh")
|
||||||
import { Config } from "../constants"
|
import { Config, Cookie } from "../constants"
|
||||||
import { getScopedConfig } from "../db"
|
import { getScopedConfig } from "../db"
|
||||||
|
import { getSessionsForUser, invalidateSessions } from "../security/sessions"
|
||||||
import {
|
import {
|
||||||
jwt as jwtPassport,
|
jwt as jwtPassport,
|
||||||
local,
|
local,
|
||||||
|
@ -15,8 +16,11 @@ import {
|
||||||
google,
|
google,
|
||||||
} from "../middleware"
|
} from "../middleware"
|
||||||
import { invalidateUser } from "../cache/user"
|
import { invalidateUser } from "../cache/user"
|
||||||
import { User } from "@budibase/types"
|
import { PlatformLogoutOpts, User } from "@budibase/types"
|
||||||
import { logAlert } from "../logging"
|
import { logAlert } from "../logging"
|
||||||
|
import * as events from "../events"
|
||||||
|
import * as userCache from "../cache/user"
|
||||||
|
import { clearCookie, getCookie } from "../utils"
|
||||||
export {
|
export {
|
||||||
auditLog,
|
auditLog,
|
||||||
authError,
|
authError,
|
||||||
|
@ -29,6 +33,7 @@ export {
|
||||||
google,
|
google,
|
||||||
oidc,
|
oidc,
|
||||||
} from "../middleware"
|
} from "../middleware"
|
||||||
|
import { ssoSaveUserNoOp } from "../middleware/passport/sso/sso"
|
||||||
export const buildAuthMiddleware = authenticated
|
export const buildAuthMiddleware = authenticated
|
||||||
export const buildTenancyMiddleware = tenancy
|
export const buildTenancyMiddleware = tenancy
|
||||||
export const buildCsrfMiddleware = csrf
|
export const buildCsrfMiddleware = csrf
|
||||||
|
@ -71,7 +76,7 @@ async function refreshOIDCAccessToken(
|
||||||
if (!enrichedConfig) {
|
if (!enrichedConfig) {
|
||||||
throw new Error("OIDC Config contents invalid")
|
throw new Error("OIDC Config contents invalid")
|
||||||
}
|
}
|
||||||
strategy = await oidc.strategyFactory(enrichedConfig)
|
strategy = await oidc.strategyFactory(enrichedConfig, ssoSaveUserNoOp)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
throw new Error("Could not refresh OAuth Token")
|
throw new Error("Could not refresh OAuth Token")
|
||||||
|
@ -103,7 +108,11 @@ async function refreshGoogleAccessToken(
|
||||||
|
|
||||||
let strategy
|
let strategy
|
||||||
try {
|
try {
|
||||||
strategy = await google.strategyFactory(config, callbackUrl)
|
strategy = await google.strategyFactory(
|
||||||
|
config,
|
||||||
|
callbackUrl,
|
||||||
|
ssoSaveUserNoOp
|
||||||
|
)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
@ -161,6 +170,8 @@ export async function refreshOAuthToken(
|
||||||
return refreshResponse
|
return refreshResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Refactor to use user save function instead to prevent the need for
|
||||||
|
// manually saving and invalidating on callback
|
||||||
export async function updateUserOAuth(userId: string, oAuthConfig: any) {
|
export async function updateUserOAuth(userId: string, oAuthConfig: any) {
|
||||||
const details = {
|
const details = {
|
||||||
accessToken: oAuthConfig.accessToken,
|
accessToken: oAuthConfig.accessToken,
|
||||||
|
@ -188,3 +199,32 @@ export async function updateUserOAuth(userId: string, oAuthConfig: any) {
|
||||||
console.error("Could not update OAuth details for current user", e)
|
console.error("Could not update OAuth details for current user", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a user out from budibase. Re-used across account portal and builder.
|
||||||
|
*/
|
||||||
|
export async function platformLogout(opts: PlatformLogoutOpts) {
|
||||||
|
const ctx = opts.ctx
|
||||||
|
const userId = opts.userId
|
||||||
|
const keepActiveSession = opts.keepActiveSession
|
||||||
|
|
||||||
|
if (!ctx) throw new Error("Koa context must be supplied to logout.")
|
||||||
|
|
||||||
|
const currentSession = getCookie(ctx, Cookie.Auth)
|
||||||
|
let sessions = await getSessionsForUser(userId)
|
||||||
|
|
||||||
|
if (keepActiveSession) {
|
||||||
|
sessions = sessions.filter(
|
||||||
|
session => session.sessionId !== currentSession.sessionId
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// clear cookies
|
||||||
|
clearCookie(ctx, Cookie.Auth)
|
||||||
|
clearCookie(ctx, Cookie.CurrentApp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionIds = sessions.map(({ sessionId }) => sessionId)
|
||||||
|
await invalidateSessions(userId, { sessionIds, reason: "logout" })
|
||||||
|
await events.auth.logout()
|
||||||
|
await userCache.invalidateUser(userId)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { structures, testEnv } from "../../../tests"
|
||||||
|
import * as auth from "../auth"
|
||||||
|
import * as events from "../../events"
|
||||||
|
|
||||||
|
describe("platformLogout", () => {
|
||||||
|
it("should call platform logout", async () => {
|
||||||
|
await testEnv.withTenant(async () => {
|
||||||
|
const ctx = structures.koa.newContext()
|
||||||
|
await auth.platformLogout({ ctx, userId: "test" })
|
||||||
|
expect(events.auth.logout).toBeCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,61 +0,0 @@
|
||||||
require("../../../tests")
|
|
||||||
const { Writethrough } = require("../writethrough")
|
|
||||||
const { getDB } = require("../../db")
|
|
||||||
const tk = require("timekeeper")
|
|
||||||
const { structures } = require("../../../tests")
|
|
||||||
|
|
||||||
const START_DATE = Date.now()
|
|
||||||
tk.freeze(START_DATE)
|
|
||||||
|
|
||||||
|
|
||||||
const DELAY = 5000
|
|
||||||
|
|
||||||
const db = getDB(structures.db.id())
|
|
||||||
const db2 = getDB(structures.db.id())
|
|
||||||
const writethrough = new Writethrough(db, DELAY), writethrough2 = new Writethrough(db2, DELAY)
|
|
||||||
|
|
||||||
describe("writethrough", () => {
|
|
||||||
describe("put", () => {
|
|
||||||
let first
|
|
||||||
it("should be able to store, will go to DB", async () => {
|
|
||||||
const response = await writethrough.put({ _id: "test", value: 1 })
|
|
||||||
const output = await db.get(response.id)
|
|
||||||
first = output
|
|
||||||
expect(output.value).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("second put shouldn't update DB", async () => {
|
|
||||||
const response = await writethrough.put({ ...first, value: 2 })
|
|
||||||
const output = await db.get(response.id)
|
|
||||||
expect(first._rev).toBe(output._rev)
|
|
||||||
expect(output.value).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should put it again after delay period", async () => {
|
|
||||||
tk.freeze(START_DATE + DELAY + 1)
|
|
||||||
const response = await writethrough.put({ ...first, value: 3 })
|
|
||||||
const output = await db.get(response.id)
|
|
||||||
expect(response.rev).not.toBe(first._rev)
|
|
||||||
expect(output.value).toBe(3)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("get", () => {
|
|
||||||
it("should be able to retrieve", async () => {
|
|
||||||
const response = await writethrough.get("test")
|
|
||||||
expect(response.value).toBe(3)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("same doc, different databases (tenancy)", () => {
|
|
||||||
it("should be able to two different databases", async () => {
|
|
||||||
const resp1 = await writethrough.put({ _id: "db1", value: "first" })
|
|
||||||
const resp2 = await writethrough2.put({ _id: "db1", value: "second" })
|
|
||||||
expect(resp1.rev).toBeDefined()
|
|
||||||
expect(resp2.rev).toBeDefined()
|
|
||||||
expect((await db.get("db1")).value).toBe("first")
|
|
||||||
expect((await db2.get("db1")).value).toBe("second")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { structures, DBTestConfiguration } from "../../../tests"
|
||||||
|
import { Writethrough } from "../writethrough"
|
||||||
|
import { getDB } from "../../db"
|
||||||
|
import tk from "timekeeper"
|
||||||
|
|
||||||
|
const START_DATE = Date.now()
|
||||||
|
tk.freeze(START_DATE)
|
||||||
|
|
||||||
|
const DELAY = 5000
|
||||||
|
|
||||||
|
describe("writethrough", () => {
|
||||||
|
const config = new DBTestConfiguration()
|
||||||
|
|
||||||
|
const db = getDB(structures.db.id())
|
||||||
|
const db2 = getDB(structures.db.id())
|
||||||
|
|
||||||
|
const writethrough = new Writethrough(db, DELAY)
|
||||||
|
const writethrough2 = new Writethrough(db2, DELAY)
|
||||||
|
|
||||||
|
describe("put", () => {
|
||||||
|
let first: any
|
||||||
|
|
||||||
|
it("should be able to store, will go to DB", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const response = await writethrough.put({ _id: "test", value: 1 })
|
||||||
|
const output = await db.get(response.id)
|
||||||
|
first = output
|
||||||
|
expect(output.value).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("second put shouldn't update DB", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const response = await writethrough.put({ ...first, value: 2 })
|
||||||
|
const output = await db.get(response.id)
|
||||||
|
expect(first._rev).toBe(output._rev)
|
||||||
|
expect(output.value).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should put it again after delay period", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
tk.freeze(START_DATE + DELAY + 1)
|
||||||
|
const response = await writethrough.put({ ...first, value: 3 })
|
||||||
|
const output = await db.get(response.id)
|
||||||
|
expect(response.rev).not.toBe(first._rev)
|
||||||
|
expect(output.value).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("get", () => {
|
||||||
|
it("should be able to retrieve", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const response = await writethrough.get("test")
|
||||||
|
expect(response.value).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("same doc, different databases (tenancy)", () => {
|
||||||
|
it("should be able to two different databases", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const resp1 = await writethrough.put({ _id: "db1", value: "first" })
|
||||||
|
const resp2 = await writethrough2.put({ _id: "db1", value: "second" })
|
||||||
|
expect(resp1.rev).toBeDefined()
|
||||||
|
expect(resp2.rev).toBeDefined()
|
||||||
|
expect((await db.get("db1")).value).toBe("first")
|
||||||
|
expect((await db2.get("db1")).value).toBe("second")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,8 +1,9 @@
|
||||||
import * as redis from "../redis/init"
|
import * as redis from "../redis/init"
|
||||||
import { getTenantId, lookupTenantId, doWithGlobalDB } from "../tenancy"
|
import * as tenancy from "../tenancy"
|
||||||
|
import * as context from "../context"
|
||||||
|
import * as platform from "../platform"
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
import * as accounts from "../cloud/accounts"
|
import * as accounts from "../accounts"
|
||||||
import { Database } from "@budibase/types"
|
|
||||||
|
|
||||||
const EXPIRY_SECONDS = 3600
|
const EXPIRY_SECONDS = 3600
|
||||||
|
|
||||||
|
@ -10,7 +11,8 @@ const EXPIRY_SECONDS = 3600
|
||||||
* The default populate user function
|
* The default populate user function
|
||||||
*/
|
*/
|
||||||
async function populateFromDB(userId: string, tenantId: string) {
|
async function populateFromDB(userId: string, tenantId: string) {
|
||||||
const user = await doWithGlobalDB(tenantId, (db: Database) => db.get(userId))
|
const db = tenancy.getTenantDB(tenantId)
|
||||||
|
const user = await db.get(userId)
|
||||||
user.budibaseAccess = true
|
user.budibaseAccess = true
|
||||||
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
|
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
|
||||||
const account = await accounts.getAccount(user.email)
|
const account = await accounts.getAccount(user.email)
|
||||||
|
@ -42,9 +44,9 @@ export async function getUser(
|
||||||
}
|
}
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
try {
|
try {
|
||||||
tenantId = getTenantId()
|
tenantId = context.getTenantId()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
tenantId = await lookupTenantId(userId)
|
tenantId = await platform.users.lookupTenantId(userId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const client = await redis.getUserClient()
|
const client = await redis.getUserClient()
|
||||||
|
|
|
@ -1,108 +0,0 @@
|
||||||
import {
|
|
||||||
getGlobalUserParams,
|
|
||||||
getAllApps,
|
|
||||||
doWithDB,
|
|
||||||
StaticDatabases,
|
|
||||||
} from "../db"
|
|
||||||
import { doWithGlobalDB } from "../tenancy"
|
|
||||||
import { App, Tenants, User, Database } from "@budibase/types"
|
|
||||||
|
|
||||||
const TENANT_DOC = StaticDatabases.PLATFORM_INFO.docs.tenants
|
|
||||||
const PLATFORM_INFO_DB = StaticDatabases.PLATFORM_INFO.name
|
|
||||||
|
|
||||||
async function removeTenantFromInfoDB(tenantId: string) {
|
|
||||||
try {
|
|
||||||
await doWithDB(PLATFORM_INFO_DB, async (infoDb: Database) => {
|
|
||||||
const tenants = (await infoDb.get(TENANT_DOC)) as Tenants
|
|
||||||
tenants.tenantIds = tenants.tenantIds.filter(id => id !== tenantId)
|
|
||||||
|
|
||||||
await infoDb.put(tenants)
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error removing tenant ${tenantId} from info db`, err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeUserFromInfoDB(dbUser: User) {
|
|
||||||
await doWithDB(PLATFORM_INFO_DB, async (infoDb: Database) => {
|
|
||||||
const keys = [dbUser._id!, dbUser.email]
|
|
||||||
const userDocs = await infoDb.allDocs({
|
|
||||||
keys,
|
|
||||||
include_docs: true,
|
|
||||||
})
|
|
||||||
const toDelete = userDocs.rows.map((row: any) => {
|
|
||||||
return {
|
|
||||||
...row.doc,
|
|
||||||
_deleted: true,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await infoDb.bulkDocs(toDelete)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeUsersFromInfoDB(tenantId: string) {
|
|
||||||
return doWithGlobalDB(tenantId, async (db: any) => {
|
|
||||||
try {
|
|
||||||
const allUsers = await db.allDocs(
|
|
||||||
getGlobalUserParams(null, {
|
|
||||||
include_docs: true,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
await doWithDB(PLATFORM_INFO_DB, async (infoDb: any) => {
|
|
||||||
const allEmails = allUsers.rows.map((row: any) => row.doc.email)
|
|
||||||
// get the id docs
|
|
||||||
let keys = allUsers.rows.map((row: any) => row.id)
|
|
||||||
// and the email docs
|
|
||||||
keys = keys.concat(allEmails)
|
|
||||||
// retrieve the docs and delete them
|
|
||||||
const userDocs = await infoDb.allDocs({
|
|
||||||
keys,
|
|
||||||
include_docs: true,
|
|
||||||
})
|
|
||||||
const toDelete = userDocs.rows.map((row: any) => {
|
|
||||||
return {
|
|
||||||
...row.doc,
|
|
||||||
_deleted: true,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await infoDb.bulkDocs(toDelete)
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error removing tenant ${tenantId} users from info db`, err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeGlobalDB(tenantId: string) {
|
|
||||||
return doWithGlobalDB(tenantId, async (db: Database) => {
|
|
||||||
try {
|
|
||||||
await db.destroy()
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error removing tenant ${tenantId} users from info db`, err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeTenantApps(tenantId: string) {
|
|
||||||
try {
|
|
||||||
const apps = (await getAllApps({ all: true })) as App[]
|
|
||||||
const destroyPromises = apps.map(app =>
|
|
||||||
doWithDB(app.appId, (db: Database) => db.destroy())
|
|
||||||
)
|
|
||||||
await Promise.allSettled(destroyPromises)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error removing tenant ${tenantId} apps`, err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// can't live in tenancy package due to circular dependency on db/utils
|
|
||||||
export async function deleteTenant(tenantId: string) {
|
|
||||||
await removeTenantFromInfoDB(tenantId)
|
|
||||||
await removeUsersFromInfoDB(tenantId)
|
|
||||||
await removeGlobalDB(tenantId)
|
|
||||||
await removeTenantApps(tenantId)
|
|
||||||
}
|
|
|
@ -1,11 +1,14 @@
|
||||||
require("../../../tests")
|
import { testEnv } from "../../../tests"
|
||||||
const context = require("../")
|
const context = require("../")
|
||||||
const { DEFAULT_TENANT_ID } = require("../../constants")
|
const { DEFAULT_TENANT_ID } = require("../../constants")
|
||||||
import env from "../../environment"
|
|
||||||
|
|
||||||
describe("context", () => {
|
describe("context", () => {
|
||||||
describe("doInTenant", () => {
|
describe("doInTenant", () => {
|
||||||
describe("single-tenancy", () => {
|
describe("single-tenancy", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
testEnv.singleTenant()
|
||||||
|
})
|
||||||
|
|
||||||
it("defaults to the default tenant", () => {
|
it("defaults to the default tenant", () => {
|
||||||
const tenantId = context.getTenantId()
|
const tenantId = context.getTenantId()
|
||||||
expect(tenantId).toBe(DEFAULT_TENANT_ID)
|
expect(tenantId).toBe(DEFAULT_TENANT_ID)
|
||||||
|
@ -20,8 +23,8 @@ describe("context", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("multi-tenancy", () => {
|
describe("multi-tenancy", () => {
|
||||||
beforeEach(() => {
|
beforeAll(() => {
|
||||||
env._set("MULTI_TENANCY", 1)
|
testEnv.multiTenant()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("fails when no tenant id is set", () => {
|
it("fails when no tenant id is set", () => {
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
import { directCouchQuery, getPouchDB } from "./couch"
|
import { directCouchQuery, DatabaseImpl } from "./couch"
|
||||||
import { CouchFindOptions, Database } from "@budibase/types"
|
import { CouchFindOptions, Database } from "@budibase/types"
|
||||||
import { DatabaseImpl } from "../db"
|
|
||||||
|
|
||||||
const dbList = new Set()
|
const dbList = new Set()
|
||||||
|
|
||||||
|
|
|
@ -1,190 +0,0 @@
|
||||||
require("../../../tests")
|
|
||||||
const {
|
|
||||||
getDevelopmentAppID,
|
|
||||||
getProdAppID,
|
|
||||||
isDevAppID,
|
|
||||||
isProdAppID,
|
|
||||||
} = require("../conversions")
|
|
||||||
const { generateAppID, getPlatformUrl, getScopedConfig } = require("../utils")
|
|
||||||
const tenancy = require("../../tenancy")
|
|
||||||
const { Config, DEFAULT_TENANT_ID } = require("../../constants")
|
|
||||||
import { generator } from "../../../tests"
|
|
||||||
import env from "../../environment"
|
|
||||||
|
|
||||||
describe("utils", () => {
|
|
||||||
describe("app ID manipulation", () => {
|
|
||||||
function getID() {
|
|
||||||
const appId = generateAppID()
|
|
||||||
const split = appId.split("_")
|
|
||||||
const uuid = split[split.length - 1]
|
|
||||||
const devAppId = `app_dev_${uuid}`
|
|
||||||
return { appId, devAppId, split, uuid }
|
|
||||||
}
|
|
||||||
|
|
||||||
it("should be able to generate a new app ID", () => {
|
|
||||||
expect(generateAppID().startsWith("app_")).toEqual(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should be able to convert a production app ID to development", () => {
|
|
||||||
const { appId, uuid } = getID()
|
|
||||||
expect(getDevelopmentAppID(appId)).toEqual(`app_dev_${uuid}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should be able to convert a development app ID to development", () => {
|
|
||||||
const { devAppId, uuid } = getID()
|
|
||||||
expect(getDevelopmentAppID(devAppId)).toEqual(`app_dev_${uuid}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should be able to convert a development ID to a production", () => {
|
|
||||||
const { devAppId, uuid } = getID()
|
|
||||||
expect(getProdAppID(devAppId)).toEqual(`app_${uuid}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should be able to convert a production ID to production", () => {
|
|
||||||
const { appId, uuid } = getID()
|
|
||||||
expect(getProdAppID(appId)).toEqual(`app_${uuid}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should be able to confirm dev app ID is development", () => {
|
|
||||||
const { devAppId } = getID()
|
|
||||||
expect(isDevAppID(devAppId)).toEqual(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should be able to confirm prod app ID is not development", () => {
|
|
||||||
const { appId } = getID()
|
|
||||||
expect(isDevAppID(appId)).toEqual(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should be able to confirm prod app ID is prod", () => {
|
|
||||||
const { appId } = getID()
|
|
||||||
expect(isProdAppID(appId)).toEqual(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should be able to confirm dev app ID is not prod", () => {
|
|
||||||
const { devAppId } = getID()
|
|
||||||
expect(isProdAppID(devAppId)).toEqual(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const DEFAULT_URL = "http://localhost:10000"
|
|
||||||
const ENV_URL = "http://env.com"
|
|
||||||
|
|
||||||
const setDbPlatformUrl = async (dbUrl: string) => {
|
|
||||||
const db = tenancy.getGlobalDB()
|
|
||||||
await db.put({
|
|
||||||
_id: "config_settings",
|
|
||||||
type: Config.SETTINGS,
|
|
||||||
config: {
|
|
||||||
platformUrl: dbUrl,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearSettingsConfig = async () => {
|
|
||||||
await tenancy.doInTenant(DEFAULT_TENANT_ID, async () => {
|
|
||||||
const db = tenancy.getGlobalDB()
|
|
||||||
try {
|
|
||||||
const config = await db.get("config_settings")
|
|
||||||
await db.remove("config_settings", config._rev)
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e.status !== 404) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("getPlatformUrl", () => {
|
|
||||||
describe("self host", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
env._set("SELF_HOST", 1)
|
|
||||||
await clearSettingsConfig()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("gets the default url", async () => {
|
|
||||||
await tenancy.doInTenant(null, async () => {
|
|
||||||
const url = await getPlatformUrl()
|
|
||||||
expect(url).toBe(DEFAULT_URL)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("gets the platform url from the environment", async () => {
|
|
||||||
await tenancy.doInTenant(null, async () => {
|
|
||||||
env._set("PLATFORM_URL", ENV_URL)
|
|
||||||
const url = await getPlatformUrl()
|
|
||||||
expect(url).toBe(ENV_URL)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("gets the platform url from the database", async () => {
|
|
||||||
await tenancy.doInTenant(null, async () => {
|
|
||||||
const dbUrl = generator.url()
|
|
||||||
await setDbPlatformUrl(dbUrl)
|
|
||||||
const url = await getPlatformUrl()
|
|
||||||
expect(url).toBe(dbUrl)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("cloud", () => {
|
|
||||||
const TENANT_AWARE_URL = "http://default.env.com"
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
env._set("SELF_HOSTED", 0)
|
|
||||||
env._set("MULTI_TENANCY", 1)
|
|
||||||
env._set("PLATFORM_URL", ENV_URL)
|
|
||||||
await clearSettingsConfig()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("gets the platform url from the environment without tenancy", async () => {
|
|
||||||
await tenancy.doInTenant(DEFAULT_TENANT_ID, async () => {
|
|
||||||
const url = await getPlatformUrl({ tenantAware: false })
|
|
||||||
expect(url).toBe(ENV_URL)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("gets the platform url from the environment with tenancy", async () => {
|
|
||||||
await tenancy.doInTenant(DEFAULT_TENANT_ID, async () => {
|
|
||||||
const url = await getPlatformUrl()
|
|
||||||
expect(url).toBe(TENANT_AWARE_URL)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("never gets the platform url from the database", async () => {
|
|
||||||
await tenancy.doInTenant(DEFAULT_TENANT_ID, async () => {
|
|
||||||
await setDbPlatformUrl(generator.url())
|
|
||||||
const url = await getPlatformUrl()
|
|
||||||
expect(url).toBe(TENANT_AWARE_URL)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("getScopedConfig", () => {
|
|
||||||
describe("settings config", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
env._set("SELF_HOSTED", 1)
|
|
||||||
env._set("PLATFORM_URL", "")
|
|
||||||
await clearSettingsConfig()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns the platform url with an existing config", async () => {
|
|
||||||
await tenancy.doInTenant(DEFAULT_TENANT_ID, async () => {
|
|
||||||
const dbUrl = generator.url()
|
|
||||||
await setDbPlatformUrl(dbUrl)
|
|
||||||
const db = tenancy.getGlobalDB()
|
|
||||||
const config = await getScopedConfig(db, { type: Config.SETTINGS })
|
|
||||||
expect(config.platformUrl).toBe(dbUrl)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns the platform url without an existing config", async () => {
|
|
||||||
await tenancy.doInTenant(DEFAULT_TENANT_ID, async () => {
|
|
||||||
const db = tenancy.getGlobalDB()
|
|
||||||
const config = await getScopedConfig(db, { type: Config.SETTINGS })
|
|
||||||
expect(config.platformUrl).toBe(DEFAULT_URL)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -0,0 +1,192 @@
|
||||||
|
import { generator, DBTestConfiguration, testEnv } from "../../../tests"
|
||||||
|
import {
|
||||||
|
getDevelopmentAppID,
|
||||||
|
getProdAppID,
|
||||||
|
isDevAppID,
|
||||||
|
isProdAppID,
|
||||||
|
} from "../conversions"
|
||||||
|
import { generateAppID, getPlatformUrl, getScopedConfig } from "../utils"
|
||||||
|
import * as context from "../../context"
|
||||||
|
import { Config } from "../../constants"
|
||||||
|
import env from "../../environment"
|
||||||
|
|
||||||
|
describe("utils", () => {
|
||||||
|
const config = new DBTestConfiguration()
|
||||||
|
|
||||||
|
describe("app ID manipulation", () => {
|
||||||
|
function getID() {
|
||||||
|
const appId = generateAppID()
|
||||||
|
const split = appId.split("_")
|
||||||
|
const uuid = split[split.length - 1]
|
||||||
|
const devAppId = `app_dev_${uuid}`
|
||||||
|
return { appId, devAppId, split, uuid }
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should be able to generate a new app ID", () => {
|
||||||
|
expect(generateAppID().startsWith("app_")).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to convert a production app ID to development", () => {
|
||||||
|
const { appId, uuid } = getID()
|
||||||
|
expect(getDevelopmentAppID(appId)).toEqual(`app_dev_${uuid}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to convert a development app ID to development", () => {
|
||||||
|
const { devAppId, uuid } = getID()
|
||||||
|
expect(getDevelopmentAppID(devAppId)).toEqual(`app_dev_${uuid}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to convert a development ID to a production", () => {
|
||||||
|
const { devAppId, uuid } = getID()
|
||||||
|
expect(getProdAppID(devAppId)).toEqual(`app_${uuid}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to convert a production ID to production", () => {
|
||||||
|
const { appId, uuid } = getID()
|
||||||
|
expect(getProdAppID(appId)).toEqual(`app_${uuid}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to confirm dev app ID is development", () => {
|
||||||
|
const { devAppId } = getID()
|
||||||
|
expect(isDevAppID(devAppId)).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to confirm prod app ID is not development", () => {
|
||||||
|
const { appId } = getID()
|
||||||
|
expect(isDevAppID(appId)).toEqual(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to confirm prod app ID is prod", () => {
|
||||||
|
const { appId } = getID()
|
||||||
|
expect(isProdAppID(appId)).toEqual(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to confirm dev app ID is not prod", () => {
|
||||||
|
const { devAppId } = getID()
|
||||||
|
expect(isProdAppID(devAppId)).toEqual(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const DEFAULT_URL = "http://localhost:10000"
|
||||||
|
const ENV_URL = "http://env.com"
|
||||||
|
|
||||||
|
const setDbPlatformUrl = async (dbUrl: string) => {
|
||||||
|
const db = context.getGlobalDB()
|
||||||
|
await db.put({
|
||||||
|
_id: "config_settings",
|
||||||
|
type: Config.SETTINGS,
|
||||||
|
config: {
|
||||||
|
platformUrl: dbUrl,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSettingsConfig = async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const db = context.getGlobalDB()
|
||||||
|
try {
|
||||||
|
const config = await db.get("config_settings")
|
||||||
|
await db.remove("config_settings", config._rev)
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.status !== 404) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getPlatformUrl", () => {
|
||||||
|
describe("self host", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
testEnv.selfHosted()
|
||||||
|
await clearSettingsConfig()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("gets the default url", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const url = await getPlatformUrl()
|
||||||
|
expect(url).toBe(DEFAULT_URL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("gets the platform url from the environment", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
env._set("PLATFORM_URL", ENV_URL)
|
||||||
|
const url = await getPlatformUrl()
|
||||||
|
expect(url).toBe(ENV_URL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("gets the platform url from the database", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const dbUrl = generator.url()
|
||||||
|
await setDbPlatformUrl(dbUrl)
|
||||||
|
const url = await getPlatformUrl()
|
||||||
|
expect(url).toBe(dbUrl)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("cloud", () => {
|
||||||
|
const TENANT_AWARE_URL = `http://${config.tenantId}.env.com`
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
testEnv.cloudHosted()
|
||||||
|
testEnv.multiTenant()
|
||||||
|
|
||||||
|
env._set("PLATFORM_URL", ENV_URL)
|
||||||
|
await clearSettingsConfig()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("gets the platform url from the environment without tenancy", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const url = await getPlatformUrl({ tenantAware: false })
|
||||||
|
expect(url).toBe(ENV_URL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("gets the platform url from the environment with tenancy", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const url = await getPlatformUrl()
|
||||||
|
expect(url).toBe(TENANT_AWARE_URL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("never gets the platform url from the database", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
await setDbPlatformUrl(generator.url())
|
||||||
|
const url = await getPlatformUrl()
|
||||||
|
expect(url).toBe(TENANT_AWARE_URL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getScopedConfig", () => {
|
||||||
|
describe("settings config", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
env._set("SELF_HOSTED", 1)
|
||||||
|
env._set("PLATFORM_URL", "")
|
||||||
|
await clearSettingsConfig()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns the platform url with an existing config", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const dbUrl = generator.url()
|
||||||
|
await setDbPlatformUrl(dbUrl)
|
||||||
|
const db = context.getGlobalDB()
|
||||||
|
const config = await getScopedConfig(db, { type: Config.SETTINGS })
|
||||||
|
expect(config.platformUrl).toBe(dbUrl)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns the platform url without an existing config", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const db = context.getGlobalDB()
|
||||||
|
const config = await getScopedConfig(db, { type: Config.SETTINGS })
|
||||||
|
expect(config.platformUrl).toBe(DEFAULT_URL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,13 +1,14 @@
|
||||||
import {
|
import {
|
||||||
DocumentType,
|
|
||||||
ViewName,
|
|
||||||
DeprecatedViews,
|
DeprecatedViews,
|
||||||
|
DocumentType,
|
||||||
SEPARATOR,
|
SEPARATOR,
|
||||||
StaticDatabases,
|
StaticDatabases,
|
||||||
|
ViewName,
|
||||||
} from "../constants"
|
} from "../constants"
|
||||||
import { getGlobalDB } from "../context"
|
import { getGlobalDB } from "../context"
|
||||||
import { doWithDB } from "./"
|
import { doWithDB } from "./"
|
||||||
import { Database, DatabaseQueryOpts } from "@budibase/types"
|
import { Database, DatabaseQueryOpts } from "@budibase/types"
|
||||||
|
import env from "../environment"
|
||||||
|
|
||||||
const DESIGN_DB = "_design/database"
|
const DESIGN_DB = "_design/database"
|
||||||
|
|
||||||
|
@ -69,17 +70,6 @@ export const createNewUserEmailView = async () => {
|
||||||
await createView(db, viewJs, ViewName.USER_BY_EMAIL)
|
await createView(db, viewJs, ViewName.USER_BY_EMAIL)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createAccountEmailView = async () => {
|
|
||||||
const viewJs = `function(doc) {
|
|
||||||
if (doc._id.startsWith("${DocumentType.ACCOUNT_METADATA}${SEPARATOR}")) {
|
|
||||||
emit(doc.email.toLowerCase(), doc._id)
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
await doWithDB(StaticDatabases.PLATFORM_INFO.name, async (db: Database) => {
|
|
||||||
await createView(db, viewJs, ViewName.ACCOUNT_BY_EMAIL)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createUserAppView = async () => {
|
export const createUserAppView = async () => {
|
||||||
const db = getGlobalDB()
|
const db = getGlobalDB()
|
||||||
const viewJs = `function(doc) {
|
const viewJs = `function(doc) {
|
||||||
|
@ -113,17 +103,6 @@ export const createUserBuildersView = async () => {
|
||||||
await createView(db, viewJs, ViewName.USER_BY_BUILDERS)
|
await createView(db, viewJs, ViewName.USER_BY_BUILDERS)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createPlatformUserView = async () => {
|
|
||||||
const viewJs = `function(doc) {
|
|
||||||
if (doc.tenantId) {
|
|
||||||
emit(doc._id.toLowerCase(), doc._id)
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
await doWithDB(StaticDatabases.PLATFORM_INFO.name, async (db: Database) => {
|
|
||||||
await createView(db, viewJs, ViewName.PLATFORM_USERS_LOWERCASE)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueryViewOptions {
|
export interface QueryViewOptions {
|
||||||
arrayResponse?: boolean
|
arrayResponse?: boolean
|
||||||
}
|
}
|
||||||
|
@ -162,13 +141,48 @@ export const queryView = async <T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PLATFORM
|
||||||
|
|
||||||
|
async function createPlatformView(viewJs: string, viewName: ViewName) {
|
||||||
|
try {
|
||||||
|
await doWithDB(StaticDatabases.PLATFORM_INFO.name, async (db: Database) => {
|
||||||
|
await createView(db, viewJs, viewName)
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.status === 409 && env.isTest()) {
|
||||||
|
// multiple tests can try to initialise platforms views
|
||||||
|
// at once - safe to exit on conflict
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPlatformAccountEmailView = async () => {
|
||||||
|
const viewJs = `function(doc) {
|
||||||
|
if (doc._id.startsWith("${DocumentType.ACCOUNT_METADATA}${SEPARATOR}")) {
|
||||||
|
emit(doc.email.toLowerCase(), doc._id)
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
await createPlatformView(viewJs, ViewName.ACCOUNT_BY_EMAIL)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPlatformUserView = async () => {
|
||||||
|
const viewJs = `function(doc) {
|
||||||
|
if (doc.tenantId) {
|
||||||
|
emit(doc._id.toLowerCase(), doc._id)
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
await createPlatformView(viewJs, ViewName.PLATFORM_USERS_LOWERCASE)
|
||||||
|
}
|
||||||
|
|
||||||
export const queryPlatformView = async <T>(
|
export const queryPlatformView = async <T>(
|
||||||
viewName: ViewName,
|
viewName: ViewName,
|
||||||
params: DatabaseQueryOpts,
|
params: DatabaseQueryOpts,
|
||||||
opts?: QueryViewOptions
|
opts?: QueryViewOptions
|
||||||
): Promise<T[] | T | undefined> => {
|
): Promise<T[] | T | undefined> => {
|
||||||
const CreateFuncByName: any = {
|
const CreateFuncByName: any = {
|
||||||
[ViewName.ACCOUNT_BY_EMAIL]: createAccountEmailView,
|
[ViewName.ACCOUNT_BY_EMAIL]: createPlatformAccountEmailView,
|
||||||
[ViewName.PLATFORM_USERS_LOWERCASE]: createPlatformUserView,
|
[ViewName.PLATFORM_USERS_LOWERCASE]: createPlatformUserView,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -44,8 +44,9 @@ const environment = {
|
||||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||||
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
SALT_ROUNDS: process.env.SALT_ROUNDS,
|
SALT_ROUNDS: process.env.SALT_ROUNDS,
|
||||||
REDIS_URL: process.env.REDIS_URL,
|
REDIS_URL: process.env.REDIS_URL || "localhost:6379",
|
||||||
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
REDIS_PASSWORD: process.env.REDIS_PASSWORD || "budibase",
|
||||||
|
MOCK_REDIS: process.env.MOCK_REDIS,
|
||||||
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
|
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
|
||||||
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
|
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
|
||||||
AWS_REGION: process.env.AWS_REGION,
|
AWS_REGION: process.env.AWS_REGION,
|
||||||
|
@ -82,6 +83,7 @@ const environment = {
|
||||||
SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD,
|
SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD,
|
||||||
DEPLOYMENT_ENVIRONMENT:
|
DEPLOYMENT_ENVIRONMENT:
|
||||||
process.env.DEPLOYMENT_ENVIRONMENT || "docker-compose",
|
process.env.DEPLOYMENT_ENVIRONMENT || "docker-compose",
|
||||||
|
ENABLE_4XX_HTTP_LOGGING: process.env.ENABLE_4XX_HTTP_LOGGING || true,
|
||||||
_set(key: any, value: any) {
|
_set(key: any, value: any) {
|
||||||
process.env[key] = value
|
process.env[key] = value
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
import * as tenancy from "../tenancy"
|
import * as context from "../context"
|
||||||
import * as dbUtils from "../db/utils"
|
import * as dbUtils from "../db/utils"
|
||||||
import { Config } from "../constants"
|
import { Config } from "../constants"
|
||||||
import { withCache, TTL, CacheKey } from "../cache"
|
import { withCache, TTL, CacheKey } from "../cache"
|
||||||
|
@ -42,7 +42,7 @@ export const enabled = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSettingsDoc = async () => {
|
const getSettingsDoc = async () => {
|
||||||
const db = tenancy.getGlobalDB()
|
const db = context.getGlobalDB()
|
||||||
let settings
|
let settings
|
||||||
try {
|
try {
|
||||||
settings = await db.get(dbUtils.generateConfigID({ type: Config.SETTINGS }))
|
settings = await db.get(dbUtils.generateConfigID({ type: Config.SETTINGS }))
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {
|
||||||
InstallationGroup,
|
InstallationGroup,
|
||||||
UserContext,
|
UserContext,
|
||||||
Group,
|
Group,
|
||||||
|
isSSOUser,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { processors } from "./processors"
|
import { processors } from "./processors"
|
||||||
import * as dbUtils from "../db/utils"
|
import * as dbUtils from "../db/utils"
|
||||||
|
@ -166,7 +167,10 @@ const identifyUser = async (
|
||||||
const type = IdentityType.USER
|
const type = IdentityType.USER
|
||||||
let builder = user.builder?.global || false
|
let builder = user.builder?.global || false
|
||||||
let admin = user.admin?.global || false
|
let admin = user.admin?.global || false
|
||||||
let providerType = user.providerType
|
let providerType
|
||||||
|
if (isSSOUser(user)) {
|
||||||
|
providerType = user.providerType
|
||||||
|
}
|
||||||
const accountHolder = account?.budibaseUserId === user._id || false
|
const accountHolder = account?.budibaseUserId === user._id || false
|
||||||
const verified =
|
const verified =
|
||||||
account && account?.budibaseUserId === user._id ? account.verified : false
|
account && account?.budibaseUserId === user._id ? account.verified : false
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import "../../../../../tests"
|
import { testEnv } from "../../../../../tests"
|
||||||
import PosthogProcessor from "../PosthogProcessor"
|
import PosthogProcessor from "../PosthogProcessor"
|
||||||
import { Event, IdentityType, Hosting } from "@budibase/types"
|
import { Event, IdentityType, Hosting } from "@budibase/types"
|
||||||
const tk = require("timekeeper")
|
const tk = require("timekeeper")
|
||||||
|
@ -16,6 +16,10 @@ const newIdentity = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("PosthogProcessor", () => {
|
describe("PosthogProcessor", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
testEnv.singleTenant()
|
||||||
|
})
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
await cache.bustCache(
|
await cache.bustCache(
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
import * as tenancy from "../tenancy"
|
import * as context from "../context"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the TENANT_FEATURE_FLAGS env var and return an array of features flags for each tenant.
|
* Read the TENANT_FEATURE_FLAGS env var and return an array of features flags for each tenant.
|
||||||
|
@ -28,7 +28,7 @@ export function buildFeatureFlags() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isEnabled(featureFlag: string) {
|
export function isEnabled(featureFlag: string) {
|
||||||
const tenantId = tenancy.getTenantId()
|
const tenantId = context.getTenantId()
|
||||||
const flags = getTenantFeatureFlags(tenantId)
|
const flags = getTenantFeatureFlags(tenantId)
|
||||||
return flags.includes(featureFlag)
|
return flags.includes(featureFlag)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,12 +3,11 @@ export * as migrations from "./migrations"
|
||||||
export * as users from "./users"
|
export * as users from "./users"
|
||||||
export * as roles from "./security/roles"
|
export * as roles from "./security/roles"
|
||||||
export * as permissions from "./security/permissions"
|
export * as permissions from "./security/permissions"
|
||||||
export * as accounts from "./cloud/accounts"
|
export * as accounts from "./accounts"
|
||||||
export * as installation from "./installation"
|
export * as installation from "./installation"
|
||||||
export * as tenancy from "./tenancy"
|
|
||||||
export * as featureFlags from "./featureFlags"
|
export * as featureFlags from "./featureFlags"
|
||||||
export * as sessions from "./security/sessions"
|
export * as sessions from "./security/sessions"
|
||||||
export * as deprovisioning from "./context/deprovision"
|
export * as platform from "./platform"
|
||||||
export * as auth from "./auth"
|
export * as auth from "./auth"
|
||||||
export * as constants from "./constants"
|
export * as constants from "./constants"
|
||||||
export * as logging from "./logging"
|
export * as logging from "./logging"
|
||||||
|
@ -21,20 +20,27 @@ export * as context from "./context"
|
||||||
export * as cache from "./cache"
|
export * as cache from "./cache"
|
||||||
export * as objectStore from "./objectStore"
|
export * as objectStore from "./objectStore"
|
||||||
export * as redis from "./redis"
|
export * as redis from "./redis"
|
||||||
|
export * as locks from "./redis/redlock"
|
||||||
export * as utils from "./utils"
|
export * as utils from "./utils"
|
||||||
export * as errors from "./errors"
|
export * as errors from "./errors"
|
||||||
export { default as env } from "./environment"
|
export { default as env } from "./environment"
|
||||||
|
|
||||||
|
// Add context to tenancy for backwards compatibility
|
||||||
|
// only do this for external usages to prevent internal
|
||||||
|
// circular dependencies
|
||||||
|
import * as context from "./context"
|
||||||
|
import * as _tenancy from "./tenancy"
|
||||||
|
export const tenancy = {
|
||||||
|
..._tenancy,
|
||||||
|
...context,
|
||||||
|
}
|
||||||
|
|
||||||
// expose error classes directly
|
// expose error classes directly
|
||||||
export * from "./errors"
|
export * from "./errors"
|
||||||
|
|
||||||
// expose constants directly
|
// expose constants directly
|
||||||
export * from "./constants"
|
export * from "./constants"
|
||||||
|
|
||||||
// expose inner locks from redis directly
|
|
||||||
import * as redis from "./redis"
|
|
||||||
export const locks = redis.redlock
|
|
||||||
|
|
||||||
// expose package init function
|
// expose package init function
|
||||||
import * as db from "./db"
|
import * as db from "./db"
|
||||||
export const init = (opts: any = {}) => {
|
export const init = (opts: any = {}) => {
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { getUser } from "../cache/user"
|
||||||
import { getSession, updateSessionTTL } from "../security/sessions"
|
import { getSession, updateSessionTTL } from "../security/sessions"
|
||||||
import { buildMatcherRegex, matches } from "./matchers"
|
import { buildMatcherRegex, matches } from "./matchers"
|
||||||
import { SEPARATOR, queryGlobalView, ViewName } from "../db"
|
import { SEPARATOR, queryGlobalView, ViewName } from "../db"
|
||||||
import { getGlobalDB, doInTenant } from "../tenancy"
|
import { getGlobalDB, doInTenant } from "../context"
|
||||||
import { decrypt } from "../security/encryption"
|
import { decrypt } from "../security/encryption"
|
||||||
import * as identity from "../context/identity"
|
import * as identity from "../context/identity"
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { APIError } from "@budibase/types"
|
||||||
|
import * as errors from "../errors"
|
||||||
|
import env from "../environment"
|
||||||
|
|
||||||
|
export async function errorHandling(ctx: any, next: any) {
|
||||||
|
try {
|
||||||
|
await next()
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err.status || err.statusCode || 500
|
||||||
|
ctx.status = status
|
||||||
|
|
||||||
|
if (status > 499 || env.ENABLE_4XX_HTTP_LOGGING) {
|
||||||
|
ctx.log.error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = errors.getPublicError(err)
|
||||||
|
const body: APIError = {
|
||||||
|
message: err.message,
|
||||||
|
status: status,
|
||||||
|
validationErrors: err.validation,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default errorHandling
|
|
@ -1,7 +1,7 @@
|
||||||
export * as jwt from "./passport/jwt"
|
export * as jwt from "./passport/jwt"
|
||||||
export * as local from "./passport/local"
|
export * as local from "./passport/local"
|
||||||
export * as google from "./passport/google"
|
export * as google from "./passport/sso/google"
|
||||||
export * as oidc from "./passport/oidc"
|
export * as oidc from "./passport/sso/oidc"
|
||||||
import * as datasourceGoogle from "./passport/datasource/google"
|
import * as datasourceGoogle from "./passport/datasource/google"
|
||||||
export const datasource = {
|
export const datasource = {
|
||||||
google: datasourceGoogle,
|
google: datasourceGoogle,
|
||||||
|
@ -16,4 +16,5 @@ export { default as adminOnly } from "./adminOnly"
|
||||||
export { default as builderOrAdmin } from "./builderOrAdmin"
|
export { default as builderOrAdmin } from "./builderOrAdmin"
|
||||||
export { default as builderOnly } from "./builderOnly"
|
export { default as builderOnly } from "./builderOnly"
|
||||||
export { default as logging } from "./logging"
|
export { default as logging } from "./logging"
|
||||||
|
export { default as errorHandling } from "./errorHandling"
|
||||||
export * as joiValidator from "./joi-validator"
|
export * as joiValidator from "./joi-validator"
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import * as google from "../google"
|
import * as google from "../sso/google"
|
||||||
import { Cookie, Config } from "../../../constants"
|
import { Cookie, Config } from "../../../constants"
|
||||||
import { clearCookie, getCookie } from "../../../utils"
|
import { clearCookie, getCookie } from "../../../utils"
|
||||||
import { getScopedConfig, getPlatformUrl, doWithDB } from "../../../db"
|
import { getScopedConfig, getPlatformUrl, doWithDB } from "../../../db"
|
||||||
import environment from "../../../environment"
|
import environment from "../../../environment"
|
||||||
import { getGlobalDB } from "../../../tenancy"
|
import { getGlobalDB } from "../../../context"
|
||||||
import { BBContext, Database, SSOProfile } from "@budibase/types"
|
import { BBContext, Database, SSOProfile } from "@budibase/types"
|
||||||
|
import { ssoSaveUserNoOp } from "../sso/sso"
|
||||||
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
|
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
|
||||||
|
|
||||||
type Passport = {
|
type Passport = {
|
||||||
|
@ -36,7 +37,11 @@ export async function preAuth(
|
||||||
const platformUrl = await getPlatformUrl({ tenantAware: false })
|
const platformUrl = await getPlatformUrl({ tenantAware: false })
|
||||||
|
|
||||||
let callbackUrl = `${platformUrl}/api/global/auth/datasource/google/callback`
|
let callbackUrl = `${platformUrl}/api/global/auth/datasource/google/callback`
|
||||||
const strategy = await google.strategyFactory(googleConfig, callbackUrl)
|
const strategy = await google.strategyFactory(
|
||||||
|
googleConfig,
|
||||||
|
callbackUrl,
|
||||||
|
ssoSaveUserNoOp
|
||||||
|
)
|
||||||
|
|
||||||
if (!ctx.query.appId || !ctx.query.datasourceId) {
|
if (!ctx.query.appId || !ctx.query.datasourceId) {
|
||||||
ctx.throw(400, "appId and datasourceId query params not present.")
|
ctx.throw(400, "appId and datasourceId query params not present.")
|
||||||
|
|
|
@ -1,15 +1,10 @@
|
||||||
import { UserStatus } from "../../constants"
|
import { UserStatus } from "../../constants"
|
||||||
import { compare, newid } from "../../utils"
|
import { compare } from "../../utils"
|
||||||
import env from "../../environment"
|
|
||||||
import * as users from "../../users"
|
import * as users from "../../users"
|
||||||
import { authError } from "./utils"
|
import { authError } from "./utils"
|
||||||
import { createASession } from "../../security/sessions"
|
|
||||||
import { getTenantId } from "../../tenancy"
|
|
||||||
import { BBContext } from "@budibase/types"
|
import { BBContext } from "@budibase/types"
|
||||||
const jwt = require("jsonwebtoken")
|
|
||||||
|
|
||||||
const INVALID_ERR = "Invalid credentials"
|
const INVALID_ERR = "Invalid credentials"
|
||||||
const SSO_NO_PASSWORD = "SSO user does not have a password set"
|
|
||||||
const EXPIRED = "This account has expired. Please reset your password"
|
const EXPIRED = "This account has expired. Please reset your password"
|
||||||
|
|
||||||
export const options = {
|
export const options = {
|
||||||
|
@ -35,50 +30,25 @@ export async function authenticate(
|
||||||
|
|
||||||
const dbUser = await users.getGlobalUserByEmail(email)
|
const dbUser = await users.getGlobalUserByEmail(email)
|
||||||
if (dbUser == null) {
|
if (dbUser == null) {
|
||||||
return authError(done, `User not found: [${email}]`)
|
console.info(`user=${email} could not be found`)
|
||||||
}
|
|
||||||
|
|
||||||
// check that the user is currently inactive, if this is the case throw invalid
|
|
||||||
if (dbUser.status === UserStatus.INACTIVE) {
|
|
||||||
return authError(done, INVALID_ERR)
|
return authError(done, INVALID_ERR)
|
||||||
}
|
}
|
||||||
|
|
||||||
// check that the user has a stored password before proceeding
|
if (dbUser.status === UserStatus.INACTIVE) {
|
||||||
if (!dbUser.password) {
|
console.info(`user=${email} is inactive`, dbUser)
|
||||||
if (
|
return authError(done, INVALID_ERR)
|
||||||
(dbUser.account && dbUser.account.authType === "sso") || // root account sso
|
|
||||||
dbUser.thirdPartyProfile // internal sso
|
|
||||||
) {
|
|
||||||
return authError(done, SSO_NO_PASSWORD)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("Non SSO usser has no password set", dbUser)
|
if (!dbUser.password) {
|
||||||
|
console.info(`user=${email} has no password set`, dbUser)
|
||||||
return authError(done, EXPIRED)
|
return authError(done, EXPIRED)
|
||||||
}
|
}
|
||||||
|
|
||||||
// authenticate
|
if (!(await compare(password, dbUser.password))) {
|
||||||
if (await compare(password, dbUser.password)) {
|
|
||||||
const sessionId = newid()
|
|
||||||
const tenantId = getTenantId()
|
|
||||||
|
|
||||||
await createASession(dbUser._id!, { sessionId, tenantId })
|
|
||||||
|
|
||||||
const token = jwt.sign(
|
|
||||||
{
|
|
||||||
userId: dbUser._id,
|
|
||||||
sessionId,
|
|
||||||
tenantId,
|
|
||||||
},
|
|
||||||
env.JWT_SECRET
|
|
||||||
)
|
|
||||||
// Remove users password in payload
|
|
||||||
delete dbUser.password
|
|
||||||
|
|
||||||
return done(null, {
|
|
||||||
...dbUser,
|
|
||||||
token,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
return authError(done, INVALID_ERR)
|
return authError(done, INVALID_ERR)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// intentionally remove the users password in payload
|
||||||
|
delete dbUser.password
|
||||||
|
return done(null, dbUser)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,26 @@
|
||||||
import { ssoCallbackUrl } from "./utils"
|
import { ssoCallbackUrl } from "../utils"
|
||||||
import { authenticateThirdParty, SaveUserFunction } from "./third-party-common"
|
import * as sso from "./sso"
|
||||||
import { ConfigType, GoogleConfig, Database, SSOProfile } from "@budibase/types"
|
import {
|
||||||
|
ConfigType,
|
||||||
|
GoogleConfig,
|
||||||
|
Database,
|
||||||
|
SSOProfile,
|
||||||
|
SSOAuthDetails,
|
||||||
|
SSOProviderType,
|
||||||
|
SaveSSOUserFunction,
|
||||||
|
} from "@budibase/types"
|
||||||
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
|
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
|
||||||
|
|
||||||
export function buildVerifyFn(saveUserFn?: SaveUserFunction) {
|
export function buildVerifyFn(saveUserFn: SaveSSOUserFunction) {
|
||||||
return (
|
return (
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
profile: SSOProfile,
|
profile: SSOProfile,
|
||||||
done: Function
|
done: Function
|
||||||
) => {
|
) => {
|
||||||
const thirdPartyUser = {
|
const details: SSOAuthDetails = {
|
||||||
provider: profile.provider, // should always be 'google'
|
provider: "google",
|
||||||
providerType: "google",
|
providerType: SSOProviderType.GOOGLE,
|
||||||
userId: profile.id,
|
userId: profile.id,
|
||||||
profile: profile,
|
profile: profile,
|
||||||
email: profile._json.email,
|
email: profile._json.email,
|
||||||
|
@ -22,8 +30,8 @@ export function buildVerifyFn(saveUserFn?: SaveUserFunction) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return authenticateThirdParty(
|
return sso.authenticate(
|
||||||
thirdPartyUser,
|
details,
|
||||||
true, // require local accounts to exist
|
true, // require local accounts to exist
|
||||||
done,
|
done,
|
||||||
saveUserFn
|
saveUserFn
|
||||||
|
@ -39,7 +47,7 @@ export function buildVerifyFn(saveUserFn?: SaveUserFunction) {
|
||||||
export async function strategyFactory(
|
export async function strategyFactory(
|
||||||
config: GoogleConfig["config"],
|
config: GoogleConfig["config"],
|
||||||
callbackUrl: string,
|
callbackUrl: string,
|
||||||
saveUserFn?: SaveUserFunction
|
saveUserFn: SaveSSOUserFunction
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { clientID, clientSecret } = config
|
const { clientID, clientSecret } = config
|
|
@ -1,22 +1,20 @@
|
||||||
import fetch from "node-fetch"
|
import fetch from "node-fetch"
|
||||||
import { authenticateThirdParty, SaveUserFunction } from "./third-party-common"
|
import * as sso from "./sso"
|
||||||
import { ssoCallbackUrl } from "./utils"
|
import { ssoCallbackUrl } from "../utils"
|
||||||
import {
|
import {
|
||||||
ConfigType,
|
ConfigType,
|
||||||
OIDCInnerCfg,
|
OIDCInnerConfig,
|
||||||
Database,
|
Database,
|
||||||
SSOProfile,
|
SSOProfile,
|
||||||
ThirdPartyUser,
|
OIDCStrategyConfiguration,
|
||||||
OIDCConfiguration,
|
SSOAuthDetails,
|
||||||
|
SSOProviderType,
|
||||||
|
JwtClaims,
|
||||||
|
SaveSSOUserFunction,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy
|
const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy
|
||||||
|
|
||||||
type JwtClaims = {
|
export function buildVerifyFn(saveUserFn: SaveSSOUserFunction) {
|
||||||
preferred_username: string
|
|
||||||
email: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildVerifyFn(saveUserFn?: SaveUserFunction) {
|
|
||||||
/**
|
/**
|
||||||
* @param {*} issuer The identity provider base URL
|
* @param {*} issuer The identity provider base URL
|
||||||
* @param {*} sub The user ID
|
* @param {*} sub The user ID
|
||||||
|
@ -39,10 +37,10 @@ export function buildVerifyFn(saveUserFn?: SaveUserFunction) {
|
||||||
params: any,
|
params: any,
|
||||||
done: Function
|
done: Function
|
||||||
) => {
|
) => {
|
||||||
const thirdPartyUser: ThirdPartyUser = {
|
const details: SSOAuthDetails = {
|
||||||
// store the issuer info to enable sync in future
|
// store the issuer info to enable sync in future
|
||||||
provider: issuer,
|
provider: issuer,
|
||||||
providerType: "oidc",
|
providerType: SSOProviderType.OIDC,
|
||||||
userId: profile.id,
|
userId: profile.id,
|
||||||
profile: profile,
|
profile: profile,
|
||||||
email: getEmail(profile, jwtClaims),
|
email: getEmail(profile, jwtClaims),
|
||||||
|
@ -52,8 +50,8 @@ export function buildVerifyFn(saveUserFn?: SaveUserFunction) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return authenticateThirdParty(
|
return sso.authenticate(
|
||||||
thirdPartyUser,
|
details,
|
||||||
false, // don't require local accounts to exist
|
false, // don't require local accounts to exist
|
||||||
done,
|
done,
|
||||||
saveUserFn
|
saveUserFn
|
||||||
|
@ -104,8 +102,8 @@ function validEmail(value: string) {
|
||||||
* @returns Dynamically configured Passport OIDC Strategy
|
* @returns Dynamically configured Passport OIDC Strategy
|
||||||
*/
|
*/
|
||||||
export async function strategyFactory(
|
export async function strategyFactory(
|
||||||
config: OIDCConfiguration,
|
config: OIDCStrategyConfiguration,
|
||||||
saveUserFn?: SaveUserFunction
|
saveUserFn: SaveSSOUserFunction
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const verify = buildVerifyFn(saveUserFn)
|
const verify = buildVerifyFn(saveUserFn)
|
||||||
|
@ -119,11 +117,11 @@ export async function strategyFactory(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchStrategyConfig(
|
export async function fetchStrategyConfig(
|
||||||
enrichedConfig: OIDCInnerCfg,
|
oidcConfig: OIDCInnerConfig,
|
||||||
callbackUrl?: string
|
callbackUrl?: string
|
||||||
): Promise<OIDCConfiguration> {
|
): Promise<OIDCStrategyConfiguration> {
|
||||||
try {
|
try {
|
||||||
const { clientID, clientSecret, configUrl } = enrichedConfig
|
const { clientID, clientSecret, configUrl } = oidcConfig
|
||||||
|
|
||||||
if (!clientID || !clientSecret || !callbackUrl || !configUrl) {
|
if (!clientID || !clientSecret || !callbackUrl || !configUrl) {
|
||||||
// check for remote config and all required elements
|
// check for remote config and all required elements
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { generateGlobalUserID } from "../../../db"
|
||||||
|
import { authError } from "../utils"
|
||||||
|
import * as users from "../../../users"
|
||||||
|
import * as context from "../../../context"
|
||||||
|
import fetch from "node-fetch"
|
||||||
|
import {
|
||||||
|
SaveSSOUserFunction,
|
||||||
|
SaveUserOpts,
|
||||||
|
SSOAuthDetails,
|
||||||
|
SSOUser,
|
||||||
|
User,
|
||||||
|
} from "@budibase/types"
|
||||||
|
|
||||||
|
// no-op function for user save
|
||||||
|
// - this allows datasource auth and access token refresh to work correctly
|
||||||
|
// - prefer no-op over an optional argument to ensure function is provided to login flows
|
||||||
|
export const ssoSaveUserNoOp: SaveSSOUserFunction = (
|
||||||
|
user: SSOUser,
|
||||||
|
opts: SaveUserOpts
|
||||||
|
) => Promise.resolve(user)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common authentication logic for third parties. e.g. OAuth, OIDC.
|
||||||
|
*/
|
||||||
|
export async function authenticate(
|
||||||
|
details: SSOAuthDetails,
|
||||||
|
requireLocalAccount: boolean = true,
|
||||||
|
done: any,
|
||||||
|
saveUserFn: SaveSSOUserFunction
|
||||||
|
) {
|
||||||
|
if (!saveUserFn) {
|
||||||
|
throw new Error("Save user function must be provided")
|
||||||
|
}
|
||||||
|
if (!details.userId) {
|
||||||
|
return authError(done, "sso user id required")
|
||||||
|
}
|
||||||
|
if (!details.email) {
|
||||||
|
return authError(done, "sso user email required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// use the third party id
|
||||||
|
const userId = generateGlobalUserID(details.userId)
|
||||||
|
|
||||||
|
let dbUser: User | undefined
|
||||||
|
|
||||||
|
// try to load by id
|
||||||
|
try {
|
||||||
|
dbUser = await users.getById(userId)
|
||||||
|
} catch (err: any) {
|
||||||
|
// abort when not 404 error
|
||||||
|
if (!err.status || err.status !== 404) {
|
||||||
|
return authError(
|
||||||
|
done,
|
||||||
|
"Unexpected error when retrieving existing user",
|
||||||
|
err
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback to loading by email
|
||||||
|
if (!dbUser) {
|
||||||
|
dbUser = await users.getGlobalUserByEmail(details.email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exit early if there is still no user and auto creation is disabled
|
||||||
|
if (!dbUser && requireLocalAccount) {
|
||||||
|
return authError(
|
||||||
|
done,
|
||||||
|
"Email does not yet exist. You must set up your local budibase account first."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// first time creation
|
||||||
|
if (!dbUser) {
|
||||||
|
// setup a blank user using the third party id
|
||||||
|
dbUser = {
|
||||||
|
_id: userId,
|
||||||
|
email: details.email,
|
||||||
|
roles: {},
|
||||||
|
tenantId: context.getTenantId(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ssoUser = await syncUser(dbUser, details)
|
||||||
|
// never prompt for password reset
|
||||||
|
ssoUser.forceResetPassword = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
// don't try to re-save any existing password
|
||||||
|
delete ssoUser.password
|
||||||
|
// create or sync the user
|
||||||
|
ssoUser = (await saveUserFn(ssoUser, {
|
||||||
|
hashPassword: false,
|
||||||
|
requirePassword: false,
|
||||||
|
})) as SSOUser
|
||||||
|
} catch (err: any) {
|
||||||
|
return authError(done, "Error saving user", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return done(null, ssoUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProfilePictureUrl(user: User, details: SSOAuthDetails) {
|
||||||
|
const pictureUrl = details.profile?._json.picture
|
||||||
|
if (pictureUrl) {
|
||||||
|
const response = await fetch(pictureUrl)
|
||||||
|
if (response.status === 200) {
|
||||||
|
const type = response.headers.get("content-type") as string
|
||||||
|
if (type.startsWith("image/")) {
|
||||||
|
return pictureUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns a user that has been sync'd with third party information
|
||||||
|
*/
|
||||||
|
async function syncUser(user: User, details: SSOAuthDetails): Promise<SSOUser> {
|
||||||
|
let firstName
|
||||||
|
let lastName
|
||||||
|
let pictureUrl
|
||||||
|
let oauth2
|
||||||
|
let thirdPartyProfile
|
||||||
|
|
||||||
|
if (details.profile) {
|
||||||
|
const profile = details.profile
|
||||||
|
|
||||||
|
if (profile.name) {
|
||||||
|
const name = profile.name
|
||||||
|
// first name
|
||||||
|
if (name.givenName) {
|
||||||
|
firstName = name.givenName
|
||||||
|
}
|
||||||
|
// last name
|
||||||
|
if (name.familyName) {
|
||||||
|
lastName = name.familyName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pictureUrl = await getProfilePictureUrl(user, details)
|
||||||
|
|
||||||
|
thirdPartyProfile = {
|
||||||
|
...profile._json,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// oauth tokens for future use
|
||||||
|
if (details.oauth2) {
|
||||||
|
oauth2 = {
|
||||||
|
...details.oauth2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
provider: details.provider,
|
||||||
|
providerType: details.providerType,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
thirdPartyProfile,
|
||||||
|
pictureUrl,
|
||||||
|
oauth2,
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { generator, structures } from "../../../../../tests"
|
||||||
|
import { SSOProviderType } from "@budibase/types"
|
||||||
|
|
||||||
|
jest.mock("passport-google-oauth")
|
||||||
|
const mockStrategy = require("passport-google-oauth").OAuth2Strategy
|
||||||
|
|
||||||
|
jest.mock("../sso")
|
||||||
|
import * as _sso from "../sso"
|
||||||
|
const sso = jest.mocked(_sso)
|
||||||
|
|
||||||
|
const mockSaveUserFn = jest.fn()
|
||||||
|
const mockDone = jest.fn()
|
||||||
|
|
||||||
|
import * as google from "../google"
|
||||||
|
|
||||||
|
describe("google", () => {
|
||||||
|
describe("strategyFactory", () => {
|
||||||
|
const googleConfig = structures.sso.googleConfig()
|
||||||
|
const callbackUrl = generator.url()
|
||||||
|
|
||||||
|
it("should create successfully create a google strategy", async () => {
|
||||||
|
await google.strategyFactory(googleConfig, callbackUrl, mockSaveUserFn)
|
||||||
|
|
||||||
|
const expectedOptions = {
|
||||||
|
clientID: googleConfig.clientID,
|
||||||
|
clientSecret: googleConfig.clientSecret,
|
||||||
|
callbackURL: callbackUrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockStrategy).toHaveBeenCalledWith(
|
||||||
|
expectedOptions,
|
||||||
|
expect.anything()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("authenticate", () => {
|
||||||
|
const details = structures.sso.authDetails()
|
||||||
|
details.provider = "google"
|
||||||
|
details.providerType = SSOProviderType.GOOGLE
|
||||||
|
|
||||||
|
const profile = details.profile!
|
||||||
|
profile.provider = "google"
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("delegates authentication to third party common", async () => {
|
||||||
|
const authenticate = await google.buildVerifyFn(mockSaveUserFn)
|
||||||
|
|
||||||
|
await authenticate(
|
||||||
|
details.oauth2.accessToken,
|
||||||
|
details.oauth2.refreshToken!,
|
||||||
|
profile,
|
||||||
|
mockDone
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(sso.authenticate).toHaveBeenCalledWith(
|
||||||
|
details,
|
||||||
|
true,
|
||||||
|
mockDone,
|
||||||
|
mockSaveUserFn
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,152 @@
|
||||||
|
import { generator, mocks, structures } from "../../../../../tests"
|
||||||
|
import {
|
||||||
|
JwtClaims,
|
||||||
|
OIDCInnerConfig,
|
||||||
|
SSOAuthDetails,
|
||||||
|
SSOProviderType,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import * as _sso from "../sso"
|
||||||
|
import * as oidc from "../oidc"
|
||||||
|
|
||||||
|
jest.mock("@techpass/passport-openidconnect")
|
||||||
|
const mockStrategy = require("@techpass/passport-openidconnect").Strategy
|
||||||
|
|
||||||
|
jest.mock("../sso")
|
||||||
|
const sso = jest.mocked(_sso)
|
||||||
|
|
||||||
|
const mockSaveUser = jest.fn()
|
||||||
|
const mockDone = jest.fn()
|
||||||
|
|
||||||
|
describe("oidc", () => {
|
||||||
|
const callbackUrl = generator.url()
|
||||||
|
const oidcConfig: OIDCInnerConfig = structures.sso.oidcConfig()
|
||||||
|
const wellKnownConfig = structures.sso.oidcWellKnownConfig()
|
||||||
|
|
||||||
|
function mockRetrieveWellKnownConfig() {
|
||||||
|
// mock the request to retrieve the oidc configuration
|
||||||
|
mocks.fetch.mockReturnValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => wellKnownConfig,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRetrieveWellKnownConfig()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("strategyFactory", () => {
|
||||||
|
it("should create successfully create an oidc strategy", async () => {
|
||||||
|
const strategyConfiguration = await oidc.fetchStrategyConfig(
|
||||||
|
oidcConfig,
|
||||||
|
callbackUrl
|
||||||
|
)
|
||||||
|
await oidc.strategyFactory(strategyConfiguration, mockSaveUser)
|
||||||
|
|
||||||
|
expect(mocks.fetch).toHaveBeenCalledWith(oidcConfig.configUrl)
|
||||||
|
|
||||||
|
const expectedOptions = {
|
||||||
|
issuer: wellKnownConfig.issuer,
|
||||||
|
authorizationURL: wellKnownConfig.authorization_endpoint,
|
||||||
|
tokenURL: wellKnownConfig.token_endpoint,
|
||||||
|
userInfoURL: wellKnownConfig.userinfo_endpoint,
|
||||||
|
clientID: oidcConfig.clientID,
|
||||||
|
clientSecret: oidcConfig.clientSecret,
|
||||||
|
callbackURL: callbackUrl,
|
||||||
|
}
|
||||||
|
expect(mockStrategy).toHaveBeenCalledWith(
|
||||||
|
expectedOptions,
|
||||||
|
expect.anything()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("authenticate", () => {
|
||||||
|
const details: SSOAuthDetails = structures.sso.authDetails()
|
||||||
|
details.providerType = SSOProviderType.OIDC
|
||||||
|
const profile = details.profile!
|
||||||
|
const issuer = profile.provider
|
||||||
|
|
||||||
|
const sub = generator.string()
|
||||||
|
const idToken = generator.string()
|
||||||
|
const params = {}
|
||||||
|
|
||||||
|
let authenticateFn: any
|
||||||
|
let jwtClaims: JwtClaims
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
authenticateFn = await oidc.buildVerifyFn(mockSaveUser)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
await authenticateFn(
|
||||||
|
issuer,
|
||||||
|
sub,
|
||||||
|
profile,
|
||||||
|
jwtClaims,
|
||||||
|
details.oauth2.accessToken,
|
||||||
|
details.oauth2.refreshToken,
|
||||||
|
idToken,
|
||||||
|
params,
|
||||||
|
mockDone
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it("passes auth details to sso module", async () => {
|
||||||
|
await authenticate()
|
||||||
|
|
||||||
|
expect(sso.authenticate).toHaveBeenCalledWith(
|
||||||
|
details,
|
||||||
|
false,
|
||||||
|
mockDone,
|
||||||
|
mockSaveUser
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses JWT email to get email", async () => {
|
||||||
|
delete profile._json.email
|
||||||
|
|
||||||
|
jwtClaims = {
|
||||||
|
email: details.email,
|
||||||
|
}
|
||||||
|
|
||||||
|
await authenticate()
|
||||||
|
|
||||||
|
expect(sso.authenticate).toHaveBeenCalledWith(
|
||||||
|
details,
|
||||||
|
false,
|
||||||
|
mockDone,
|
||||||
|
mockSaveUser
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses JWT username to get email", async () => {
|
||||||
|
delete profile._json.email
|
||||||
|
|
||||||
|
jwtClaims = {
|
||||||
|
email: details.email,
|
||||||
|
}
|
||||||
|
|
||||||
|
await authenticate()
|
||||||
|
|
||||||
|
expect(sso.authenticate).toHaveBeenCalledWith(
|
||||||
|
details,
|
||||||
|
false,
|
||||||
|
mockDone,
|
||||||
|
mockSaveUser
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses JWT invalid username to get email", async () => {
|
||||||
|
delete profile._json.email
|
||||||
|
|
||||||
|
jwtClaims = {
|
||||||
|
preferred_username: "invalidUsername",
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(authenticate()).rejects.toThrow(
|
||||||
|
"Could not determine user email from profile"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,196 @@
|
||||||
|
import { structures, testEnv, mocks } from "../../../../../tests"
|
||||||
|
import { SSOAuthDetails, User } from "@budibase/types"
|
||||||
|
|
||||||
|
import { HTTPError } from "../../../../errors"
|
||||||
|
import * as sso from "../sso"
|
||||||
|
import * as context from "../../../../context"
|
||||||
|
|
||||||
|
const mockDone = jest.fn()
|
||||||
|
const mockSaveUser = jest.fn()
|
||||||
|
|
||||||
|
jest.mock("../../../../users")
|
||||||
|
import * as _users from "../../../../users"
|
||||||
|
const users = jest.mocked(_users)
|
||||||
|
|
||||||
|
const getErrorMessage = () => {
|
||||||
|
return mockDone.mock.calls[0][2].message
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sso", () => {
|
||||||
|
describe("authenticate", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
testEnv.singleTenant()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("validation", () => {
|
||||||
|
const testValidation = async (
|
||||||
|
details: SSOAuthDetails,
|
||||||
|
message: string
|
||||||
|
) => {
|
||||||
|
await sso.authenticate(details, false, mockDone, mockSaveUser)
|
||||||
|
|
||||||
|
expect(mockDone.mock.calls.length).toBe(1)
|
||||||
|
expect(getErrorMessage()).toContain(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
it("user id fails", async () => {
|
||||||
|
const details = structures.sso.authDetails()
|
||||||
|
details.userId = undefined!
|
||||||
|
|
||||||
|
await testValidation(details, "sso user id required")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("email fails", async () => {
|
||||||
|
const details = structures.sso.authDetails()
|
||||||
|
details.email = undefined!
|
||||||
|
|
||||||
|
await testValidation(details, "sso user email required")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function mockGetProfilePicture() {
|
||||||
|
mocks.fetch.mockReturnValueOnce(
|
||||||
|
Promise.resolve({
|
||||||
|
status: 200,
|
||||||
|
headers: { get: () => "image/" },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("when the user doesn't exist", () => {
|
||||||
|
let user: User
|
||||||
|
let details: SSOAuthDetails
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
users.getById.mockImplementationOnce(() => {
|
||||||
|
throw new HTTPError("", 404)
|
||||||
|
})
|
||||||
|
mockGetProfilePicture()
|
||||||
|
|
||||||
|
user = structures.users.user()
|
||||||
|
delete user._rev
|
||||||
|
delete user._id
|
||||||
|
|
||||||
|
details = structures.sso.authDetails(user)
|
||||||
|
details.userId = structures.uuid()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("when a local account is required", () => {
|
||||||
|
it("returns an error message", async () => {
|
||||||
|
const details = structures.sso.authDetails()
|
||||||
|
|
||||||
|
await sso.authenticate(details, true, mockDone, mockSaveUser)
|
||||||
|
|
||||||
|
expect(mockDone.mock.calls.length).toBe(1)
|
||||||
|
expect(getErrorMessage()).toContain(
|
||||||
|
"Email does not yet exist. You must set up your local budibase account first."
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("when a local account isn't required", () => {
|
||||||
|
it("creates and authenticates the user", async () => {
|
||||||
|
const ssoUser = structures.users.ssoUser({ user, details })
|
||||||
|
mockSaveUser.mockReturnValueOnce(ssoUser)
|
||||||
|
|
||||||
|
await sso.authenticate(details, false, mockDone, mockSaveUser)
|
||||||
|
|
||||||
|
// default roles for new user
|
||||||
|
ssoUser.roles = {}
|
||||||
|
|
||||||
|
// modified external id to match user format
|
||||||
|
ssoUser._id = "us_" + details.userId
|
||||||
|
|
||||||
|
// new sso user won't have a password
|
||||||
|
delete ssoUser.password
|
||||||
|
|
||||||
|
// new user isn't saved with rev
|
||||||
|
delete ssoUser._rev
|
||||||
|
|
||||||
|
// tenant id added
|
||||||
|
ssoUser.tenantId = context.getTenantId()
|
||||||
|
|
||||||
|
expect(mockSaveUser).toBeCalledWith(ssoUser, {
|
||||||
|
hashPassword: false,
|
||||||
|
requirePassword: false,
|
||||||
|
})
|
||||||
|
expect(mockDone).toBeCalledWith(null, ssoUser)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("when the user exists", () => {
|
||||||
|
let existingUser: User
|
||||||
|
let details: SSOAuthDetails
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
existingUser = structures.users.user()
|
||||||
|
existingUser._id = structures.uuid()
|
||||||
|
details = structures.sso.authDetails(existingUser)
|
||||||
|
mockGetProfilePicture()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("exists by email", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
users.getById.mockImplementationOnce(() => {
|
||||||
|
throw new HTTPError("", 404)
|
||||||
|
})
|
||||||
|
users.getGlobalUserByEmail.mockReturnValueOnce(
|
||||||
|
Promise.resolve(existingUser)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("syncs and authenticates the user", async () => {
|
||||||
|
const ssoUser = structures.users.ssoUser({
|
||||||
|
user: existingUser,
|
||||||
|
details,
|
||||||
|
})
|
||||||
|
mockSaveUser.mockReturnValueOnce(ssoUser)
|
||||||
|
|
||||||
|
await sso.authenticate(details, true, mockDone, mockSaveUser)
|
||||||
|
|
||||||
|
// roles preserved
|
||||||
|
ssoUser.roles = existingUser.roles
|
||||||
|
|
||||||
|
// existing id preserved
|
||||||
|
ssoUser._id = existingUser._id
|
||||||
|
|
||||||
|
expect(mockSaveUser).toBeCalledWith(ssoUser, {
|
||||||
|
hashPassword: false,
|
||||||
|
requirePassword: false,
|
||||||
|
})
|
||||||
|
expect(mockDone).toBeCalledWith(null, ssoUser)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("exists by id", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
users.getById.mockReturnValueOnce(Promise.resolve(existingUser))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("syncs and authenticates the user", async () => {
|
||||||
|
const ssoUser = structures.users.ssoUser({
|
||||||
|
user: existingUser,
|
||||||
|
details,
|
||||||
|
})
|
||||||
|
mockSaveUser.mockReturnValueOnce(ssoUser)
|
||||||
|
|
||||||
|
await sso.authenticate(details, true, mockDone, mockSaveUser)
|
||||||
|
|
||||||
|
// roles preserved
|
||||||
|
ssoUser.roles = existingUser.roles
|
||||||
|
|
||||||
|
// existing id preserved
|
||||||
|
ssoUser._id = existingUser._id
|
||||||
|
|
||||||
|
expect(mockSaveUser).toBeCalledWith(ssoUser, {
|
||||||
|
hashPassword: false,
|
||||||
|
requirePassword: false,
|
||||||
|
})
|
||||||
|
expect(mockDone).toBeCalledWith(null, ssoUser)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,79 +0,0 @@
|
||||||
// Mock data
|
|
||||||
|
|
||||||
const { data } = require("./utilities/mock-data")
|
|
||||||
|
|
||||||
const TENANT_ID = "default"
|
|
||||||
|
|
||||||
const googleConfig = {
|
|
||||||
clientID: data.clientID,
|
|
||||||
clientSecret: data.clientSecret,
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = {
|
|
||||||
id: "mockId",
|
|
||||||
_json: {
|
|
||||||
email : data.email
|
|
||||||
},
|
|
||||||
provider: "google"
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = data.buildThirdPartyUser("google", "google", profile)
|
|
||||||
|
|
||||||
describe("google", () => {
|
|
||||||
describe("strategyFactory", () => {
|
|
||||||
// mock passport strategy factory
|
|
||||||
jest.mock("passport-google-oauth")
|
|
||||||
const mockStrategy = require("passport-google-oauth").OAuth2Strategy
|
|
||||||
|
|
||||||
it("should create successfully create a google strategy", async () => {
|
|
||||||
const google = require("../google")
|
|
||||||
|
|
||||||
const callbackUrl = `/api/global/auth/${TENANT_ID}/google/callback`
|
|
||||||
await google.strategyFactory(googleConfig, callbackUrl)
|
|
||||||
|
|
||||||
const expectedOptions = {
|
|
||||||
clientID: googleConfig.clientID,
|
|
||||||
clientSecret: googleConfig.clientSecret,
|
|
||||||
callbackURL: callbackUrl,
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(mockStrategy).toHaveBeenCalledWith(
|
|
||||||
expectedOptions,
|
|
||||||
expect.anything()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("authenticate", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// mock third party common authentication
|
|
||||||
jest.mock("../third-party-common")
|
|
||||||
const authenticateThirdParty = require("../third-party-common").authenticateThirdParty
|
|
||||||
|
|
||||||
// mock the passport callback
|
|
||||||
const mockDone = jest.fn()
|
|
||||||
|
|
||||||
it("delegates authentication to third party common", async () => {
|
|
||||||
const google = require("../google")
|
|
||||||
const mockSaveUserFn = jest.fn()
|
|
||||||
const authenticate = await google.buildVerifyFn(mockSaveUserFn)
|
|
||||||
|
|
||||||
await authenticate(
|
|
||||||
data.accessToken,
|
|
||||||
data.refreshToken,
|
|
||||||
profile,
|
|
||||||
mockDone
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(authenticateThirdParty).toHaveBeenCalledWith(
|
|
||||||
user,
|
|
||||||
true,
|
|
||||||
mockDone,
|
|
||||||
mockSaveUserFn)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
@ -1,144 +0,0 @@
|
||||||
// Mock data
|
|
||||||
const mockFetch = require("node-fetch")
|
|
||||||
const { data } = require("./utilities/mock-data")
|
|
||||||
const issuer = "mockIssuer"
|
|
||||||
const sub = "mockSub"
|
|
||||||
const profile = {
|
|
||||||
id: "mockId",
|
|
||||||
_json: {
|
|
||||||
email : data.email
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let jwtClaims = {}
|
|
||||||
const idToken = "mockIdToken"
|
|
||||||
const params = {}
|
|
||||||
|
|
||||||
const callbackUrl = "http://somecallbackurl"
|
|
||||||
|
|
||||||
// response from .well-known/openid-configuration
|
|
||||||
const oidcConfigUrlResponse = {
|
|
||||||
issuer: issuer,
|
|
||||||
authorization_endpoint: "mockAuthorizationEndpoint",
|
|
||||||
token_endpoint: "mockTokenEndpoint",
|
|
||||||
userinfo_endpoint: "mockUserInfoEndpoint"
|
|
||||||
}
|
|
||||||
|
|
||||||
const oidcConfig = {
|
|
||||||
configUrl: "http://someconfigurl",
|
|
||||||
clientID: data.clientID,
|
|
||||||
clientSecret: data.clientSecret,
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = data.buildThirdPartyUser(issuer, "oidc", profile)
|
|
||||||
|
|
||||||
describe("oidc", () => {
|
|
||||||
describe("strategyFactory", () => {
|
|
||||||
// mock passport strategy factory
|
|
||||||
jest.mock("@techpass/passport-openidconnect")
|
|
||||||
const mockStrategy = require("@techpass/passport-openidconnect").Strategy
|
|
||||||
|
|
||||||
// mock the request to retrieve the oidc configuration
|
|
||||||
mockFetch.mockReturnValue({
|
|
||||||
ok: true,
|
|
||||||
json: () => oidcConfigUrlResponse
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should create successfully create an oidc strategy", async () => {
|
|
||||||
const oidc = require("../oidc")
|
|
||||||
const enrichedConfig = await oidc.fetchStrategyConfig(oidcConfig, callbackUrl)
|
|
||||||
await oidc.strategyFactory(enrichedConfig, callbackUrl)
|
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(oidcConfig.configUrl)
|
|
||||||
|
|
||||||
const expectedOptions = {
|
|
||||||
issuer: oidcConfigUrlResponse.issuer,
|
|
||||||
authorizationURL: oidcConfigUrlResponse.authorization_endpoint,
|
|
||||||
tokenURL: oidcConfigUrlResponse.token_endpoint,
|
|
||||||
userInfoURL: oidcConfigUrlResponse.userinfo_endpoint,
|
|
||||||
clientID: oidcConfig.clientID,
|
|
||||||
clientSecret: oidcConfig.clientSecret,
|
|
||||||
callbackURL: callbackUrl,
|
|
||||||
}
|
|
||||||
expect(mockStrategy).toHaveBeenCalledWith(
|
|
||||||
expectedOptions,
|
|
||||||
expect.anything()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("authenticate", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllMocks()
|
|
||||||
});
|
|
||||||
|
|
||||||
// mock third party common authentication
|
|
||||||
jest.mock("../third-party-common")
|
|
||||||
const authenticateThirdParty = require("../third-party-common").authenticateThirdParty
|
|
||||||
|
|
||||||
// mock the passport callback
|
|
||||||
const mockDone = jest.fn()
|
|
||||||
const mockSaveUserFn = jest.fn()
|
|
||||||
|
|
||||||
async function doAuthenticate() {
|
|
||||||
const oidc = require("../oidc")
|
|
||||||
const authenticate = await oidc.buildVerifyFn(mockSaveUserFn)
|
|
||||||
|
|
||||||
await authenticate(
|
|
||||||
issuer,
|
|
||||||
sub,
|
|
||||||
profile,
|
|
||||||
jwtClaims,
|
|
||||||
data.accessToken,
|
|
||||||
data.refreshToken,
|
|
||||||
idToken,
|
|
||||||
params,
|
|
||||||
mockDone
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doTest() {
|
|
||||||
await doAuthenticate()
|
|
||||||
|
|
||||||
expect(authenticateThirdParty).toHaveBeenCalledWith(
|
|
||||||
user,
|
|
||||||
false,
|
|
||||||
mockDone,
|
|
||||||
mockSaveUserFn,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
it("delegates authentication to third party common", async () => {
|
|
||||||
await doTest()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("uses JWT email to get email", async () => {
|
|
||||||
delete profile._json.email
|
|
||||||
jwtClaims = {
|
|
||||||
email : "mock@budibase.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
await doTest()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("uses JWT username to get email", async () => {
|
|
||||||
delete profile._json.email
|
|
||||||
jwtClaims = {
|
|
||||||
preferred_username : "mock@budibase.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
await doTest()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("uses JWT invalid username to get email", async () => {
|
|
||||||
delete profile._json.email
|
|
||||||
|
|
||||||
jwtClaims = {
|
|
||||||
preferred_username : "invalidUsername"
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(doAuthenticate()).rejects.toThrow("Could not determine user email from profile");
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
@ -1,178 +0,0 @@
|
||||||
require("../../../../tests")
|
|
||||||
const { authenticateThirdParty } = require("../third-party-common")
|
|
||||||
const { data } = require("./utilities/mock-data")
|
|
||||||
const { DEFAULT_TENANT_ID } = require("../../../constants")
|
|
||||||
|
|
||||||
const { generateGlobalUserID } = require("../../../db/utils")
|
|
||||||
const { newid } = require("../../../utils")
|
|
||||||
const { doWithGlobalDB, doInTenant } = require("../../../tenancy")
|
|
||||||
|
|
||||||
const done = jest.fn()
|
|
||||||
|
|
||||||
const getErrorMessage = () => {
|
|
||||||
return done.mock.calls[0][2].message
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveUser = async (user) => {
|
|
||||||
return doWithGlobalDB(DEFAULT_TENANT_ID, async db => {
|
|
||||||
return await db.put(user)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function authenticate(user, requireLocal, saveFn) {
|
|
||||||
return doInTenant(DEFAULT_TENANT_ID, () => {
|
|
||||||
return authenticateThirdParty(user, requireLocal, done, saveFn)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("third party common", () => {
|
|
||||||
describe("authenticateThirdParty", () => {
|
|
||||||
let thirdPartyUser
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
thirdPartyUser = data.buildThirdPartyUser()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
return doWithGlobalDB(DEFAULT_TENANT_ID, async db => {
|
|
||||||
jest.clearAllMocks()
|
|
||||||
await db.destroy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("validation", () => {
|
|
||||||
const testValidation = async (message) => {
|
|
||||||
await authenticate(thirdPartyUser, false, saveUser)
|
|
||||||
expect(done.mock.calls.length).toBe(1)
|
|
||||||
expect(getErrorMessage()).toContain(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
it("provider fails", async () => {
|
|
||||||
delete thirdPartyUser.provider
|
|
||||||
await testValidation("third party user provider required")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("user id fails", async () => {
|
|
||||||
delete thirdPartyUser.userId
|
|
||||||
await testValidation("third party user id required")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("email fails", async () => {
|
|
||||||
delete thirdPartyUser.email
|
|
||||||
await testValidation("third party user email required")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const expectUserIsAuthenticated = () => {
|
|
||||||
const user = done.mock.calls[0][1]
|
|
||||||
expect(user).toBeDefined()
|
|
||||||
expect(user._id).toBeDefined()
|
|
||||||
expect(user._rev).toBeDefined()
|
|
||||||
expect(user.token).toBeDefined()
|
|
||||||
return user
|
|
||||||
}
|
|
||||||
|
|
||||||
const expectUserIsSynced = (user, thirdPartyUser) => {
|
|
||||||
expect(user.provider).toBe(thirdPartyUser.provider)
|
|
||||||
expect(user.firstName).toBe(thirdPartyUser.profile.name.givenName)
|
|
||||||
expect(user.lastName).toBe(thirdPartyUser.profile.name.familyName)
|
|
||||||
expect(user.thirdPartyProfile).toStrictEqual(thirdPartyUser.profile._json)
|
|
||||||
expect(user.oauth2).toStrictEqual(thirdPartyUser.oauth2)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("when the user doesn't exist", () => {
|
|
||||||
describe("when a local account is required", () => {
|
|
||||||
it("returns an error message", async () => {
|
|
||||||
await authenticate(thirdPartyUser, true, saveUser)
|
|
||||||
expect(done.mock.calls.length).toBe(1)
|
|
||||||
expect(getErrorMessage()).toContain("Email does not yet exist. You must set up your local budibase account first.")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("when a local account isn't required", () => {
|
|
||||||
it("creates and authenticates the user", async () => {
|
|
||||||
await authenticate(thirdPartyUser, false, saveUser)
|
|
||||||
const user = expectUserIsAuthenticated()
|
|
||||||
expectUserIsSynced(user, thirdPartyUser)
|
|
||||||
expect(user.roles).toStrictEqual({})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("when the user exists", () => {
|
|
||||||
let dbUser
|
|
||||||
let id
|
|
||||||
let email
|
|
||||||
|
|
||||||
const createUser = async () => {
|
|
||||||
return doWithGlobalDB(DEFAULT_TENANT_ID, async db => {
|
|
||||||
dbUser = {
|
|
||||||
_id: id,
|
|
||||||
email: email,
|
|
||||||
}
|
|
||||||
const response = await db.put(dbUser)
|
|
||||||
dbUser._rev = response.rev
|
|
||||||
return dbUser
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const expectUserIsUpdated = (user) => {
|
|
||||||
// id is unchanged
|
|
||||||
expect(user._id).toBe(id)
|
|
||||||
// user is updated
|
|
||||||
expect(user._rev).not.toBe(dbUser._rev)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("exists by email", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
id = generateGlobalUserID(newid()) // random id
|
|
||||||
email = thirdPartyUser.email // matching email
|
|
||||||
await createUser()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("syncs and authenticates the user", async () => {
|
|
||||||
await authenticate(thirdPartyUser, true, saveUser)
|
|
||||||
|
|
||||||
const user = expectUserIsAuthenticated()
|
|
||||||
expectUserIsSynced(user, thirdPartyUser)
|
|
||||||
expectUserIsUpdated(user)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("exists by email with different casing", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
id = generateGlobalUserID(newid()) // random id
|
|
||||||
email = thirdPartyUser.email.toUpperCase() // matching email except for casing
|
|
||||||
await createUser()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("syncs and authenticates the user", async () => {
|
|
||||||
await authenticate(thirdPartyUser, true, saveUser)
|
|
||||||
|
|
||||||
const user = expectUserIsAuthenticated()
|
|
||||||
expectUserIsSynced(user, thirdPartyUser)
|
|
||||||
expectUserIsUpdated(user)
|
|
||||||
expect(user.email).toBe(thirdPartyUser.email.toUpperCase())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
describe("exists by id", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
id = generateGlobalUserID(thirdPartyUser.userId) // matching id
|
|
||||||
email = "test@test.com" // random email
|
|
||||||
await createUser()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("syncs and authenticates the user", async () => {
|
|
||||||
await authenticate(thirdPartyUser, true, saveUser)
|
|
||||||
|
|
||||||
const user = expectUserIsAuthenticated()
|
|
||||||
expectUserIsSynced(user, thirdPartyUser)
|
|
||||||
expectUserIsUpdated(user)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
@ -1,54 +0,0 @@
|
||||||
// Mock Data
|
|
||||||
|
|
||||||
const mockClientID = "mockClientID"
|
|
||||||
const mockClientSecret = "mockClientSecret"
|
|
||||||
|
|
||||||
const mockEmail = "mock@budibase.com"
|
|
||||||
const mockAccessToken = "mockAccessToken"
|
|
||||||
const mockRefreshToken = "mockRefreshToken"
|
|
||||||
|
|
||||||
const mockProvider = "mockProvider"
|
|
||||||
const mockProviderType = "mockProviderType"
|
|
||||||
|
|
||||||
const mockProfile = {
|
|
||||||
id: "mockId",
|
|
||||||
name: {
|
|
||||||
givenName: "mockGivenName",
|
|
||||||
familyName: "mockFamilyName",
|
|
||||||
},
|
|
||||||
_json: {
|
|
||||||
email: mockEmail,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildOauth2 = (
|
|
||||||
accessToken = mockAccessToken,
|
|
||||||
refreshToken = mockRefreshToken
|
|
||||||
) => ({
|
|
||||||
accessToken: accessToken,
|
|
||||||
refreshToken: refreshToken,
|
|
||||||
})
|
|
||||||
|
|
||||||
const buildThirdPartyUser = (
|
|
||||||
provider = mockProvider,
|
|
||||||
providerType = mockProviderType,
|
|
||||||
profile = mockProfile,
|
|
||||||
email = mockEmail,
|
|
||||||
oauth2 = buildOauth2()
|
|
||||||
) => ({
|
|
||||||
provider: provider,
|
|
||||||
providerType: providerType,
|
|
||||||
userId: profile.id,
|
|
||||||
profile: profile,
|
|
||||||
email: email,
|
|
||||||
oauth2: oauth2,
|
|
||||||
})
|
|
||||||
|
|
||||||
exports.data = {
|
|
||||||
clientID: mockClientID,
|
|
||||||
clientSecret: mockClientSecret,
|
|
||||||
email: mockEmail,
|
|
||||||
accessToken: mockAccessToken,
|
|
||||||
refreshToken: mockRefreshToken,
|
|
||||||
buildThirdPartyUser,
|
|
||||||
}
|
|
|
@ -1,177 +0,0 @@
|
||||||
import env from "../../environment"
|
|
||||||
import { generateGlobalUserID } from "../../db"
|
|
||||||
import { authError } from "./utils"
|
|
||||||
import { newid } from "../../utils"
|
|
||||||
import { createASession } from "../../security/sessions"
|
|
||||||
import * as users from "../../users"
|
|
||||||
import { getGlobalDB, getTenantId } from "../../tenancy"
|
|
||||||
import fetch from "node-fetch"
|
|
||||||
import { ThirdPartyUser } from "@budibase/types"
|
|
||||||
const jwt = require("jsonwebtoken")
|
|
||||||
|
|
||||||
type SaveUserOpts = {
|
|
||||||
requirePassword?: boolean
|
|
||||||
hashPassword?: boolean
|
|
||||||
currentUserId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SaveUserFunction = (
|
|
||||||
user: ThirdPartyUser,
|
|
||||||
opts: SaveUserOpts
|
|
||||||
) => Promise<any>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Common authentication logic for third parties. e.g. OAuth, OIDC.
|
|
||||||
*/
|
|
||||||
export async function authenticateThirdParty(
|
|
||||||
thirdPartyUser: ThirdPartyUser,
|
|
||||||
requireLocalAccount: boolean = true,
|
|
||||||
done: Function,
|
|
||||||
saveUserFn?: SaveUserFunction
|
|
||||||
) {
|
|
||||||
if (!saveUserFn) {
|
|
||||||
throw new Error("Save user function must be provided")
|
|
||||||
}
|
|
||||||
if (!thirdPartyUser.provider) {
|
|
||||||
return authError(done, "third party user provider required")
|
|
||||||
}
|
|
||||||
if (!thirdPartyUser.userId) {
|
|
||||||
return authError(done, "third party user id required")
|
|
||||||
}
|
|
||||||
if (!thirdPartyUser.email) {
|
|
||||||
return authError(done, "third party user email required")
|
|
||||||
}
|
|
||||||
|
|
||||||
// use the third party id
|
|
||||||
const userId = generateGlobalUserID(thirdPartyUser.userId)
|
|
||||||
const db = getGlobalDB()
|
|
||||||
|
|
||||||
let dbUser
|
|
||||||
|
|
||||||
// try to load by id
|
|
||||||
try {
|
|
||||||
dbUser = await db.get(userId)
|
|
||||||
} catch (err: any) {
|
|
||||||
// abort when not 404 error
|
|
||||||
if (!err.status || err.status !== 404) {
|
|
||||||
return authError(
|
|
||||||
done,
|
|
||||||
"Unexpected error when retrieving existing user",
|
|
||||||
err
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fallback to loading by email
|
|
||||||
if (!dbUser) {
|
|
||||||
dbUser = await users.getGlobalUserByEmail(thirdPartyUser.email)
|
|
||||||
}
|
|
||||||
|
|
||||||
// exit early if there is still no user and auto creation is disabled
|
|
||||||
if (!dbUser && requireLocalAccount) {
|
|
||||||
return authError(
|
|
||||||
done,
|
|
||||||
"Email does not yet exist. You must set up your local budibase account first."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// first time creation
|
|
||||||
if (!dbUser) {
|
|
||||||
// setup a blank user using the third party id
|
|
||||||
dbUser = {
|
|
||||||
_id: userId,
|
|
||||||
email: thirdPartyUser.email,
|
|
||||||
roles: {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dbUser = await syncUser(dbUser, thirdPartyUser)
|
|
||||||
|
|
||||||
// never prompt for password reset
|
|
||||||
dbUser.forceResetPassword = false
|
|
||||||
|
|
||||||
// create or sync the user
|
|
||||||
try {
|
|
||||||
await saveUserFn(dbUser, { hashPassword: false, requirePassword: false })
|
|
||||||
} catch (err: any) {
|
|
||||||
return authError(done, "Error saving user", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// now that we're sure user exists, load them from the db
|
|
||||||
dbUser = await db.get(dbUser._id)
|
|
||||||
|
|
||||||
// authenticate
|
|
||||||
const sessionId = newid()
|
|
||||||
const tenantId = getTenantId()
|
|
||||||
await createASession(dbUser._id, { sessionId, tenantId })
|
|
||||||
|
|
||||||
dbUser.token = jwt.sign(
|
|
||||||
{
|
|
||||||
userId: dbUser._id,
|
|
||||||
sessionId,
|
|
||||||
},
|
|
||||||
env.JWT_SECRET
|
|
||||||
)
|
|
||||||
|
|
||||||
return done(null, dbUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncProfilePicture(
|
|
||||||
user: ThirdPartyUser,
|
|
||||||
thirdPartyUser: ThirdPartyUser
|
|
||||||
) {
|
|
||||||
const pictureUrl = thirdPartyUser.profile?._json.picture
|
|
||||||
if (pictureUrl) {
|
|
||||||
const response = await fetch(pictureUrl)
|
|
||||||
|
|
||||||
if (response.status === 200) {
|
|
||||||
const type = response.headers.get("content-type") as string
|
|
||||||
if (type.startsWith("image/")) {
|
|
||||||
user.pictureUrl = pictureUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return user
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns a user that has been sync'd with third party information
|
|
||||||
*/
|
|
||||||
async function syncUser(user: ThirdPartyUser, thirdPartyUser: ThirdPartyUser) {
|
|
||||||
// provider
|
|
||||||
user.provider = thirdPartyUser.provider
|
|
||||||
user.providerType = thirdPartyUser.providerType
|
|
||||||
|
|
||||||
if (thirdPartyUser.profile) {
|
|
||||||
const profile = thirdPartyUser.profile
|
|
||||||
|
|
||||||
if (profile.name) {
|
|
||||||
const name = profile.name
|
|
||||||
// first name
|
|
||||||
if (name.givenName) {
|
|
||||||
user.firstName = name.givenName
|
|
||||||
}
|
|
||||||
// last name
|
|
||||||
if (name.familyName) {
|
|
||||||
user.lastName = name.familyName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
user = await syncProfilePicture(user, thirdPartyUser)
|
|
||||||
|
|
||||||
// profile
|
|
||||||
user.thirdPartyProfile = {
|
|
||||||
...profile._json,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// oauth tokens for future use
|
|
||||||
if (thirdPartyUser.oauth2) {
|
|
||||||
user.oauth2 = {
|
|
||||||
...thirdPartyUser.oauth2,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return user
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { isMultiTenant, getTenantId } from "../../tenancy"
|
import { isMultiTenant, getTenantId } from "../../context"
|
||||||
import { getScopedConfig } from "../../db"
|
import { getScopedConfig } from "../../db"
|
||||||
import { ConfigType, Database, Config } from "@budibase/types"
|
import { ConfigType, Database } from "@budibase/types"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility to handle authentication errors.
|
* Utility to handle authentication errors.
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { doInTenant, getTenantIDFromCtx } from "../tenancy"
|
import { doInTenant } from "../context"
|
||||||
|
import { getTenantIDFromCtx } from "../tenancy"
|
||||||
import { buildMatcherRegex, matches } from "./matchers"
|
import { buildMatcherRegex, matches } from "./matchers"
|
||||||
import { Header } from "../constants"
|
import { Header } from "../constants"
|
||||||
import {
|
import {
|
||||||
|
|
|
@ -4,10 +4,10 @@ import {
|
||||||
StaticDatabases,
|
StaticDatabases,
|
||||||
getAllApps,
|
getAllApps,
|
||||||
getGlobalDBName,
|
getGlobalDBName,
|
||||||
doWithDB,
|
getDB,
|
||||||
} from "../db"
|
} from "../db"
|
||||||
import environment from "../environment"
|
import environment from "../environment"
|
||||||
import { doInTenant, getTenantIds, getTenantId } from "../tenancy"
|
import * as platform from "../platform"
|
||||||
import * as context from "../context"
|
import * as context from "../context"
|
||||||
import { DEFINITIONS } from "."
|
import { DEFINITIONS } from "."
|
||||||
import {
|
import {
|
||||||
|
@ -47,7 +47,7 @@ export const runMigration = async (
|
||||||
const migrationType = migration.type
|
const migrationType = migration.type
|
||||||
let tenantId: string | undefined
|
let tenantId: string | undefined
|
||||||
if (migrationType !== MigrationType.INSTALLATION) {
|
if (migrationType !== MigrationType.INSTALLATION) {
|
||||||
tenantId = getTenantId()
|
tenantId = context.getTenantId()
|
||||||
}
|
}
|
||||||
const migrationName = migration.name
|
const migrationName = migration.name
|
||||||
const silent = migration.silent
|
const silent = migration.silent
|
||||||
|
@ -86,7 +86,7 @@ export const runMigration = async (
|
||||||
count++
|
count++
|
||||||
const lengthStatement = length > 1 ? `[${count}/${length}]` : ""
|
const lengthStatement = length > 1 ? `[${count}/${length}]` : ""
|
||||||
|
|
||||||
await doWithDB(dbName, async (db: any) => {
|
const db = getDB(dbName)
|
||||||
try {
|
try {
|
||||||
const doc = await getMigrationsDoc(db)
|
const doc = await getMigrationsDoc(db)
|
||||||
|
|
||||||
|
@ -145,7 +145,6 @@ export const runMigration = async (
|
||||||
)
|
)
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -160,7 +159,7 @@ export const runMigrations = async (
|
||||||
tenantIds = [options.noOp.tenantId]
|
tenantIds = [options.noOp.tenantId]
|
||||||
} else if (!options.tenantIds || !options.tenantIds.length) {
|
} else if (!options.tenantIds || !options.tenantIds.length) {
|
||||||
// run for all tenants
|
// run for all tenants
|
||||||
tenantIds = await getTenantIds()
|
tenantIds = await platform.tenants.getTenantIds()
|
||||||
} else {
|
} else {
|
||||||
tenantIds = options.tenantIds
|
tenantIds = options.tenantIds
|
||||||
}
|
}
|
||||||
|
@ -185,7 +184,10 @@ export const runMigrations = async (
|
||||||
// for all migrations
|
// for all migrations
|
||||||
for (const migration of migrations) {
|
for (const migration of migrations) {
|
||||||
// run the migration
|
// run the migration
|
||||||
await doInTenant(tenantId, () => runMigration(migration, options))
|
await context.doInTenant(
|
||||||
|
tenantId,
|
||||||
|
async () => await runMigration(migration, options)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log("Migrations complete")
|
console.log("Migrations complete")
|
||||||
|
|
|
@ -1,57 +0,0 @@
|
||||||
require("../../../tests")
|
|
||||||
const { runMigrations, getMigrationsDoc } = require("../index")
|
|
||||||
const { getGlobalDBName, getDB } = require("../../db")
|
|
||||||
|
|
||||||
const { structures, testEnv } = require("../../../tests")
|
|
||||||
testEnv.multiTenant()
|
|
||||||
|
|
||||||
let db
|
|
||||||
|
|
||||||
describe("migrations", () => {
|
|
||||||
|
|
||||||
const migrationFunction = jest.fn()
|
|
||||||
|
|
||||||
const MIGRATIONS = [{
|
|
||||||
type: "global",
|
|
||||||
name: "test",
|
|
||||||
fn: migrationFunction
|
|
||||||
}]
|
|
||||||
|
|
||||||
let tenantId
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tenantId = structures.tenant.id()
|
|
||||||
db = getDB(getGlobalDBName(tenantId))
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
jest.clearAllMocks()
|
|
||||||
await db.destroy()
|
|
||||||
})
|
|
||||||
|
|
||||||
const migrate = () => {
|
|
||||||
return runMigrations(MIGRATIONS, { tenantIds: [tenantId]})
|
|
||||||
}
|
|
||||||
|
|
||||||
it("should run a new migration", async () => {
|
|
||||||
await migrate()
|
|
||||||
expect(migrationFunction).toHaveBeenCalled()
|
|
||||||
const doc = await getMigrationsDoc(db)
|
|
||||||
expect(doc.test).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should match snapshot", async () => {
|
|
||||||
await migrate()
|
|
||||||
const doc = await getMigrationsDoc(db)
|
|
||||||
expect(doc).toMatchSnapshot()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should skip a previously run migration", async () => {
|
|
||||||
await migrate()
|
|
||||||
const previousMigrationTime = await getMigrationsDoc(db).test
|
|
||||||
await migrate()
|
|
||||||
const currentMigrationTime = await getMigrationsDoc(db).test
|
|
||||||
expect(migrationFunction).toHaveBeenCalledTimes(1)
|
|
||||||
expect(currentMigrationTime).toBe(previousMigrationTime)
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { testEnv, DBTestConfiguration } from "../../../tests"
|
||||||
|
import * as migrations from "../index"
|
||||||
|
import * as context from "../../context"
|
||||||
|
import { MigrationType } from "@budibase/types"
|
||||||
|
|
||||||
|
testEnv.multiTenant()
|
||||||
|
|
||||||
|
describe("migrations", () => {
|
||||||
|
const config = new DBTestConfiguration()
|
||||||
|
|
||||||
|
const migrationFunction = jest.fn()
|
||||||
|
|
||||||
|
const MIGRATIONS = [
|
||||||
|
{
|
||||||
|
type: MigrationType.GLOBAL,
|
||||||
|
name: "test" as any,
|
||||||
|
fn: migrationFunction,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
config.newTenant()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const migrate = () => {
|
||||||
|
return migrations.runMigrations(MIGRATIONS, {
|
||||||
|
tenantIds: [config.tenantId],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should run a new migration", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
await migrate()
|
||||||
|
expect(migrationFunction).toHaveBeenCalled()
|
||||||
|
const db = context.getGlobalDB()
|
||||||
|
const doc = await migrations.getMigrationsDoc(db)
|
||||||
|
expect(doc.test).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should match snapshot", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
await migrate()
|
||||||
|
const doc = await migrations.getMigrationsDoc(context.getGlobalDB())
|
||||||
|
expect(doc).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should skip a previously run migration", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const db = context.getGlobalDB()
|
||||||
|
await migrate()
|
||||||
|
const previousDoc = await migrations.getMigrationsDoc(db)
|
||||||
|
await migrate()
|
||||||
|
const currentDoc = await migrations.getMigrationsDoc(db)
|
||||||
|
expect(migrationFunction).toHaveBeenCalledTimes(1)
|
||||||
|
expect(currentDoc.test).toBe(previousDoc.test)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,5 +1,5 @@
|
||||||
import env from "../../environment"
|
import env from "../../environment"
|
||||||
import * as tenancy from "../../tenancy"
|
import * as context from "../../context"
|
||||||
import * as objectStore from "../objectStore"
|
import * as objectStore from "../objectStore"
|
||||||
import * as cloudfront from "../cloudfront"
|
import * as cloudfront from "../cloudfront"
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ export const getGlobalFileUrl = (type: string, name: string, etag?: string) => {
|
||||||
export const getGlobalFileS3Key = (type: string, name: string) => {
|
export const getGlobalFileS3Key = (type: string, name: string) => {
|
||||||
let file = `${type}/${name}`
|
let file = `${type}/${name}`
|
||||||
if (env.MULTI_TENANCY) {
|
if (env.MULTI_TENANCY) {
|
||||||
const tenantId = tenancy.getTenantId()
|
const tenantId = context.getTenantId()
|
||||||
file = `${tenantId}/${file}`
|
file = `${tenantId}/${file}`
|
||||||
}
|
}
|
||||||
return file
|
return file
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import env from "../../environment"
|
import env from "../../environment"
|
||||||
import * as objectStore from "../objectStore"
|
import * as objectStore from "../objectStore"
|
||||||
import * as tenancy from "../../tenancy"
|
import * as context from "../../context"
|
||||||
import * as cloudfront from "../cloudfront"
|
import * as cloudfront from "../cloudfront"
|
||||||
import { Plugin } from "@budibase/types"
|
import { Plugin } from "@budibase/types"
|
||||||
|
|
||||||
|
@ -61,7 +61,7 @@ const getPluginS3Key = (plugin: Plugin, fileName: string) => {
|
||||||
export const getPluginS3Dir = (pluginName: string) => {
|
export const getPluginS3Dir = (pluginName: string) => {
|
||||||
let s3Key = `${pluginName}`
|
let s3Key = `${pluginName}`
|
||||||
if (env.MULTI_TENANCY) {
|
if (env.MULTI_TENANCY) {
|
||||||
const tenantId = tenancy.getTenantId()
|
const tenantId = context.getTenantId()
|
||||||
s3Key = `${tenantId}/${s3Key}`
|
s3Key = `${tenantId}/${s3Key}`
|
||||||
}
|
}
|
||||||
if (env.CLOUDFRONT_CDN) {
|
if (env.CLOUDFRONT_CDN) {
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * as users from "./users"
|
||||||
|
export * as tenants from "./tenants"
|
||||||
|
export * from "./platformDb"
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { StaticDatabases } from "../constants"
|
||||||
|
import { getDB } from "../db/db"
|
||||||
|
|
||||||
|
export function getPlatformDB() {
|
||||||
|
return getDB(StaticDatabases.PLATFORM_INFO.name)
|
||||||
|
}
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { StaticDatabases } from "../constants"
|
||||||
|
import { getPlatformDB } from "./platformDb"
|
||||||
|
import { LockName, LockOptions, LockType, Tenants } from "@budibase/types"
|
||||||
|
import * as locks from "../redis/redlock"
|
||||||
|
|
||||||
|
const TENANT_DOC = StaticDatabases.PLATFORM_INFO.docs.tenants
|
||||||
|
|
||||||
|
export const tenacyLockOptions: LockOptions = {
|
||||||
|
type: LockType.DEFAULT,
|
||||||
|
name: LockName.UPDATE_TENANTS_DOC,
|
||||||
|
ttl: 10 * 1000, // auto expire after 10 seconds
|
||||||
|
systemLock: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// READ
|
||||||
|
|
||||||
|
export async function getTenantIds(): Promise<string[]> {
|
||||||
|
const tenants = await getTenants()
|
||||||
|
return tenants.tenantIds
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTenants(): Promise<Tenants> {
|
||||||
|
const db = getPlatformDB()
|
||||||
|
let tenants: Tenants
|
||||||
|
|
||||||
|
try {
|
||||||
|
tenants = await db.get(TENANT_DOC)
|
||||||
|
} catch (e: any) {
|
||||||
|
// doesn't exist yet - create
|
||||||
|
if (e.status === 404) {
|
||||||
|
tenants = await createTenantsDoc()
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tenants
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exists(tenantId: string) {
|
||||||
|
const tenants = await getTenants()
|
||||||
|
return tenants.tenantIds.indexOf(tenantId) !== -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// CREATE / UPDATE
|
||||||
|
|
||||||
|
function newTenantsDoc(): Tenants {
|
||||||
|
return {
|
||||||
|
_id: TENANT_DOC,
|
||||||
|
tenantIds: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTenantsDoc(): Promise<Tenants> {
|
||||||
|
const db = getPlatformDB()
|
||||||
|
let tenants = newTenantsDoc()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await db.put(tenants)
|
||||||
|
tenants._rev = response.rev
|
||||||
|
} catch (e: any) {
|
||||||
|
// don't throw 409 is doc has already been created
|
||||||
|
if (e.status === 409) {
|
||||||
|
return db.get(TENANT_DOC)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
return tenants
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTenant(tenantId: string) {
|
||||||
|
const db = getPlatformDB()
|
||||||
|
|
||||||
|
// use a lock as tenant creation is conflict prone
|
||||||
|
await locks.doWithLock(tenacyLockOptions, async () => {
|
||||||
|
const tenants = await getTenants()
|
||||||
|
|
||||||
|
// write the new tenant if it doesn't already exist
|
||||||
|
if (tenants.tenantIds.indexOf(tenantId) === -1) {
|
||||||
|
tenants.tenantIds.push(tenantId)
|
||||||
|
await db.put(tenants)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
|
||||||
|
export async function removeTenant(tenantId: string) {
|
||||||
|
try {
|
||||||
|
await locks.doWithLock(tenacyLockOptions, async () => {
|
||||||
|
const db = getPlatformDB()
|
||||||
|
const tenants = await getTenants()
|
||||||
|
tenants.tenantIds = tenants.tenantIds.filter(id => id !== tenantId)
|
||||||
|
await db.put(tenants)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error removing tenant ${tenantId} from info db`, err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { DBTestConfiguration, structures } from "../../../tests"
|
||||||
|
import * as tenants from "../tenants"
|
||||||
|
|
||||||
|
describe("tenants", () => {
|
||||||
|
const config = new DBTestConfiguration()
|
||||||
|
|
||||||
|
describe("addTenant", () => {
|
||||||
|
it("concurrently adds multiple tenants safely", async () => {
|
||||||
|
const tenant1 = structures.tenant.id()
|
||||||
|
const tenant2 = structures.tenant.id()
|
||||||
|
const tenant3 = structures.tenant.id()
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
tenants.addTenant(tenant1),
|
||||||
|
tenants.addTenant(tenant2),
|
||||||
|
tenants.addTenant(tenant3),
|
||||||
|
])
|
||||||
|
|
||||||
|
const tenantIds = await tenants.getTenantIds()
|
||||||
|
expect(tenantIds.includes(tenant1)).toBe(true)
|
||||||
|
expect(tenantIds.includes(tenant2)).toBe(true)
|
||||||
|
expect(tenantIds.includes(tenant3)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { getPlatformDB } from "./platformDb"
|
||||||
|
import { DEFAULT_TENANT_ID } from "../constants"
|
||||||
|
import env from "../environment"
|
||||||
|
import {
|
||||||
|
PlatformUser,
|
||||||
|
PlatformUserByEmail,
|
||||||
|
PlatformUserById,
|
||||||
|
User,
|
||||||
|
} from "@budibase/types"
|
||||||
|
|
||||||
|
// READ
|
||||||
|
|
||||||
|
export async function lookupTenantId(userId: string) {
|
||||||
|
if (!env.MULTI_TENANCY) {
|
||||||
|
return DEFAULT_TENANT_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserDoc(userId)
|
||||||
|
return user.tenantId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserDoc(emailOrId: string): Promise<PlatformUser> {
|
||||||
|
const db = getPlatformDB()
|
||||||
|
return db.get(emailOrId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CREATE
|
||||||
|
|
||||||
|
function newUserIdDoc(id: string, tenantId: string): PlatformUserById {
|
||||||
|
return {
|
||||||
|
_id: id,
|
||||||
|
tenantId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function newUserEmailDoc(
|
||||||
|
userId: string,
|
||||||
|
email: string,
|
||||||
|
tenantId: string
|
||||||
|
): PlatformUserByEmail {
|
||||||
|
return {
|
||||||
|
_id: email,
|
||||||
|
userId,
|
||||||
|
tenantId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new user id or email doc if it doesn't exist.
|
||||||
|
*/
|
||||||
|
async function addUserDoc(emailOrId: string, newDocFn: () => PlatformUser) {
|
||||||
|
const db = getPlatformDB()
|
||||||
|
let user: PlatformUser
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.get(emailOrId)
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.status === 404) {
|
||||||
|
user = newDocFn()
|
||||||
|
await db.put(user)
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addUser(tenantId: string, userId: string, email: string) {
|
||||||
|
await Promise.all([
|
||||||
|
addUserDoc(userId, () => newUserIdDoc(userId, tenantId)),
|
||||||
|
addUserDoc(email, () => newUserEmailDoc(userId, email, tenantId)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
|
||||||
|
export async function removeUser(user: User) {
|
||||||
|
const db = getPlatformDB()
|
||||||
|
const keys = [user._id!, user.email]
|
||||||
|
const userDocs = await db.allDocs({
|
||||||
|
keys,
|
||||||
|
include_docs: true,
|
||||||
|
})
|
||||||
|
const toDelete = userDocs.rows.map((row: any) => {
|
||||||
|
return {
|
||||||
|
...row.doc,
|
||||||
|
_deleted: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await db.bulkDocs(toDelete)
|
||||||
|
}
|
|
@ -4,7 +4,6 @@ import { JobQueue } from "./constants"
|
||||||
import InMemoryQueue from "./inMemoryQueue"
|
import InMemoryQueue from "./inMemoryQueue"
|
||||||
import BullQueue from "bull"
|
import BullQueue from "bull"
|
||||||
import { addListeners, StalledFn } from "./listeners"
|
import { addListeners, StalledFn } from "./listeners"
|
||||||
const { opts: redisOpts, redisProtocolUrl } = getRedisOptions()
|
|
||||||
|
|
||||||
const CLEANUP_PERIOD_MS = 60 * 1000
|
const CLEANUP_PERIOD_MS = 60 * 1000
|
||||||
let QUEUES: BullQueue.Queue[] | InMemoryQueue[] = []
|
let QUEUES: BullQueue.Queue[] | InMemoryQueue[] = []
|
||||||
|
@ -20,6 +19,7 @@ export function createQueue<T>(
|
||||||
jobQueue: JobQueue,
|
jobQueue: JobQueue,
|
||||||
opts: { removeStalledCb?: StalledFn } = {}
|
opts: { removeStalledCb?: StalledFn } = {}
|
||||||
): BullQueue.Queue<T> {
|
): BullQueue.Queue<T> {
|
||||||
|
const { opts: redisOpts, redisProtocolUrl } = getRedisOptions()
|
||||||
const queueConfig: any = redisProtocolUrl || { redis: redisOpts }
|
const queueConfig: any = redisProtocolUrl || { redis: redisOpts }
|
||||||
let queue: any
|
let queue: any
|
||||||
if (!env.isTest()) {
|
if (!env.isTest()) {
|
||||||
|
|
|
@ -3,4 +3,4 @@
|
||||||
export { default as Client } from "./redis"
|
export { default as Client } from "./redis"
|
||||||
export * as utils from "./utils"
|
export * as utils from "./utils"
|
||||||
export * as clients from "./init"
|
export * as clients from "./init"
|
||||||
export * as redlock from "./redlock"
|
export * as locks from "./redlock"
|
||||||
|
|
|
@ -20,13 +20,17 @@ async function init() {
|
||||||
).init()
|
).init()
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on("exit", async () => {
|
export async function shutdown() {
|
||||||
if (userClient) await userClient.finish()
|
if (userClient) await userClient.finish()
|
||||||
if (sessionClient) await sessionClient.finish()
|
if (sessionClient) await sessionClient.finish()
|
||||||
if (appClient) await appClient.finish()
|
if (appClient) await appClient.finish()
|
||||||
if (cacheClient) await cacheClient.finish()
|
if (cacheClient) await cacheClient.finish()
|
||||||
if (writethroughClient) await writethroughClient.finish()
|
if (writethroughClient) await writethroughClient.finish()
|
||||||
if (lockClient) await lockClient.finish()
|
if (lockClient) await lockClient.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("exit", async () => {
|
||||||
|
await shutdown()
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function getUserClient() {
|
export async function getUserClient() {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
// ioredis mock is all in memory
|
// ioredis mock is all in memory
|
||||||
const Redis = env.isTest() ? require("ioredis-mock") : require("ioredis")
|
const Redis = env.MOCK_REDIS ? require("ioredis-mock") : require("ioredis")
|
||||||
import {
|
import {
|
||||||
addDbPrefix,
|
addDbPrefix,
|
||||||
removeDbPrefix,
|
removeDbPrefix,
|
||||||
|
@ -17,8 +17,13 @@ const DEFAULT_SELECT_DB = SelectableDatabase.DEFAULT
|
||||||
// for testing just generate the client once
|
// for testing just generate the client once
|
||||||
let CLOSED = false
|
let CLOSED = false
|
||||||
let CLIENTS: { [key: number]: any } = {}
|
let CLIENTS: { [key: number]: any } = {}
|
||||||
// if in test always connected
|
|
||||||
let CONNECTED = env.isTest()
|
let CONNECTED = false
|
||||||
|
|
||||||
|
// mock redis always connected
|
||||||
|
if (env.MOCK_REDIS) {
|
||||||
|
CONNECTED = true
|
||||||
|
}
|
||||||
|
|
||||||
function pickClient(selectDb: number): any {
|
function pickClient(selectDb: number): any {
|
||||||
return CLIENTS[selectDb]
|
return CLIENTS[selectDb]
|
||||||
|
@ -57,7 +62,7 @@ function init(selectDb = DEFAULT_SELECT_DB) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// testing uses a single in memory client
|
// testing uses a single in memory client
|
||||||
if (env.isTest()) {
|
if (env.MOCK_REDIS) {
|
||||||
CLIENTS[selectDb] = new Redis(getRedisOptions())
|
CLIENTS[selectDb] = new Redis(getRedisOptions())
|
||||||
}
|
}
|
||||||
// start the timer - only allowed 5 seconds to connect
|
// start the timer - only allowed 5 seconds to connect
|
||||||
|
@ -86,6 +91,11 @@ function init(selectDb = DEFAULT_SELECT_DB) {
|
||||||
}
|
}
|
||||||
// attach handlers
|
// attach handlers
|
||||||
client.on("end", (err: Error) => {
|
client.on("end", (err: Error) => {
|
||||||
|
if (env.isTest()) {
|
||||||
|
// don't try to re-connect in test env
|
||||||
|
// allow the process to exit
|
||||||
|
return
|
||||||
|
}
|
||||||
connectionError(selectDb, timeout, err)
|
connectionError(selectDb, timeout, err)
|
||||||
})
|
})
|
||||||
client.on("error", (err: Error) => {
|
client.on("error", (err: Error) => {
|
||||||
|
|
|
@ -1,29 +1,22 @@
|
||||||
import Redlock, { Options } from "redlock"
|
import Redlock, { Options } from "redlock"
|
||||||
import { getLockClient } from "./init"
|
import { getLockClient } from "./init"
|
||||||
import { LockOptions, LockType } from "@budibase/types"
|
import { LockOptions, LockType } from "@budibase/types"
|
||||||
import * as tenancy from "../tenancy"
|
import * as context from "../context"
|
||||||
|
import env from "../environment"
|
||||||
let noRetryRedlock: Redlock | undefined
|
|
||||||
|
|
||||||
const getClient = async (type: LockType): Promise<Redlock> => {
|
const getClient = async (type: LockType): Promise<Redlock> => {
|
||||||
|
if (env.isTest() && type !== LockType.TRY_ONCE) {
|
||||||
|
return newRedlock(OPTIONS.TEST)
|
||||||
|
}
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case LockType.TRY_ONCE: {
|
case LockType.TRY_ONCE: {
|
||||||
if (!noRetryRedlock) {
|
return newRedlock(OPTIONS.TRY_ONCE)
|
||||||
noRetryRedlock = await newRedlock(OPTIONS.TRY_ONCE)
|
|
||||||
}
|
|
||||||
return noRetryRedlock
|
|
||||||
}
|
}
|
||||||
case LockType.DEFAULT: {
|
case LockType.DEFAULT: {
|
||||||
if (!noRetryRedlock) {
|
return newRedlock(OPTIONS.DEFAULT)
|
||||||
noRetryRedlock = await newRedlock(OPTIONS.DEFAULT)
|
|
||||||
}
|
|
||||||
return noRetryRedlock
|
|
||||||
}
|
}
|
||||||
case LockType.DELAY_500: {
|
case LockType.DELAY_500: {
|
||||||
if (!noRetryRedlock) {
|
return newRedlock(OPTIONS.DELAY_500)
|
||||||
noRetryRedlock = await newRedlock(OPTIONS.DELAY_500)
|
|
||||||
}
|
|
||||||
return noRetryRedlock
|
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new Error(`Could not get redlock client: ${type}`)
|
throw new Error(`Could not get redlock client: ${type}`)
|
||||||
|
@ -36,6 +29,11 @@ export const OPTIONS = {
|
||||||
// immediately throws an error if the lock is already held
|
// immediately throws an error if the lock is already held
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
},
|
},
|
||||||
|
TEST: {
|
||||||
|
// higher retry count in unit tests
|
||||||
|
// due to high contention.
|
||||||
|
retryCount: 100,
|
||||||
|
},
|
||||||
DEFAULT: {
|
DEFAULT: {
|
||||||
// the expected clock drift; for more details
|
// the expected clock drift; for more details
|
||||||
// see http://redis.io/topics/distlock
|
// see http://redis.io/topics/distlock
|
||||||
|
@ -69,28 +67,38 @@ export const doWithLock = async (opts: LockOptions, task: any) => {
|
||||||
const redlock = await getClient(opts.type)
|
const redlock = await getClient(opts.type)
|
||||||
let lock
|
let lock
|
||||||
try {
|
try {
|
||||||
// aquire lock
|
// determine lock name
|
||||||
let name: string = `lock:${tenancy.getTenantId()}_${opts.name}`
|
// by default use the tenantId for uniqueness, unless using a system lock
|
||||||
|
const prefix = opts.systemLock ? "system" : context.getTenantId()
|
||||||
|
let name: string = `lock:${prefix}_${opts.name}`
|
||||||
|
|
||||||
|
// add additional unique name if required
|
||||||
if (opts.nameSuffix) {
|
if (opts.nameSuffix) {
|
||||||
name = name + `_${opts.nameSuffix}`
|
name = name + `_${opts.nameSuffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// create the lock
|
||||||
lock = await redlock.lock(name, opts.ttl)
|
lock = await redlock.lock(name, opts.ttl)
|
||||||
|
|
||||||
// perform locked task
|
// perform locked task
|
||||||
// need to await to ensure completion before unlocking
|
// need to await to ensure completion before unlocking
|
||||||
const result = await task()
|
const result = await task()
|
||||||
return result
|
return result
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.log("lock error")
|
console.warn("lock error")
|
||||||
// lock limit exceeded
|
// lock limit exceeded
|
||||||
if (e.name === "LockError") {
|
if (e.name === "LockError") {
|
||||||
if (opts.type === LockType.TRY_ONCE) {
|
if (opts.type === LockType.TRY_ONCE) {
|
||||||
// don't throw for try-once locks, they will always error
|
// don't throw for try-once locks, they will always error
|
||||||
// due to retry count (0) exceeded
|
// due to retry count (0) exceeded
|
||||||
|
console.warn(e)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
|
console.error(e)
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
console.error(e)
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
@ -2,8 +2,6 @@ import env from "../environment"
|
||||||
|
|
||||||
const SLOT_REFRESH_MS = 2000
|
const SLOT_REFRESH_MS = 2000
|
||||||
const CONNECT_TIMEOUT_MS = 10000
|
const CONNECT_TIMEOUT_MS = 10000
|
||||||
const REDIS_URL = !env.REDIS_URL ? "localhost:6379" : env.REDIS_URL
|
|
||||||
const REDIS_PASSWORD = !env.REDIS_PASSWORD ? "budibase" : env.REDIS_PASSWORD
|
|
||||||
export const SEPARATOR = "-"
|
export const SEPARATOR = "-"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -60,8 +58,8 @@ export enum SelectableDatabase {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRedisOptions(clustered = false) {
|
export function getRedisOptions(clustered = false) {
|
||||||
let password = REDIS_PASSWORD
|
let password = env.REDIS_PASSWORD
|
||||||
let url: string[] | string = REDIS_URL.split("//")
|
let url: string[] | string = env.REDIS_URL.split("//")
|
||||||
// get rid of the protocol
|
// get rid of the protocol
|
||||||
url = url.length > 1 ? url[1] : url[0]
|
url = url.length > 1 ? url[1] : url[0]
|
||||||
// check for a password etc
|
// check for a password etc
|
||||||
|
@ -78,8 +76,8 @@ export function getRedisOptions(clustered = false) {
|
||||||
let redisProtocolUrl
|
let redisProtocolUrl
|
||||||
|
|
||||||
// fully qualified redis URL
|
// fully qualified redis URL
|
||||||
if (/rediss?:\/\//.test(REDIS_URL)) {
|
if (/rediss?:\/\//.test(env.REDIS_URL)) {
|
||||||
redisProtocolUrl = REDIS_URL
|
redisProtocolUrl = env.REDIS_URL
|
||||||
}
|
}
|
||||||
|
|
||||||
const opts: any = {
|
const opts: any = {
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { getDB } from "../db/db"
|
||||||
|
import { getGlobalDBName } from "../context"
|
||||||
|
|
||||||
|
export function getTenantDB(tenantId: string) {
|
||||||
|
return getDB(getGlobalDBName(tenantId))
|
||||||
|
}
|
|
@ -1,2 +1,2 @@
|
||||||
export * from "../context"
|
export * from "./db"
|
||||||
export * from "./tenancy"
|
export * from "./tenancy"
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { doWithDB, getGlobalDBName } from "../db"
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_TENANT_ID,
|
DEFAULT_TENANT_ID,
|
||||||
getTenantId,
|
getTenantId,
|
||||||
|
@ -11,10 +10,7 @@ import {
|
||||||
TenantResolutionStrategy,
|
TenantResolutionStrategy,
|
||||||
GetTenantIdOptions,
|
GetTenantIdOptions,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { Header, StaticDatabases } from "../constants"
|
import { Header } from "../constants"
|
||||||
|
|
||||||
const TENANT_DOC = StaticDatabases.PLATFORM_INFO.docs.tenants
|
|
||||||
const PLATFORM_INFO_DB = StaticDatabases.PLATFORM_INFO.name
|
|
||||||
|
|
||||||
export function addTenantToUrl(url: string) {
|
export function addTenantToUrl(url: string) {
|
||||||
const tenantId = getTenantId()
|
const tenantId = getTenantId()
|
||||||
|
@ -27,89 +23,6 @@ export function addTenantToUrl(url: string) {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function doesTenantExist(tenantId: string) {
|
|
||||||
return doWithDB(PLATFORM_INFO_DB, async (db: any) => {
|
|
||||||
let tenants
|
|
||||||
try {
|
|
||||||
tenants = await db.get(TENANT_DOC)
|
|
||||||
} catch (err) {
|
|
||||||
// if theres an error the doc doesn't exist, no tenants exist
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
tenants &&
|
|
||||||
Array.isArray(tenants.tenantIds) &&
|
|
||||||
tenants.tenantIds.indexOf(tenantId) !== -1
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function tryAddTenant(
|
|
||||||
tenantId: string,
|
|
||||||
userId: string,
|
|
||||||
email: string,
|
|
||||||
afterCreateTenant: () => Promise<void>
|
|
||||||
) {
|
|
||||||
return doWithDB(PLATFORM_INFO_DB, async (db: any) => {
|
|
||||||
const getDoc = async (id: string) => {
|
|
||||||
if (!id) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await db.get(id)
|
|
||||||
} catch (err) {
|
|
||||||
return { _id: id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let [tenants, userIdDoc, emailDoc] = await Promise.all([
|
|
||||||
getDoc(TENANT_DOC),
|
|
||||||
getDoc(userId),
|
|
||||||
getDoc(email),
|
|
||||||
])
|
|
||||||
if (!Array.isArray(tenants.tenantIds)) {
|
|
||||||
tenants = {
|
|
||||||
_id: TENANT_DOC,
|
|
||||||
tenantIds: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let promises = []
|
|
||||||
if (userIdDoc) {
|
|
||||||
userIdDoc.tenantId = tenantId
|
|
||||||
promises.push(db.put(userIdDoc))
|
|
||||||
}
|
|
||||||
if (emailDoc) {
|
|
||||||
emailDoc.tenantId = tenantId
|
|
||||||
emailDoc.userId = userId
|
|
||||||
promises.push(db.put(emailDoc))
|
|
||||||
}
|
|
||||||
if (tenants.tenantIds.indexOf(tenantId) === -1) {
|
|
||||||
tenants.tenantIds.push(tenantId)
|
|
||||||
promises.push(db.put(tenants))
|
|
||||||
await afterCreateTenant()
|
|
||||||
}
|
|
||||||
await Promise.all(promises)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function doWithGlobalDB(tenantId: string, cb: any) {
|
|
||||||
return doWithDB(getGlobalDBName(tenantId), cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function lookupTenantId(userId: string) {
|
|
||||||
return doWithDB(StaticDatabases.PLATFORM_INFO.name, async (db: any) => {
|
|
||||||
let tenantId = env.MULTI_TENANCY ? DEFAULT_TENANT_ID : null
|
|
||||||
try {
|
|
||||||
const doc = await db.get(userId)
|
|
||||||
if (doc && doc.tenantId) {
|
|
||||||
tenantId = doc.tenantId
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// just return the default
|
|
||||||
}
|
|
||||||
return tenantId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isUserInAppTenant = (appId: string, user?: any) => {
|
export const isUserInAppTenant = (appId: string, user?: any) => {
|
||||||
let userTenantId
|
let userTenantId
|
||||||
if (user) {
|
if (user) {
|
||||||
|
@ -121,19 +34,6 @@ export const isUserInAppTenant = (appId: string, user?: any) => {
|
||||||
return tenantId === userTenantId
|
return tenantId === userTenantId
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTenantIds() {
|
|
||||||
return doWithDB(PLATFORM_INFO_DB, async (db: any) => {
|
|
||||||
let tenants
|
|
||||||
try {
|
|
||||||
tenants = await db.get(TENANT_DOC)
|
|
||||||
} catch (err) {
|
|
||||||
// if theres an error the doc doesn't exist, no tenants exist
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return (tenants && tenants.tenantIds) || []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALL_STRATEGIES = Object.values(TenantResolutionStrategy)
|
const ALL_STRATEGIES = Object.values(TenantResolutionStrategy)
|
||||||
|
|
||||||
export const getTenantIDFromCtx = (
|
export const getTenantIDFromCtx = (
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} from "./db"
|
} from "./db"
|
||||||
import { BulkDocsResponse, User } from "@budibase/types"
|
import { BulkDocsResponse, User } from "@budibase/types"
|
||||||
import { getGlobalDB } from "./context"
|
import { getGlobalDB } from "./context"
|
||||||
|
import * as context from "./context"
|
||||||
|
|
||||||
export const bulkGetGlobalUsersById = async (userIds: string[]) => {
|
export const bulkGetGlobalUsersById = async (userIds: string[]) => {
|
||||||
const db = getGlobalDB()
|
const db = getGlobalDB()
|
||||||
|
@ -24,6 +25,11 @@ export const bulkUpdateGlobalUsers = async (users: User[]) => {
|
||||||
return (await db.bulkDocs(users)) as BulkDocsResponse
|
return (await db.bulkDocs(users)) as BulkDocsResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getById(id: string): Promise<User> {
|
||||||
|
const db = context.getGlobalDB()
|
||||||
|
return db.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given an email address this will use a view to search through
|
* Given an email address this will use a view to search through
|
||||||
* all the users to find one with this email address.
|
* all the users to find one with this email address.
|
||||||
|
|
|
@ -1,21 +1,12 @@
|
||||||
import { structures } from "../../../tests"
|
import { structures, DBTestConfiguration } from "../../../tests"
|
||||||
import * as utils from "../../utils"
|
import * as utils from "../../utils"
|
||||||
import * as events from "../../events"
|
|
||||||
import * as db from "../../db"
|
import * as db from "../../db"
|
||||||
import { Header } from "../../constants"
|
import { Header } from "../../constants"
|
||||||
import { doInTenant } from "../../context"
|
|
||||||
import { newid } from "../../utils"
|
import { newid } from "../../utils"
|
||||||
|
import env from "../../environment"
|
||||||
|
|
||||||
describe("utils", () => {
|
describe("utils", () => {
|
||||||
describe("platformLogout", () => {
|
const config = new DBTestConfiguration()
|
||||||
it("should call platform logout", async () => {
|
|
||||||
await doInTenant(structures.tenant.id(), async () => {
|
|
||||||
const ctx = structures.koa.newContext()
|
|
||||||
await utils.platformLogout({ ctx, userId: "test" })
|
|
||||||
expect(events.auth.logout).toBeCalledTimes(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("getAppIdFromCtx", () => {
|
describe("getAppIdFromCtx", () => {
|
||||||
it("gets appId from header", async () => {
|
it("gets appId from header", async () => {
|
||||||
|
@ -50,8 +41,14 @@ describe("utils", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("gets appId from url", async () => {
|
it("gets appId from url", async () => {
|
||||||
|
await config.doInTenant(async () => {
|
||||||
|
const url = "http://test.com"
|
||||||
|
env._set("PLATFORM_URL", url)
|
||||||
|
|
||||||
const ctx = structures.koa.newContext()
|
const ctx = structures.koa.newContext()
|
||||||
const expected = db.generateAppID()
|
ctx.host = `${config.tenantId}.test.com`
|
||||||
|
|
||||||
|
const expected = db.generateAppID(config.tenantId)
|
||||||
const app = structures.apps.app(expected)
|
const app = structures.apps.app(expected)
|
||||||
|
|
||||||
// set custom url
|
// set custom url
|
||||||
|
@ -66,6 +63,7 @@ describe("utils", () => {
|
||||||
const actual = await utils.getAppIdFromCtx(ctx)
|
const actual = await utils.getAppIdFromCtx(ctx)
|
||||||
expect(actual).toBe(expected)
|
expect(actual).toBe(expected)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it("doesn't get appId from url when previewing", async () => {
|
it("doesn't get appId from url when previewing", async () => {
|
||||||
const ctx = structures.koa.newContext()
|
const ctx = structures.koa.newContext()
|
||||||
|
|
|
@ -2,23 +2,15 @@ import { getAllApps, queryGlobalView } from "../db"
|
||||||
import { options } from "../middleware/passport/jwt"
|
import { options } from "../middleware/passport/jwt"
|
||||||
import {
|
import {
|
||||||
Header,
|
Header,
|
||||||
Cookie,
|
|
||||||
MAX_VALID_DATE,
|
MAX_VALID_DATE,
|
||||||
DocumentType,
|
DocumentType,
|
||||||
SEPARATOR,
|
SEPARATOR,
|
||||||
ViewName,
|
ViewName,
|
||||||
} from "../constants"
|
} from "../constants"
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
import * as userCache from "../cache/user"
|
|
||||||
import { getSessionsForUser, invalidateSessions } from "../security/sessions"
|
|
||||||
import * as events from "../events"
|
|
||||||
import * as tenancy from "../tenancy"
|
import * as tenancy from "../tenancy"
|
||||||
import {
|
import * as context from "../context"
|
||||||
App,
|
import { App, Ctx, TenantResolutionStrategy } from "@budibase/types"
|
||||||
Ctx,
|
|
||||||
PlatformLogoutOpts,
|
|
||||||
TenantResolutionStrategy,
|
|
||||||
} from "@budibase/types"
|
|
||||||
import { SetOption } from "cookies"
|
import { SetOption } from "cookies"
|
||||||
const jwt = require("jsonwebtoken")
|
const jwt = require("jsonwebtoken")
|
||||||
|
|
||||||
|
@ -38,7 +30,7 @@ export async function resolveAppUrl(ctx: Ctx) {
|
||||||
const appUrl = ctx.path.split("/")[2]
|
const appUrl = ctx.path.split("/")[2]
|
||||||
let possibleAppUrl = `/${appUrl.toLowerCase()}`
|
let possibleAppUrl = `/${appUrl.toLowerCase()}`
|
||||||
|
|
||||||
let tenantId: string | null = tenancy.getTenantId()
|
let tenantId: string | null = context.getTenantId()
|
||||||
if (env.MULTI_TENANCY) {
|
if (env.MULTI_TENANCY) {
|
||||||
// always use the tenant id from the subdomain in multi tenancy
|
// always use the tenant id from the subdomain in multi tenancy
|
||||||
// this ensures the logged-in user tenant id doesn't overwrite
|
// this ensures the logged-in user tenant id doesn't overwrite
|
||||||
|
@ -49,7 +41,7 @@ export async function resolveAppUrl(ctx: Ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// search prod apps for a url that matches
|
// search prod apps for a url that matches
|
||||||
const apps: App[] = await tenancy.doInTenant(tenantId, () =>
|
const apps: App[] = await context.doInTenant(tenantId, () =>
|
||||||
getAllApps({ dev: false })
|
getAllApps({ dev: false })
|
||||||
)
|
)
|
||||||
const app = apps.filter(
|
const app = apps.filter(
|
||||||
|
@ -222,35 +214,6 @@ export async function getBuildersCount() {
|
||||||
return builders.length
|
return builders.length
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs a user out from budibase. Re-used across account portal and builder.
|
|
||||||
*/
|
|
||||||
export async function platformLogout(opts: PlatformLogoutOpts) {
|
|
||||||
const ctx = opts.ctx
|
|
||||||
const userId = opts.userId
|
|
||||||
const keepActiveSession = opts.keepActiveSession
|
|
||||||
|
|
||||||
if (!ctx) throw new Error("Koa context must be supplied to logout.")
|
|
||||||
|
|
||||||
const currentSession = getCookie(ctx, Cookie.Auth)
|
|
||||||
let sessions = await getSessionsForUser(userId)
|
|
||||||
|
|
||||||
if (keepActiveSession) {
|
|
||||||
sessions = sessions.filter(
|
|
||||||
session => session.sessionId !== currentSession.sessionId
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// clear cookies
|
|
||||||
clearCookie(ctx, Cookie.Auth)
|
|
||||||
clearCookie(ctx, Cookie.CurrentApp)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionIds = sessions.map(({ sessionId }) => sessionId)
|
|
||||||
await invalidateSessions(userId, { sessionIds, reason: "logout" })
|
|
||||||
await events.auth.logout()
|
|
||||||
await userCache.invalidateUser(userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function timeout(timeMs: number) {
|
export function timeout(timeMs: number) {
|
||||||
return new Promise(resolve => setTimeout(resolve, timeMs))
|
return new Promise(resolve => setTimeout(resolve, timeMs))
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +1,6 @@
|
||||||
import env from "../src/environment"
|
process.env.SELF_HOSTED = "1"
|
||||||
import { mocks } from "./utilities"
|
process.env.MULTI_TENANCY = "1"
|
||||||
|
process.env.NODE_ENV = "jest"
|
||||||
// must explicitly enable fetch mock
|
process.env.MOCK_REDIS = "1"
|
||||||
mocks.fetch.enable()
|
process.env.LOG_LEVEL = process.env.LOG_LEVEL || "error"
|
||||||
|
process.env.ENABLE_4XX_HTTP_LOGGING = "0"
|
||||||
// mock all dates to 2020-01-01T00:00:00.000Z
|
|
||||||
// use tk.reset() to use real dates in individual tests
|
|
||||||
import tk from "timekeeper"
|
|
||||||
tk.freeze(mocks.date.MOCK_DATE)
|
|
||||||
|
|
||||||
env._set("SELF_HOSTED", "1")
|
|
||||||
env._set("NODE_ENV", "jest")
|
|
||||||
|
|
||||||
if (!process.env.DEBUG) {
|
|
||||||
global.console.log = jest.fn() // console.log are ignored in tests
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.CI) {
|
|
||||||
// set a longer timeout in dev for debugging
|
|
||||||
// 100 seconds
|
|
||||||
jest.setTimeout(100000)
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,4 +1,23 @@
|
||||||
|
import "./logging"
|
||||||
import env from "../src/environment"
|
import env from "../src/environment"
|
||||||
import { testContainerUtils } from "./utilities"
|
import { mocks, testContainerUtils } from "./utilities"
|
||||||
|
|
||||||
|
// must explicitly enable fetch mock
|
||||||
|
mocks.fetch.enable()
|
||||||
|
|
||||||
|
// mock all dates to 2020-01-01T00:00:00.000Z
|
||||||
|
// use tk.reset() to use real dates in individual tests
|
||||||
|
import tk from "timekeeper"
|
||||||
|
tk.freeze(mocks.date.MOCK_DATE)
|
||||||
|
|
||||||
|
if (!process.env.DEBUG) {
|
||||||
|
console.log = jest.fn() // console.log are ignored in tests
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.CI) {
|
||||||
|
// set a longer timeout in dev for debugging
|
||||||
|
// 100 seconds
|
||||||
|
jest.setTimeout(100000)
|
||||||
|
}
|
||||||
|
|
||||||
testContainerUtils.setupEnv(env)
|
testContainerUtils.setupEnv(env)
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
export enum LogLevel {
|
||||||
|
TRACE = "trace",
|
||||||
|
DEBUG = "debug",
|
||||||
|
INFO = "info",
|
||||||
|
WARN = "warn",
|
||||||
|
ERROR = "error",
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_INDEX: { [key in LogLevel]: number } = {
|
||||||
|
[LogLevel.TRACE]: 1,
|
||||||
|
[LogLevel.DEBUG]: 2,
|
||||||
|
[LogLevel.INFO]: 3,
|
||||||
|
[LogLevel.WARN]: 4,
|
||||||
|
[LogLevel.ERROR]: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
const setIndex = LOG_INDEX[process.env.LOG_LEVEL as LogLevel]
|
||||||
|
|
||||||
|
if (setIndex > LOG_INDEX.trace) {
|
||||||
|
global.console.trace = jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setIndex > LOG_INDEX.debug) {
|
||||||
|
global.console.debug = jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setIndex > LOG_INDEX.info) {
|
||||||
|
global.console.info = jest.fn()
|
||||||
|
global.console.log = jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setIndex > LOG_INDEX.warn) {
|
||||||
|
global.console.warn = jest.fn()
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
import "./mocks"
|
||||||
|
import * as structures from "./structures"
|
||||||
|
import * as testEnv from "./testEnv"
|
||||||
|
import * as context from "../../src/context"
|
||||||
|
|
||||||
|
class DBTestConfiguration {
|
||||||
|
tenantId: string
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// db tests need to be multi tenant to prevent conflicts
|
||||||
|
testEnv.multiTenant()
|
||||||
|
this.tenantId = structures.tenant.id()
|
||||||
|
}
|
||||||
|
|
||||||
|
newTenant() {
|
||||||
|
this.tenantId = structures.tenant.id()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TENANCY
|
||||||
|
|
||||||
|
doInTenant(task: any) {
|
||||||
|
return context.doInTenant(this.tenantId, () => {
|
||||||
|
return task()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getTenantId() {
|
||||||
|
try {
|
||||||
|
return context.getTenantId()
|
||||||
|
} catch (e) {
|
||||||
|
return this.tenantId!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DBTestConfiguration
|
|
@ -1,9 +0,0 @@
|
||||||
import * as db from "../../src/db"
|
|
||||||
|
|
||||||
const dbConfig = {
|
|
||||||
inMemory: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const init = () => {
|
|
||||||
db.init(dbConfig)
|
|
||||||
}
|
|
|
@ -4,5 +4,4 @@ export { generator } from "./structures"
|
||||||
export * as testEnv from "./testEnv"
|
export * as testEnv from "./testEnv"
|
||||||
export * as testContainerUtils from "./testContainerUtils"
|
export * as testContainerUtils from "./testContainerUtils"
|
||||||
|
|
||||||
import * as dbConfig from "./db"
|
export { default as DBTestConfiguration } from "./DBTestConfiguration"
|
||||||
dbConfig.init()
|
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
const mockGetAccount = jest.fn()
|
|
||||||
const mockGetAccountByTenantId = jest.fn()
|
|
||||||
const mockGetStatus = jest.fn()
|
|
||||||
|
|
||||||
jest.mock("../../../src/cloud/accounts", () => ({
|
|
||||||
getAccount: mockGetAccount,
|
|
||||||
getAccountByTenantId: mockGetAccountByTenantId,
|
|
||||||
getStatus: mockGetStatus,
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const getAccount = mockGetAccount
|
|
||||||
export const getAccountByTenantId = mockGetAccountByTenantId
|
|
||||||
export const getStatus = mockGetStatus
|
|
|
@ -1,4 +1,7 @@
|
||||||
export * as accounts from "./accounts"
|
jest.mock("../../../src/accounts")
|
||||||
|
import * as _accounts from "../../../src/accounts"
|
||||||
|
export const accounts = jest.mocked(_accounts)
|
||||||
|
|
||||||
export * as date from "./date"
|
export * as date from "./date"
|
||||||
export * as licenses from "./licenses"
|
export * as licenses from "./licenses"
|
||||||
export { default as fetch } from "./fetch"
|
export { default as fetch } from "./fetch"
|
||||||
|
|
|
@ -1,6 +1,15 @@
|
||||||
import { generator, uuid } from "."
|
import { generator, uuid } from "."
|
||||||
import * as db from "../../../src/db/utils"
|
import * as db from "../../../src/db/utils"
|
||||||
import { Account, AuthType, CloudAccount, Hosting } from "@budibase/types"
|
import {
|
||||||
|
Account,
|
||||||
|
AccountSSOProvider,
|
||||||
|
AccountSSOProviderType,
|
||||||
|
AuthType,
|
||||||
|
CloudAccount,
|
||||||
|
Hosting,
|
||||||
|
SSOAccount,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import _ from "lodash"
|
||||||
|
|
||||||
export const account = (): Account => {
|
export const account = (): Account => {
|
||||||
return {
|
return {
|
||||||
|
@ -27,3 +36,28 @@ export const cloudAccount = (): CloudAccount => {
|
||||||
budibaseUserId: db.generateGlobalUserID(),
|
budibaseUserId: db.generateGlobalUserID(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function providerType(): AccountSSOProviderType {
|
||||||
|
return _.sample(
|
||||||
|
Object.values(AccountSSOProviderType)
|
||||||
|
) as AccountSSOProviderType
|
||||||
|
}
|
||||||
|
|
||||||
|
function provider(): AccountSSOProvider {
|
||||||
|
return _.sample(Object.values(AccountSSOProvider)) as AccountSSOProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ssoAccount(): SSOAccount {
|
||||||
|
return {
|
||||||
|
...cloudAccount(),
|
||||||
|
authType: AuthType.SSO,
|
||||||
|
oauth2: {
|
||||||
|
accessToken: generator.string(),
|
||||||
|
refreshToken: generator.string(),
|
||||||
|
},
|
||||||
|
pictureUrl: generator.url(),
|
||||||
|
provider: provider(),
|
||||||
|
providerType: providerType(),
|
||||||
|
thirdPartyProfile: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -5,8 +5,10 @@ export const generator = new Chance()
|
||||||
|
|
||||||
export * as accounts from "./accounts"
|
export * as accounts from "./accounts"
|
||||||
export * as apps from "./apps"
|
export * as apps from "./apps"
|
||||||
|
export * as db from "./db"
|
||||||
export * as koa from "./koa"
|
export * as koa from "./koa"
|
||||||
export * as licenses from "./licenses"
|
export * as licenses from "./licenses"
|
||||||
export * as plugins from "./plugins"
|
export * as plugins from "./plugins"
|
||||||
|
export * as sso from "./sso"
|
||||||
export * as tenant from "./tenants"
|
export * as tenant from "./tenants"
|
||||||
export * as db from "./db"
|
export * as users from "./users"
|
||||||
|
|
|
@ -0,0 +1,100 @@
|
||||||
|
import {
|
||||||
|
GoogleInnerConfig,
|
||||||
|
JwtClaims,
|
||||||
|
OIDCInnerConfig,
|
||||||
|
OIDCWellKnownConfig,
|
||||||
|
SSOAuthDetails,
|
||||||
|
SSOProfile,
|
||||||
|
SSOProviderType,
|
||||||
|
User,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import { uuid, generator, users, email } from "./index"
|
||||||
|
import _ from "lodash"
|
||||||
|
|
||||||
|
export function providerType(): SSOProviderType {
|
||||||
|
return _.sample(Object.values(SSOProviderType)) as SSOProviderType
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ssoProfile(user?: User): SSOProfile {
|
||||||
|
if (!user) {
|
||||||
|
user = users.user()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: user._id!,
|
||||||
|
name: {
|
||||||
|
givenName: user.firstName,
|
||||||
|
familyName: user.lastName,
|
||||||
|
},
|
||||||
|
_json: {
|
||||||
|
email: user.email,
|
||||||
|
picture: "http://test.com",
|
||||||
|
},
|
||||||
|
provider: generator.string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authDetails(user?: User): SSOAuthDetails {
|
||||||
|
if (!user) {
|
||||||
|
user = users.user()
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = user._id || uuid()
|
||||||
|
const provider = generator.string()
|
||||||
|
|
||||||
|
const profile = ssoProfile(user)
|
||||||
|
profile.provider = provider
|
||||||
|
profile.id = userId
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: user.email,
|
||||||
|
oauth2: {
|
||||||
|
refreshToken: generator.string(),
|
||||||
|
accessToken: generator.string(),
|
||||||
|
},
|
||||||
|
profile,
|
||||||
|
provider,
|
||||||
|
providerType: providerType(),
|
||||||
|
userId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDC
|
||||||
|
|
||||||
|
export function oidcConfig(): OIDCInnerConfig {
|
||||||
|
return {
|
||||||
|
uuid: uuid(),
|
||||||
|
activated: true,
|
||||||
|
logo: "",
|
||||||
|
name: generator.string(),
|
||||||
|
configUrl: "http://someconfigurl",
|
||||||
|
clientID: generator.string(),
|
||||||
|
clientSecret: generator.string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// response from .well-known/openid-configuration
|
||||||
|
export function oidcWellKnownConfig(): OIDCWellKnownConfig {
|
||||||
|
return {
|
||||||
|
issuer: generator.string(),
|
||||||
|
authorization_endpoint: generator.url(),
|
||||||
|
token_endpoint: generator.url(),
|
||||||
|
userinfo_endpoint: generator.url(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jwtClaims(): JwtClaims {
|
||||||
|
return {
|
||||||
|
email: email(),
|
||||||
|
preferred_username: email(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOGLE
|
||||||
|
|
||||||
|
export function googleConfig(): GoogleInnerConfig {
|
||||||
|
return {
|
||||||
|
activated: true,
|
||||||
|
clientID: generator.string(),
|
||||||
|
clientSecret: generator.string(),
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { generator } from "../"
|
||||||
|
import {
|
||||||
|
AdminUser,
|
||||||
|
BuilderUser,
|
||||||
|
SSOAuthDetails,
|
||||||
|
SSOUser,
|
||||||
|
User,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import { v4 as uuid } from "uuid"
|
||||||
|
import * as sso from "./sso"
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminUser = (userProps?: any): AdminUser => {
|
||||||
|
return {
|
||||||
|
...user(userProps),
|
||||||
|
admin: {
|
||||||
|
global: true,
|
||||||
|
},
|
||||||
|
builder: {
|
||||||
|
global: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const builderUser = (userProps?: any): BuilderUser => {
|
||||||
|
return {
|
||||||
|
...user(userProps),
|
||||||
|
builder: {
|
||||||
|
global: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ssoUser(
|
||||||
|
opts: { user?: any; details?: SSOAuthDetails } = {}
|
||||||
|
): SSOUser {
|
||||||
|
const base = user(opts.user)
|
||||||
|
delete base.password
|
||||||
|
|
||||||
|
if (!opts.details) {
|
||||||
|
opts.details = sso.authDetails(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
forceResetPassword: false,
|
||||||
|
oauth2: opts.details?.oauth2,
|
||||||
|
provider: opts.details?.provider!,
|
||||||
|
providerType: opts.details?.providerType!,
|
||||||
|
thirdPartyProfile: {
|
||||||
|
email: base.email,
|
||||||
|
picture: base.pictureUrl,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -34,12 +34,17 @@ function getMinioConfig() {
|
||||||
return getContainerInfo("minio-service", 9000)
|
return getContainerInfo("minio-service", 9000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRedisConfig() {
|
||||||
|
return getContainerInfo("redis-service", 6379)
|
||||||
|
}
|
||||||
|
|
||||||
export function setupEnv(...envs: any[]) {
|
export function setupEnv(...envs: any[]) {
|
||||||
const configs = [
|
const configs = [
|
||||||
{ key: "COUCH_DB_PORT", value: getCouchConfig().port },
|
{ key: "COUCH_DB_PORT", value: getCouchConfig().port },
|
||||||
{ key: "COUCH_DB_URL", value: getCouchConfig().url },
|
{ key: "COUCH_DB_URL", value: getCouchConfig().url },
|
||||||
{ key: "MINIO_PORT", value: getMinioConfig().port },
|
{ key: "MINIO_PORT", value: getMinioConfig().port },
|
||||||
{ key: "MINIO_URL", value: getMinioConfig().url },
|
{ key: "MINIO_URL", value: getMinioConfig().url },
|
||||||
|
{ key: "REDIS_URL", value: getRedisConfig().url },
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const config of configs.filter(x => !!x.value)) {
|
for (const config of configs.filter(x => !!x.value)) {
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import env from "../../src/environment"
|
import env from "../../src/environment"
|
||||||
import * as tenancy from "../../src/tenancy"
|
import * as context from "../../src/context"
|
||||||
import { newid } from "../../src/utils"
|
import * as structures from "./structures"
|
||||||
|
|
||||||
// TENANCY
|
// TENANCY
|
||||||
|
|
||||||
export async function withTenant(task: (tenantId: string) => any) {
|
export async function withTenant(task: (tenantId: string) => any) {
|
||||||
const tenantId = newid()
|
const tenantId = structures.tenant.id()
|
||||||
return tenancy.doInTenant(tenantId, async () => {
|
return context.doInTenant(tenantId, async () => {
|
||||||
await task(tenantId)
|
await task(tenantId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,14 @@ export function multiTenant() {
|
||||||
env._set("MULTI_TENANCY", 1)
|
env._set("MULTI_TENANCY", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selfHosted() {
|
||||||
|
env._set("SELF_HOSTED", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloudHosted() {
|
||||||
|
env._set("SELF_HOSTED", 0)
|
||||||
|
}
|
||||||
|
|
||||||
// NODE
|
// NODE
|
||||||
|
|
||||||
export function nodeDev() {
|
export function nodeDev() {
|
||||||
|
|
|
@ -1197,10 +1197,10 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/istanbul-lib-report" "*"
|
"@types/istanbul-lib-report" "*"
|
||||||
|
|
||||||
"@types/jest@27.5.1":
|
"@types/jest@28.1.1":
|
||||||
version "27.5.1"
|
version "28.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.5.1.tgz#2c8b6dc6ff85c33bcd07d0b62cb3d19ddfdb3ab9"
|
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-28.1.1.tgz#8c9ba63702a11f8c386ee211280e8b68cb093cd1"
|
||||||
integrity sha512-fUy7YRpT+rHXto1YlL+J9rs0uLGyiqVt3ZOTQR+4ROc47yNl8WLdVLgUloBRhOxP1PZvguHl44T3H0wAWxahYQ==
|
integrity sha512-C2p7yqleUKtCkVjlOur9BWVA4HgUQmEj/HWCt5WzZ5mLXrWnyIfl0wGuArc+kBXsy0ZZfLp+7dywB4HtSVYGVA==
|
||||||
dependencies:
|
dependencies:
|
||||||
jest-matcher-utils "^27.0.0"
|
jest-matcher-utils "^27.0.0"
|
||||||
pretty-format "^27.0.0"
|
pretty-format "^27.0.0"
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@budibase/bbui",
|
"name": "@budibase/bbui",
|
||||||
"description": "A UI solution used in the different Budibase projects.",
|
"description": "A UI solution used in the different Budibase projects.",
|
||||||
"version": "2.3.17-alpha.4",
|
"version": "2.3.18-alpha.8",
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"svelte": "src/index.js",
|
"svelte": "src/index.js",
|
||||||
"module": "dist/bbui.es.js",
|
"module": "dist/bbui.es.js",
|
||||||
|
@ -38,7 +38,7 @@
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@adobe/spectrum-css-workflow-icons": "1.2.1",
|
"@adobe/spectrum-css-workflow-icons": "1.2.1",
|
||||||
"@budibase/string-templates": "2.3.17-alpha.4",
|
"@budibase/string-templates": "2.3.18-alpha.8",
|
||||||
"@spectrum-css/accordion": "3.0.24",
|
"@spectrum-css/accordion": "3.0.24",
|
||||||
"@spectrum-css/actionbutton": "1.0.1",
|
"@spectrum-css/actionbutton": "1.0.1",
|
||||||
"@spectrum-css/actiongroup": "1.0.1",
|
"@spectrum-css/actiongroup": "1.0.1",
|
||||||
|
|
|
@ -67,6 +67,9 @@
|
||||||
color: var(--spectrum-alias-icon-color-selected-hover) !important;
|
color: var(--spectrum-alias-icon-color-selected-hover) !important;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
svg.hoverable:active {
|
||||||
|
color: var(--spectrum-global-color-blue-400) !important;
|
||||||
|
}
|
||||||
|
|
||||||
svg.disabled {
|
svg.disabled {
|
||||||
color: var(--spectrum-global-color-gray-500) !important;
|
color: var(--spectrum-global-color-gray-500) !important;
|
||||||
|
|
|
@ -57,5 +57,7 @@
|
||||||
--spectrum-semantic-negative-icon-color: #e34850;
|
--spectrum-semantic-negative-icon-color: #e34850;
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
border-color: var(--spectrum-global-color-gray-400);
|
||||||
|
border-width: 1px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
label {
|
label {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--spectrum-global-color-gray-600);
|
color: var(--spectrum-global-color-gray-700);
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import "@spectrum-css/modal/dist/index-vars.css"
|
import "@spectrum-css/modal/dist/index-vars.css"
|
||||||
import "@spectrum-css/underlay/dist/index-vars.css"
|
import "@spectrum-css/underlay/dist/index-vars.css"
|
||||||
import { createEventDispatcher, setContext, tick } from "svelte"
|
import { createEventDispatcher, setContext, tick, onMount } from "svelte"
|
||||||
import { fade, fly } from "svelte/transition"
|
import { fade, fly } from "svelte/transition"
|
||||||
import Portal from "svelte-portal"
|
import Portal from "svelte-portal"
|
||||||
import Context from "../context"
|
import Context from "../context"
|
||||||
|
@ -62,9 +62,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
setContext(Context.Modal, { show, hide, cancel })
|
setContext(Context.Modal, { show, hide, cancel })
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window on:keydown={handleKey} />
|
onMount(() => {
|
||||||
|
document.addEventListener("keydown", handleKey)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
{#if inline}
|
{#if inline}
|
||||||
{#if visible}
|
{#if visible}
|
||||||
|
|
|
@ -104,6 +104,9 @@ export const deepSet = (obj, key, value) => {
|
||||||
* @param obj the object to clone
|
* @param obj the object to clone
|
||||||
*/
|
*/
|
||||||
export const cloneDeep = obj => {
|
export const cloneDeep = obj => {
|
||||||
|
if (!obj) {
|
||||||
|
return obj
|
||||||
|
}
|
||||||
return JSON.parse(JSON.stringify(obj))
|
return JSON.parse(JSON.stringify(obj))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@budibase/builder",
|
"name": "@budibase/builder",
|
||||||
"version": "2.3.17-alpha.4",
|
"version": "2.3.18-alpha.8",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -58,10 +58,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@budibase/bbui": "2.3.17-alpha.4",
|
"@budibase/bbui": "2.3.18-alpha.8",
|
||||||
"@budibase/client": "2.3.17-alpha.4",
|
"@budibase/client": "2.3.18-alpha.8",
|
||||||
"@budibase/frontend-core": "2.3.17-alpha.4",
|
"@budibase/frontend-core": "2.3.18-alpha.8",
|
||||||
"@budibase/string-templates": "2.3.17-alpha.4",
|
"@budibase/string-templates": "2.3.18-alpha.8",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.2.1",
|
"@fortawesome/fontawesome-svg-core": "^6.2.1",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.2.1",
|
"@fortawesome/free-brands-svg-icons": "^6.2.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||||
|
@ -72,6 +72,7 @@
|
||||||
"codemirror": "^5.59.0",
|
"codemirror": "^5.59.0",
|
||||||
"dayjs": "^1.11.2",
|
"dayjs": "^1.11.2",
|
||||||
"downloadjs": "1.4.7",
|
"downloadjs": "1.4.7",
|
||||||
|
"fast-json-patch": "^3.1.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"posthog-js": "^1.36.0",
|
"posthog-js": "^1.36.0",
|
||||||
"remixicon": "2.5.0",
|
"remixicon": "2.5.0",
|
||||||
|
|
|
@ -19,6 +19,7 @@ process.env.COUCH_DB_USER = "budibase"
|
||||||
process.env.COUCH_DB_PASSWORD = "budibase"
|
process.env.COUCH_DB_PASSWORD = "budibase"
|
||||||
process.env.INTERNAL_API_KEY = "budibase"
|
process.env.INTERNAL_API_KEY = "budibase"
|
||||||
process.env.ALLOW_DEV_AUTOMATIONS = 1
|
process.env.ALLOW_DEV_AUTOMATIONS = 1
|
||||||
|
process.env.MOCK_REDIS = 1
|
||||||
|
|
||||||
// Stop info logs polluting test outputs
|
// Stop info logs polluting test outputs
|
||||||
process.env.LOG_LEVEL = "error"
|
process.env.LOG_LEVEL = "error"
|
||||||
|
|
|
@ -5,12 +5,47 @@ import { getThemeStore } from "./store/theme"
|
||||||
import { derived } from "svelte/store"
|
import { derived } from "svelte/store"
|
||||||
import { findComponent, findComponentPath } from "./componentUtils"
|
import { findComponent, findComponentPath } from "./componentUtils"
|
||||||
import { RoleUtils } from "@budibase/frontend-core"
|
import { RoleUtils } from "@budibase/frontend-core"
|
||||||
|
import { createHistoryStore } from "builderStore/store/history"
|
||||||
|
import { get } from "svelte/store"
|
||||||
|
|
||||||
export const store = getFrontendStore()
|
export const store = getFrontendStore()
|
||||||
export const automationStore = getAutomationStore()
|
export const automationStore = getAutomationStore()
|
||||||
export const themeStore = getThemeStore()
|
export const themeStore = getThemeStore()
|
||||||
export const temporalStore = getTemporalStore()
|
export const temporalStore = getTemporalStore()
|
||||||
|
|
||||||
|
// Setup history for screens
|
||||||
|
export const screenHistoryStore = createHistoryStore({
|
||||||
|
getDoc: id => get(store).screens?.find(screen => screen._id === id),
|
||||||
|
selectDoc: store.actions.screens.select,
|
||||||
|
afterAction: () => {
|
||||||
|
// Ensure a valid component is selected
|
||||||
|
if (!get(selectedComponent)) {
|
||||||
|
store.update(state => ({
|
||||||
|
...state,
|
||||||
|
selectedComponentId: get(selectedScreen)?.props._id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
store.actions.screens.save = screenHistoryStore.wrapSaveDoc(
|
||||||
|
store.actions.screens.save
|
||||||
|
)
|
||||||
|
store.actions.screens.delete = screenHistoryStore.wrapDeleteDoc(
|
||||||
|
store.actions.screens.delete
|
||||||
|
)
|
||||||
|
|
||||||
|
// Setup history for automations
|
||||||
|
export const automationHistoryStore = createHistoryStore({
|
||||||
|
getDoc: automationStore.actions.getDefinition,
|
||||||
|
selectDoc: automationStore.actions.select,
|
||||||
|
})
|
||||||
|
automationStore.actions.save = automationHistoryStore.wrapSaveDoc(
|
||||||
|
automationStore.actions.save
|
||||||
|
)
|
||||||
|
automationStore.actions.delete = automationHistoryStore.wrapDeleteDoc(
|
||||||
|
automationStore.actions.delete
|
||||||
|
)
|
||||||
|
|
||||||
export const selectedScreen = derived(store, $store => {
|
export const selectedScreen = derived(store, $store => {
|
||||||
return $store.screens.find(screen => screen._id === $store.selectedScreenId)
|
return $store.screens.find(screen => screen._id === $store.selectedScreenId)
|
||||||
})
|
})
|
||||||
|
@ -71,3 +106,13 @@ export const selectedComponentPath = derived(
|
||||||
).map(component => component._id)
|
).map(component => component._id)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Derived automation state
|
||||||
|
export const selectedAutomation = derived(automationStore, $automationStore => {
|
||||||
|
if (!$automationStore.selectedAutomationId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return $automationStore.automations?.find(
|
||||||
|
x => x._id === $automationStore.selectedAutomationId
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
|
@ -1,69 +0,0 @@
|
||||||
import { generate } from "shortid"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class responsible for the traversing of the automation definition.
|
|
||||||
* Automation definitions are stored in linked lists.
|
|
||||||
*/
|
|
||||||
export default class Automation {
|
|
||||||
constructor(automation) {
|
|
||||||
this.automation = automation
|
|
||||||
}
|
|
||||||
|
|
||||||
hasTrigger() {
|
|
||||||
return this.automation.definition.trigger
|
|
||||||
}
|
|
||||||
|
|
||||||
addTestData(data) {
|
|
||||||
this.automation.testData = { ...this.automation.testData, ...data }
|
|
||||||
}
|
|
||||||
|
|
||||||
addBlock(block, idx) {
|
|
||||||
// Make sure to add trigger if doesn't exist
|
|
||||||
if (!this.hasTrigger() && block.type === "TRIGGER") {
|
|
||||||
const trigger = { id: generate(), ...block }
|
|
||||||
this.automation.definition.trigger = trigger
|
|
||||||
return trigger
|
|
||||||
}
|
|
||||||
|
|
||||||
const newBlock = { id: generate(), ...block }
|
|
||||||
this.automation.definition.steps.splice(idx, 0, newBlock)
|
|
||||||
return newBlock
|
|
||||||
}
|
|
||||||
|
|
||||||
updateBlock(updatedBlock, id) {
|
|
||||||
const { steps, trigger } = this.automation.definition
|
|
||||||
|
|
||||||
if (trigger && trigger.id === id) {
|
|
||||||
this.automation.definition.trigger = updatedBlock
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const stepIdx = steps.findIndex(step => step.id === id)
|
|
||||||
if (stepIdx < 0) throw new Error("Block not found.")
|
|
||||||
steps.splice(stepIdx, 1, updatedBlock)
|
|
||||||
this.automation.definition.steps = steps
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteBlock(id) {
|
|
||||||
const { steps, trigger } = this.automation.definition
|
|
||||||
|
|
||||||
if (trigger && trigger.id === id) {
|
|
||||||
this.automation.definition.trigger = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const stepIdx = steps.findIndex(step => step.id === id)
|
|
||||||
if (stepIdx < 0) throw new Error("Block not found.")
|
|
||||||
steps.splice(stepIdx, 1)
|
|
||||||
this.automation.definition.steps = steps
|
|
||||||
}
|
|
||||||
|
|
||||||
constructBlock(type, stepId, blockDefinition) {
|
|
||||||
return {
|
|
||||||
...blockDefinition,
|
|
||||||
inputs: blockDefinition.inputs || {},
|
|
||||||
stepId,
|
|
||||||
type,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +1,18 @@
|
||||||
import { writable } from "svelte/store"
|
import { writable, get } from "svelte/store"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
import Automation from "./Automation"
|
|
||||||
import { cloneDeep } from "lodash/fp"
|
import { cloneDeep } from "lodash/fp"
|
||||||
|
import { generate } from "shortid"
|
||||||
|
import { selectedAutomation } from "builderStore"
|
||||||
|
|
||||||
const initialAutomationState = {
|
const initialAutomationState = {
|
||||||
automations: [],
|
automations: [],
|
||||||
|
testResults: null,
|
||||||
showTestPanel: false,
|
showTestPanel: false,
|
||||||
blockDefinitions: {
|
blockDefinitions: {
|
||||||
TRIGGER: [],
|
TRIGGER: [],
|
||||||
ACTION: [],
|
ACTION: [],
|
||||||
},
|
},
|
||||||
selectedAutomation: null,
|
selectedAutomationId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAutomationStore = () => {
|
export const getAutomationStore = () => {
|
||||||
|
@ -37,49 +39,41 @@ const automationActions = store => ({
|
||||||
API.getAutomationDefinitions(),
|
API.getAutomationDefinitions(),
|
||||||
])
|
])
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
let selected = state.selectedAutomation?.automation
|
|
||||||
state.automations = responses[0]
|
state.automations = responses[0]
|
||||||
|
state.automations.sort((a, b) => {
|
||||||
|
return a.name < b.name ? -1 : 1
|
||||||
|
})
|
||||||
state.blockDefinitions = {
|
state.blockDefinitions = {
|
||||||
TRIGGER: responses[1].trigger,
|
TRIGGER: responses[1].trigger,
|
||||||
ACTION: responses[1].action,
|
ACTION: responses[1].action,
|
||||||
}
|
}
|
||||||
// If previously selected find the new obj and select it
|
|
||||||
if (selected) {
|
|
||||||
selected = responses[0].filter(
|
|
||||||
automation => automation._id === selected._id
|
|
||||||
)
|
|
||||||
state.selectedAutomation = new Automation(selected[0])
|
|
||||||
}
|
|
||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
create: async ({ name }) => {
|
create: async (name, trigger) => {
|
||||||
const automation = {
|
const automation = {
|
||||||
name,
|
name,
|
||||||
type: "automation",
|
type: "automation",
|
||||||
definition: {
|
definition: {
|
||||||
steps: [],
|
steps: [],
|
||||||
|
trigger,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const response = await API.createAutomation(automation)
|
const response = await store.actions.save(automation)
|
||||||
store.update(state => {
|
await store.actions.fetch()
|
||||||
state.automations = [...state.automations, response.automation]
|
store.actions.select(response._id)
|
||||||
store.actions.select(response.automation)
|
return response
|
||||||
return state
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
duplicate: async automation => {
|
duplicate: async automation => {
|
||||||
const response = await API.createAutomation({
|
const response = await store.actions.save({
|
||||||
...automation,
|
...automation,
|
||||||
name: `${automation.name} - copy`,
|
name: `${automation.name} - copy`,
|
||||||
_id: undefined,
|
_id: undefined,
|
||||||
_ref: undefined,
|
_ref: undefined,
|
||||||
})
|
})
|
||||||
store.update(state => {
|
await store.actions.fetch()
|
||||||
state.automations = [...state.automations, response.automation]
|
store.actions.select(response._id)
|
||||||
store.actions.select(response.automation)
|
return response
|
||||||
return state
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
save: async automation => {
|
save: async automation => {
|
||||||
const response = await API.updateAutomation(automation)
|
const response = await API.updateAutomation(automation)
|
||||||
|
@ -90,11 +84,13 @@ const automationActions = store => ({
|
||||||
)
|
)
|
||||||
if (existingIdx !== -1) {
|
if (existingIdx !== -1) {
|
||||||
state.automations.splice(existingIdx, 1, updatedAutomation)
|
state.automations.splice(existingIdx, 1, updatedAutomation)
|
||||||
state.automations = [...state.automations]
|
|
||||||
store.actions.select(updatedAutomation)
|
|
||||||
return state
|
return state
|
||||||
|
} else {
|
||||||
|
state.automations = [...state.automations, updatedAutomation]
|
||||||
}
|
}
|
||||||
|
return state
|
||||||
})
|
})
|
||||||
|
return response.automation
|
||||||
},
|
},
|
||||||
delete: async automation => {
|
delete: async automation => {
|
||||||
await API.deleteAutomation({
|
await API.deleteAutomation({
|
||||||
|
@ -102,34 +98,83 @@ const automationActions = store => ({
|
||||||
automationRev: automation?._rev,
|
automationRev: automation?._rev,
|
||||||
})
|
})
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
const existingIdx = state.automations.findIndex(
|
// Remove the automation
|
||||||
existing => existing._id === automation?._id
|
state.automations = state.automations.filter(
|
||||||
|
x => x._id !== automation._id
|
||||||
)
|
)
|
||||||
state.automations.splice(existingIdx, 1)
|
// Select a new automation if required
|
||||||
state.automations = [...state.automations]
|
if (automation._id === state.selectedAutomationId) {
|
||||||
state.selectedAutomation = null
|
store.actions.select(state.automations[0]?._id)
|
||||||
state.selectedBlock = null
|
}
|
||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
|
await store.actions.fetch()
|
||||||
|
},
|
||||||
|
updateBlockInputs: async (block, data) => {
|
||||||
|
// Create new modified block
|
||||||
|
let newBlock = {
|
||||||
|
...block,
|
||||||
|
inputs: {
|
||||||
|
...block.inputs,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any nullish or empty string values
|
||||||
|
Object.keys(newBlock.inputs).forEach(key => {
|
||||||
|
const val = newBlock.inputs[key]
|
||||||
|
if (val == null || val === "") {
|
||||||
|
delete newBlock.inputs[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create new modified automation
|
||||||
|
const automation = get(selectedAutomation)
|
||||||
|
const newAutomation = store.actions.getUpdatedDefinition(
|
||||||
|
automation,
|
||||||
|
newBlock
|
||||||
|
)
|
||||||
|
|
||||||
|
// Don't save if no changes were made
|
||||||
|
if (JSON.stringify(newAutomation) === JSON.stringify(automation)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await store.actions.save(newAutomation)
|
||||||
},
|
},
|
||||||
test: async (automation, testData) => {
|
test: async (automation, testData) => {
|
||||||
store.update(state => {
|
|
||||||
state.selectedAutomation.testResults = null
|
|
||||||
return state
|
|
||||||
})
|
|
||||||
const result = await API.testAutomation({
|
const result = await API.testAutomation({
|
||||||
automationId: automation?._id,
|
automationId: automation?._id,
|
||||||
testData,
|
testData,
|
||||||
})
|
})
|
||||||
|
if (!result?.trigger && !result?.steps?.length) {
|
||||||
|
throw "Something went wrong testing your automation"
|
||||||
|
}
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
state.selectedAutomation.testResults = result
|
state.testResults = result
|
||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
select: automation => {
|
getDefinition: id => {
|
||||||
|
return get(store).automations?.find(x => x._id === id)
|
||||||
|
},
|
||||||
|
getUpdatedDefinition: (automation, block) => {
|
||||||
|
let newAutomation = cloneDeep(automation)
|
||||||
|
if (automation.definition.trigger?.id === block.id) {
|
||||||
|
newAutomation.definition.trigger = block
|
||||||
|
} else {
|
||||||
|
const idx = automation.definition.steps.findIndex(x => x.id === block.id)
|
||||||
|
newAutomation.definition.steps.splice(idx, 1, block)
|
||||||
|
}
|
||||||
|
return newAutomation
|
||||||
|
},
|
||||||
|
select: id => {
|
||||||
|
if (!id || id === get(store).selectedAutomationId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
state.selectedAutomation = new Automation(cloneDeep(automation))
|
state.selectedAutomationId = id
|
||||||
state.selectedBlock = null
|
state.testResults = null
|
||||||
|
state.showTestPanel = false
|
||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
@ -147,48 +192,57 @@ const automationActions = store => ({
|
||||||
appId,
|
appId,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
addTestDataToAutomation: data => {
|
addTestDataToAutomation: async data => {
|
||||||
store.update(state => {
|
let newAutomation = cloneDeep(get(selectedAutomation))
|
||||||
state.selectedAutomation.addTestData(data)
|
newAutomation.testData = {
|
||||||
return state
|
...newAutomation.testData,
|
||||||
})
|
...data,
|
||||||
},
|
|
||||||
addBlockToAutomation: (block, blockIdx) => {
|
|
||||||
store.update(state => {
|
|
||||||
state.selectedBlock = state.selectedAutomation.addBlock(
|
|
||||||
cloneDeep(block),
|
|
||||||
blockIdx
|
|
||||||
)
|
|
||||||
return state
|
|
||||||
})
|
|
||||||
},
|
|
||||||
toggleFieldControl: value => {
|
|
||||||
store.update(state => {
|
|
||||||
state.selectedBlock.rowControl = value
|
|
||||||
return state
|
|
||||||
})
|
|
||||||
},
|
|
||||||
deleteAutomationBlock: block => {
|
|
||||||
store.update(state => {
|
|
||||||
const idx =
|
|
||||||
state.selectedAutomation.automation.definition.steps.findIndex(
|
|
||||||
x => x.id === block.id
|
|
||||||
)
|
|
||||||
state.selectedAutomation.deleteBlock(block.id)
|
|
||||||
|
|
||||||
// Select next closest step
|
|
||||||
const steps = state.selectedAutomation.automation.definition.steps
|
|
||||||
let nextSelectedBlock
|
|
||||||
if (steps[idx] != null) {
|
|
||||||
nextSelectedBlock = steps[idx]
|
|
||||||
} else if (steps[idx - 1] != null) {
|
|
||||||
nextSelectedBlock = steps[idx - 1]
|
|
||||||
} else {
|
|
||||||
nextSelectedBlock =
|
|
||||||
state.selectedAutomation.automation.definition.trigger || null
|
|
||||||
}
|
}
|
||||||
state.selectedBlock = nextSelectedBlock
|
await store.actions.save(newAutomation)
|
||||||
return state
|
},
|
||||||
})
|
constructBlock(type, stepId, blockDefinition) {
|
||||||
|
return {
|
||||||
|
...blockDefinition,
|
||||||
|
inputs: blockDefinition.inputs || {},
|
||||||
|
stepId,
|
||||||
|
type,
|
||||||
|
id: generate(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addBlockToAutomation: async (block, blockIdx) => {
|
||||||
|
const automation = get(selectedAutomation)
|
||||||
|
let newAutomation = cloneDeep(automation)
|
||||||
|
if (!automation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newAutomation.definition.steps.splice(blockIdx, 0, block)
|
||||||
|
await store.actions.save(newAutomation)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* "rowControl" appears to be the name of the flag used to determine whether
|
||||||
|
* a certain automation block uses values or bindings as inputs
|
||||||
|
*/
|
||||||
|
toggleRowControl: async (block, rowControl) => {
|
||||||
|
const newBlock = { ...block, rowControl }
|
||||||
|
const newAutomation = store.actions.getUpdatedDefinition(
|
||||||
|
get(selectedAutomation),
|
||||||
|
newBlock
|
||||||
|
)
|
||||||
|
await store.actions.save(newAutomation)
|
||||||
|
},
|
||||||
|
deleteAutomationBlock: async block => {
|
||||||
|
const automation = get(selectedAutomation)
|
||||||
|
let newAutomation = cloneDeep(automation)
|
||||||
|
|
||||||
|
// Delete trigger if required
|
||||||
|
if (newAutomation.definition.trigger?.id === block.id) {
|
||||||
|
delete newAutomation.definition.trigger
|
||||||
|
} else {
|
||||||
|
// Otherwise remove step
|
||||||
|
newAutomation.definition.steps = newAutomation.definition.steps.filter(
|
||||||
|
step => step.id !== block.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await store.actions.save(newAutomation)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,48 +0,0 @@
|
||||||
import Automation from "../Automation"
|
|
||||||
import TEST_AUTOMATION from "./testAutomation"
|
|
||||||
|
|
||||||
const TEST_BLOCK = {
|
|
||||||
id: "AUXJQGZY7",
|
|
||||||
name: "Delay",
|
|
||||||
icon: "ri-time-fill",
|
|
||||||
tagline: "Delay for <b>{{time}}</b> milliseconds",
|
|
||||||
description: "Delay the automation until an amount of time has passed.",
|
|
||||||
params: { time: "number" },
|
|
||||||
type: "LOGIC",
|
|
||||||
args: { time: "5000" },
|
|
||||||
stepId: "DELAY",
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Automation Data Object", () => {
|
|
||||||
let automation
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
automation = new Automation({ ...TEST_AUTOMATION })
|
|
||||||
})
|
|
||||||
|
|
||||||
it("adds a automation block to the automation", () => {
|
|
||||||
automation.addBlock(TEST_BLOCK)
|
|
||||||
expect(automation.automation.definition)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("updates a automation block with new attributes", () => {
|
|
||||||
const firstBlock = automation.automation.definition.steps[0]
|
|
||||||
const updatedBlock = {
|
|
||||||
...firstBlock,
|
|
||||||
name: "UPDATED",
|
|
||||||
}
|
|
||||||
automation.updateBlock(updatedBlock, firstBlock.id)
|
|
||||||
expect(automation.automation.definition.steps[0]).toEqual(updatedBlock)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("deletes a automation block successfully", () => {
|
|
||||||
const { steps } = automation.automation.definition
|
|
||||||
const originalLength = steps.length
|
|
||||||
|
|
||||||
const lastBlock = steps[steps.length - 1]
|
|
||||||
automation.deleteBlock(lastBlock.id)
|
|
||||||
expect(automation.automation.definition.steps.length).toBeLessThan(
|
|
||||||
originalLength
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -1,78 +0,0 @@
|
||||||
export default {
|
|
||||||
name: "Test automation",
|
|
||||||
definition: {
|
|
||||||
steps: [
|
|
||||||
{
|
|
||||||
id: "ANBDINAPS",
|
|
||||||
description: "Send an email.",
|
|
||||||
tagline: "Send email to <b>{{to}}</b>",
|
|
||||||
icon: "ri-mail-open-fill",
|
|
||||||
name: "Send Email",
|
|
||||||
params: {
|
|
||||||
to: "string",
|
|
||||||
from: "string",
|
|
||||||
subject: "longText",
|
|
||||||
text: "longText",
|
|
||||||
},
|
|
||||||
type: "ACTION",
|
|
||||||
args: {
|
|
||||||
text: "A user was created!",
|
|
||||||
subject: "New Budibase User",
|
|
||||||
from: "budimaster@budibase.com",
|
|
||||||
to: "test@test.com",
|
|
||||||
},
|
|
||||||
stepId: "SEND_EMAIL",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
trigger: {
|
|
||||||
id: "iRzYMOqND",
|
|
||||||
name: "Row Saved",
|
|
||||||
event: "row:save",
|
|
||||||
icon: "ri-save-line",
|
|
||||||
tagline: "Row is added to <b>{{table.name}}</b>",
|
|
||||||
description: "Fired when a row is saved to your database.",
|
|
||||||
params: { table: "table" },
|
|
||||||
type: "TRIGGER",
|
|
||||||
args: {
|
|
||||||
table: {
|
|
||||||
type: "table",
|
|
||||||
views: {},
|
|
||||||
name: "users",
|
|
||||||
schema: {
|
|
||||||
name: {
|
|
||||||
type: "string",
|
|
||||||
constraints: {
|
|
||||||
type: "string",
|
|
||||||
length: { maximum: 123 },
|
|
||||||
presence: { allowEmpty: false },
|
|
||||||
},
|
|
||||||
name: "name",
|
|
||||||
},
|
|
||||||
age: {
|
|
||||||
type: "number",
|
|
||||||
constraints: {
|
|
||||||
type: "number",
|
|
||||||
presence: { allowEmpty: false },
|
|
||||||
numericality: {
|
|
||||||
greaterThanOrEqualTo: "",
|
|
||||||
lessThanOrEqualTo: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
name: "age",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_id: "c6b4e610cd984b588837bca27188a451",
|
|
||||||
_rev: "7-b8aa1ce0b53e88928bb88fc11bdc0aff",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
stepId: "ROW_SAVED",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
type: "automation",
|
|
||||||
ok: true,
|
|
||||||
id: "b384f861f4754e1693835324a7fcca62",
|
|
||||||
rev: "1-aa1c2cbd868ef02e26f8fad531dd7e37",
|
|
||||||
live: false,
|
|
||||||
_id: "b384f861f4754e1693835324a7fcca62",
|
|
||||||
_rev: "108-4116829ec375e0481d0ecab9e83a2caf",
|
|
||||||
}
|
|
|
@ -1,6 +1,11 @@
|
||||||
import { get, writable } from "svelte/store"
|
import { get, writable } from "svelte/store"
|
||||||
import { cloneDeep } from "lodash/fp"
|
import { cloneDeep } from "lodash/fp"
|
||||||
import { selectedScreen, selectedComponent } from "builderStore"
|
import {
|
||||||
|
selectedScreen,
|
||||||
|
selectedComponent,
|
||||||
|
screenHistoryStore,
|
||||||
|
automationHistoryStore,
|
||||||
|
} from "builderStore"
|
||||||
import {
|
import {
|
||||||
datasources,
|
datasources,
|
||||||
integrations,
|
integrations,
|
||||||
|
@ -122,6 +127,8 @@ export const getFrontendStore = () => {
|
||||||
navigation: application.navigation || {},
|
navigation: application.navigation || {},
|
||||||
usedPlugins: application.usedPlugins || [],
|
usedPlugins: application.usedPlugins || [],
|
||||||
}))
|
}))
|
||||||
|
screenHistoryStore.reset()
|
||||||
|
automationHistoryStore.reset()
|
||||||
|
|
||||||
// Initialise backend stores
|
// Initialise backend stores
|
||||||
database.set(application.instance)
|
database.set(application.instance)
|
||||||
|
@ -179,10 +186,7 @@ export const getFrontendStore = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check screen isn't already selected
|
// Check screen isn't already selected
|
||||||
if (
|
if (state.selectedScreenId === screen._id) {
|
||||||
state.selectedScreenId === screen._id &&
|
|
||||||
state.selectedComponentId === screen.props?._id
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -347,6 +351,7 @@ export const getFrontendStore = () => {
|
||||||
|
|
||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
|
return null
|
||||||
},
|
},
|
||||||
updateSetting: async (screen, name, value) => {
|
updateSetting: async (screen, name, value) => {
|
||||||
if (!screen || !name) {
|
if (!screen || !name) {
|
||||||
|
|
|
@ -0,0 +1,319 @@
|
||||||
|
import * as jsonpatch from "fast-json-patch/index.mjs"
|
||||||
|
import { writable, derived, get } from "svelte/store"
|
||||||
|
|
||||||
|
const Operations = {
|
||||||
|
Add: "Add",
|
||||||
|
Delete: "Delete",
|
||||||
|
Change: "Change",
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
history: [],
|
||||||
|
position: 0,
|
||||||
|
loading: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createHistoryStore = ({
|
||||||
|
getDoc,
|
||||||
|
selectDoc,
|
||||||
|
beforeAction,
|
||||||
|
afterAction,
|
||||||
|
}) => {
|
||||||
|
// Use a derived store to check if we are able to undo or redo any operations
|
||||||
|
const store = writable(initialState)
|
||||||
|
const derivedStore = derived(store, $store => {
|
||||||
|
return {
|
||||||
|
...$store,
|
||||||
|
canUndo: $store.position > 0,
|
||||||
|
canRedo: $store.position < $store.history.length,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wrapped versions of essential functions which we call ourselves when using
|
||||||
|
// undo and redo
|
||||||
|
let saveFn
|
||||||
|
let deleteFn
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal util to set the loading flag
|
||||||
|
*/
|
||||||
|
const startLoading = () => {
|
||||||
|
store.update(state => {
|
||||||
|
state.loading = true
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal util to unset the loading flag
|
||||||
|
*/
|
||||||
|
const stopLoading = () => {
|
||||||
|
store.update(state => {
|
||||||
|
state.loading = false
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets history state
|
||||||
|
*/
|
||||||
|
const reset = () => {
|
||||||
|
store.set(initialState)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds or updates an operation in history.
|
||||||
|
* For internal use only.
|
||||||
|
* @param operation the operation to save
|
||||||
|
*/
|
||||||
|
const saveOperation = operation => {
|
||||||
|
store.update(state => {
|
||||||
|
// Update history
|
||||||
|
let history = state.history
|
||||||
|
let position = state.position
|
||||||
|
if (!operation.id) {
|
||||||
|
// Every time a new operation occurs we discard any redo potential
|
||||||
|
operation.id = Math.random()
|
||||||
|
history = [...history.slice(0, state.position), operation]
|
||||||
|
position += 1
|
||||||
|
} else {
|
||||||
|
// If this is a redo/undo of an existing operation, just update history
|
||||||
|
// to replace the doc object as revisions may have changed
|
||||||
|
const idx = history.findIndex(op => op.id === operation.id)
|
||||||
|
history[idx].doc = operation.doc
|
||||||
|
}
|
||||||
|
return { history, position }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the save function, which asynchronously updates a doc.
|
||||||
|
* The returned function is an enriched version of the real save function so
|
||||||
|
* that we can control history.
|
||||||
|
* @param fn the save function
|
||||||
|
* @returns {function} a wrapped version of the save function
|
||||||
|
*/
|
||||||
|
const wrapSaveDoc = fn => {
|
||||||
|
saveFn = async (doc, operationId) => {
|
||||||
|
// Only works on a single doc at a time
|
||||||
|
if (!doc || Array.isArray(doc)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startLoading()
|
||||||
|
try {
|
||||||
|
const oldDoc = getDoc(doc._id)
|
||||||
|
const newDoc = jsonpatch.deepClone(await fn(doc))
|
||||||
|
|
||||||
|
// Store the change
|
||||||
|
if (!oldDoc) {
|
||||||
|
// If no old doc, this is an add operation
|
||||||
|
saveOperation({
|
||||||
|
type: Operations.Add,
|
||||||
|
doc: newDoc,
|
||||||
|
id: operationId,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Otherwise this is a change operation
|
||||||
|
saveOperation({
|
||||||
|
type: Operations.Change,
|
||||||
|
forwardPatch: jsonpatch.compare(oldDoc, doc),
|
||||||
|
backwardsPatch: jsonpatch.compare(doc, oldDoc),
|
||||||
|
doc: newDoc,
|
||||||
|
id: operationId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
stopLoading()
|
||||||
|
return newDoc
|
||||||
|
} catch (error) {
|
||||||
|
// We want to allow errors to propagate up to normal handlers, but we
|
||||||
|
// want to stop loading first
|
||||||
|
stopLoading()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return saveFn
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the delete function, which asynchronously deletes a doc.
|
||||||
|
* The returned function is an enriched version of the real delete function so
|
||||||
|
* that we can control history.
|
||||||
|
* @param fn the delete function
|
||||||
|
* @returns {function} a wrapped version of the delete function
|
||||||
|
*/
|
||||||
|
const wrapDeleteDoc = fn => {
|
||||||
|
deleteFn = async (doc, operationId) => {
|
||||||
|
// Only works on a single doc at a time
|
||||||
|
if (!doc || Array.isArray(doc)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startLoading()
|
||||||
|
try {
|
||||||
|
const oldDoc = jsonpatch.deepClone(doc)
|
||||||
|
await fn(doc)
|
||||||
|
saveOperation({
|
||||||
|
type: Operations.Delete,
|
||||||
|
doc: oldDoc,
|
||||||
|
id: operationId,
|
||||||
|
})
|
||||||
|
stopLoading()
|
||||||
|
} catch (error) {
|
||||||
|
// We want to allow errors to propagate up to normal handlers, but we
|
||||||
|
// want to stop loading first
|
||||||
|
stopLoading()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deleteFn
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously undoes the previous operation.
|
||||||
|
* Optionally selects the changed document so that changes are visible.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const undo = async () => {
|
||||||
|
// Sanity checks
|
||||||
|
const { canUndo, history, position, loading } = get(derivedStore)
|
||||||
|
if (!canUndo || loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const operation = history[position - 1]
|
||||||
|
if (!operation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startLoading()
|
||||||
|
|
||||||
|
// Before hook
|
||||||
|
await beforeAction?.(operation)
|
||||||
|
|
||||||
|
// Update state immediately to prevent further clicks and to prevent bad
|
||||||
|
// history in the event of an update failing
|
||||||
|
store.update(state => {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
position: state.position - 1,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Undo the operation
|
||||||
|
try {
|
||||||
|
// Undo ADD
|
||||||
|
if (operation.type === Operations.Add) {
|
||||||
|
// Try to get the latest doc version to delete
|
||||||
|
const latestDoc = getDoc(operation.doc._id)
|
||||||
|
const doc = latestDoc || operation.doc
|
||||||
|
await deleteFn(doc, operation.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo DELETE
|
||||||
|
else if (operation.type === Operations.Delete) {
|
||||||
|
// Delete the _rev from the deleted doc so that we can save it as a new
|
||||||
|
// doc again without conflicts
|
||||||
|
let doc = jsonpatch.deepClone(operation.doc)
|
||||||
|
delete doc._rev
|
||||||
|
const created = await saveFn(doc, operation.id)
|
||||||
|
selectDoc?.(created?._id || doc._id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo CHANGE
|
||||||
|
else {
|
||||||
|
// Get the current doc and apply the backwards patch on top of it
|
||||||
|
let doc = jsonpatch.deepClone(getDoc(operation.doc._id))
|
||||||
|
if (doc) {
|
||||||
|
jsonpatch.applyPatch(
|
||||||
|
doc,
|
||||||
|
jsonpatch.deepClone(operation.backwardsPatch)
|
||||||
|
)
|
||||||
|
await saveFn(doc, operation.id)
|
||||||
|
selectDoc?.(doc._id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stopLoading()
|
||||||
|
} catch (error) {
|
||||||
|
stopLoading()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// After hook
|
||||||
|
await afterAction?.(operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously redoes the previous undo.
|
||||||
|
* Optionally selects the changed document so that changes are visible.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const redo = async () => {
|
||||||
|
// Sanity checks
|
||||||
|
const { canRedo, history, position, loading } = get(derivedStore)
|
||||||
|
if (!canRedo || loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const operation = history[position]
|
||||||
|
if (!operation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startLoading()
|
||||||
|
|
||||||
|
// Before hook
|
||||||
|
await beforeAction?.(operation)
|
||||||
|
|
||||||
|
// Update state immediately to prevent further clicks and to prevent bad
|
||||||
|
// history in the event of an update failing
|
||||||
|
store.update(state => {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
position: state.position + 1,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Redo the operation
|
||||||
|
try {
|
||||||
|
// Redo ADD
|
||||||
|
if (operation.type === Operations.Add) {
|
||||||
|
// Delete the _rev from the deleted doc so that we can save it as a new
|
||||||
|
// doc again without conflicts
|
||||||
|
let doc = jsonpatch.deepClone(operation.doc)
|
||||||
|
delete doc._rev
|
||||||
|
const created = await saveFn(doc, operation.id)
|
||||||
|
selectDoc?.(created?._id || doc._id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redo DELETE
|
||||||
|
else if (operation.type === Operations.Delete) {
|
||||||
|
// Try to get the latest doc version to delete
|
||||||
|
const latestDoc = getDoc(operation.doc._id)
|
||||||
|
const doc = latestDoc || operation.doc
|
||||||
|
await deleteFn(doc, operation.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redo CHANGE
|
||||||
|
else {
|
||||||
|
// Get the current doc and apply the forwards patch on top of it
|
||||||
|
let doc = jsonpatch.deepClone(getDoc(operation.doc._id))
|
||||||
|
if (doc) {
|
||||||
|
jsonpatch.applyPatch(doc, jsonpatch.deepClone(operation.forwardPatch))
|
||||||
|
await saveFn(doc, operation.id)
|
||||||
|
selectDoc?.(doc._id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stopLoading()
|
||||||
|
} catch (error) {
|
||||||
|
stopLoading()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// After hook
|
||||||
|
await afterAction?.(operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe: derivedStore.subscribe,
|
||||||
|
wrapSaveDoc,
|
||||||
|
wrapDeleteDoc,
|
||||||
|
reset,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
<script>
|
<script>
|
||||||
import { automationStore } from "builderStore"
|
import { selectedAutomation } from "builderStore"
|
||||||
import Flowchart from "./FlowChart/FlowChart.svelte"
|
import Flowchart from "./FlowChart/FlowChart.svelte"
|
||||||
|
|
||||||
$: automation = $automationStore.selectedAutomation?.automation
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if automation}
|
{#if $selectedAutomation}
|
||||||
<Flowchart {automation} />
|
{#key $selectedAutomation._id}
|
||||||
|
<Flowchart automation={$selectedAutomation} />
|
||||||
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -5,7 +5,6 @@
|
||||||
Detail,
|
Detail,
|
||||||
Body,
|
Body,
|
||||||
Icon,
|
Icon,
|
||||||
Tooltip,
|
|
||||||
notifications,
|
notifications,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import { automationStore } from "builderStore"
|
import { automationStore } from "builderStore"
|
||||||
|
@ -13,7 +12,6 @@
|
||||||
import { externalActions } from "./ExternalActions"
|
import { externalActions } from "./ExternalActions"
|
||||||
|
|
||||||
export let blockIdx
|
export let blockIdx
|
||||||
export let blockComplete
|
|
||||||
|
|
||||||
const disabled = {
|
const disabled = {
|
||||||
SEND_EMAIL_SMTP: {
|
SEND_EMAIL_SMTP: {
|
||||||
|
@ -50,15 +48,12 @@
|
||||||
|
|
||||||
async function addBlockToAutomation() {
|
async function addBlockToAutomation() {
|
||||||
try {
|
try {
|
||||||
const newBlock = $automationStore.selectedAutomation.constructBlock(
|
const newBlock = automationStore.actions.constructBlock(
|
||||||
"ACTION",
|
"ACTION",
|
||||||
actionVal.stepId,
|
actionVal.stepId,
|
||||||
actionVal
|
actionVal
|
||||||
)
|
)
|
||||||
automationStore.actions.addBlockToAutomation(newBlock, blockIdx + 1)
|
await automationStore.actions.addBlockToAutomation(newBlock, blockIdx + 1)
|
||||||
await automationStore.actions.save(
|
|
||||||
$automationStore.selectedAutomation?.automation
|
|
||||||
)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error("Error saving automation")
|
notifications.error("Error saving automation")
|
||||||
}
|
}
|
||||||
|
@ -66,20 +61,14 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ModalContent
|
<ModalContent
|
||||||
title="Create Automation"
|
title="Add automation step"
|
||||||
confirmText="Save"
|
confirmText="Save"
|
||||||
size="M"
|
size="M"
|
||||||
disabled={!selectedAction}
|
disabled={!selectedAction}
|
||||||
onConfirm={() => {
|
onConfirm={addBlockToAutomation}
|
||||||
blockComplete = true
|
|
||||||
addBlockToAutomation()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Body size="XS">Select an app or event.</Body>
|
<Layout noPadding gap="XS">
|
||||||
|
<Detail size="S">Apps</Detail>
|
||||||
<Layout noPadding>
|
|
||||||
<Body size="S">Apps</Body>
|
|
||||||
|
|
||||||
<div class="item-list">
|
<div class="item-list">
|
||||||
{#each Object.entries(external) as [idx, action]}
|
{#each Object.entries(external) as [idx, action]}
|
||||||
<div
|
<div
|
||||||
|
@ -95,64 +84,45 @@
|
||||||
alt="zapier"
|
alt="zapier"
|
||||||
/>
|
/>
|
||||||
<span class="icon-spacing">
|
<span class="icon-spacing">
|
||||||
<Body size="XS">{idx.charAt(0).toUpperCase() + idx.slice(1)}</Body
|
<Body size="XS">
|
||||||
></span
|
{idx.charAt(0).toUpperCase() + idx.slice(1)}
|
||||||
>
|
</Body>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<Layout noPadding gap="XS">
|
||||||
<Detail size="S">Actions</Detail>
|
<Detail size="S">Actions</Detail>
|
||||||
|
|
||||||
<div class="item-list">
|
<div class="item-list">
|
||||||
{#each Object.entries(internal) as [idx, action]}
|
{#each Object.entries(internal) as [idx, action]}
|
||||||
{#if disabled[idx] && disabled[idx].disabled}
|
{@const isDisabled = disabled[idx] && disabled[idx].disabled}
|
||||||
<Tooltip text={disabled[idx].message} direction="bottom">
|
|
||||||
<div
|
<div
|
||||||
class="item"
|
class="item"
|
||||||
|
class:disabled={isDisabled}
|
||||||
class:selected={selectedAction === action.name}
|
class:selected={selectedAction === action.name}
|
||||||
class:disabled={true}
|
on:click={isDisabled ? null : () => selectAction(action)}
|
||||||
on:click={() => selectAction(action)}
|
|
||||||
>
|
>
|
||||||
<div class="item-body">
|
<div class="item-body">
|
||||||
<Icon name={action.icon} />
|
<Icon name={action.icon} />
|
||||||
<span class="icon-spacing">
|
<Body size="XS">{action.name}</Body>
|
||||||
<Body size="XS">{action.name}</Body></span
|
{#if isDisabled}
|
||||||
>
|
<Icon name="Help" tooltip={disabled[idx].message} />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="item"
|
|
||||||
class:selected={selectedAction === action.name}
|
|
||||||
on:click={() => selectAction(action)}
|
|
||||||
>
|
|
||||||
<div class="item-body">
|
|
||||||
<Icon name={action.icon} />
|
|
||||||
<span class="icon-spacing">
|
|
||||||
<Body size="XS">{action.name}</Body></span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.disabled {
|
|
||||||
opacity: 0.3;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.icon-spacing {
|
|
||||||
margin-left: var(--spacing-m);
|
|
||||||
}
|
|
||||||
.item-body {
|
.item-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-left: var(--spacing-m);
|
margin-left: var(--spacing-m);
|
||||||
|
gap: var(--spacing-m);
|
||||||
}
|
}
|
||||||
.item-list {
|
.item-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
@ -171,8 +141,15 @@
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
}
|
}
|
||||||
.item:hover,
|
.item:not(.disabled):hover,
|
||||||
.selected {
|
.selected {
|
||||||
background: var(--spectrum-alias-background-color-tertiary);
|
background: var(--spectrum-alias-background-color-tertiary);
|
||||||
}
|
}
|
||||||
|
.disabled {
|
||||||
|
background: var(--spectrum-global-color-gray-200);
|
||||||
|
color: var(--spectrum-global-color-gray-500);
|
||||||
|
}
|
||||||
|
.disabled :global(.spectrum-Body) {
|
||||||
|
color: var(--spectrum-global-color-gray-600);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script>
|
<script>
|
||||||
import { automationStore } from "builderStore"
|
import { automationStore, selectedAutomation } from "builderStore"
|
||||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||||
import FlowItem from "./FlowItem.svelte"
|
import FlowItem from "./FlowItem.svelte"
|
||||||
import TestDataModal from "./TestDataModal.svelte"
|
import TestDataModal from "./TestDataModal.svelte"
|
||||||
|
@ -13,27 +13,28 @@
|
||||||
Modal,
|
Modal,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import { ActionStepID } from "constants/backend/automations"
|
import { ActionStepID } from "constants/backend/automations"
|
||||||
|
import UndoRedoControl from "components/common/UndoRedoControl.svelte"
|
||||||
|
import { automationHistoryStore } from "builderStore"
|
||||||
|
|
||||||
export let automation
|
export let automation
|
||||||
|
|
||||||
let testDataModal
|
let testDataModal
|
||||||
let blocks
|
|
||||||
let confirmDeleteDialog
|
let confirmDeleteDialog
|
||||||
|
|
||||||
$: {
|
$: blocks = getBlocks(automation)
|
||||||
blocks = []
|
|
||||||
if (automation) {
|
const getBlocks = automation => {
|
||||||
|
let blocks = []
|
||||||
if (automation.definition.trigger) {
|
if (automation.definition.trigger) {
|
||||||
blocks.push(automation.definition.trigger)
|
blocks.push(automation.definition.trigger)
|
||||||
}
|
}
|
||||||
blocks = blocks.concat(automation.definition.steps || [])
|
blocks = blocks.concat(automation.definition.steps || [])
|
||||||
}
|
return blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAutomation() {
|
async function deleteAutomation() {
|
||||||
try {
|
try {
|
||||||
await automationStore.actions.delete(
|
await automationStore.actions.delete($selectedAutomation)
|
||||||
$automationStore.selectedAutomation?.automation
|
|
||||||
)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error("Error deleting automation")
|
notifications.error("Error deleting automation")
|
||||||
}
|
}
|
||||||
|
@ -41,20 +42,17 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="canvas">
|
<div class="canvas">
|
||||||
<div style="float: left; padding-left: var(--spacing-xl);">
|
<div class="header">
|
||||||
<Heading size="S">{automation.name}</Heading>
|
<Heading size="S">{automation.name}</Heading>
|
||||||
</div>
|
<div class="controls">
|
||||||
<div style="float: right; padding-right: var(--spacing-xl);" class="title">
|
<UndoRedoControl store={automationHistoryStore} />
|
||||||
<div class="subtitle">
|
|
||||||
<div style="display:flex; align-items: center;">
|
|
||||||
<div class="icon">
|
|
||||||
<Icon
|
<Icon
|
||||||
on:click={confirmDeleteDialog.show}
|
on:click={confirmDeleteDialog.show}
|
||||||
hoverable
|
hoverable
|
||||||
size="M"
|
size="M"
|
||||||
name="DeleteOutline"
|
name="DeleteOutline"
|
||||||
/>
|
/>
|
||||||
</div>
|
<div class="buttons">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
testDataModal.show()
|
testDataModal.show()
|
||||||
|
@ -62,9 +60,8 @@
|
||||||
icon="MultipleCheck"
|
icon="MultipleCheck"
|
||||||
size="M">Run test</ActionButton
|
size="M">Run test</ActionButton
|
||||||
>
|
>
|
||||||
<div style="padding-left: var(--spacing-m);">
|
|
||||||
<ActionButton
|
<ActionButton
|
||||||
disabled={!$automationStore.selectedAutomation?.testResults}
|
disabled={!$automationStore.testResults}
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
$automationStore.showTestPanel = true
|
$automationStore.showTestPanel = true
|
||||||
}}
|
}}
|
||||||
|
@ -74,13 +71,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{#each blocks as block, idx (block.id)}
|
{#each blocks as block, idx (block.id)}
|
||||||
<div
|
<div
|
||||||
class="block"
|
class="block"
|
||||||
animate:flip={{ duration: 500 }}
|
animate:flip={{ duration: 500 }}
|
||||||
in:fly|local={{ x: 500, duration: 500 }}
|
in:fly={{ x: 500, duration: 500 }}
|
||||||
out:fly|local={{ x: 500, duration: 500 }}
|
out:fly|local={{ x: 500, duration: 500 }}
|
||||||
>
|
>
|
||||||
{#if block.stepId !== ActionStepID.LOOP}
|
{#if block.stepId !== ActionStepID.LOOP}
|
||||||
|
@ -105,6 +101,9 @@
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.canvas {
|
||||||
|
padding: var(--spacing-l) var(--spacing-xl);
|
||||||
|
}
|
||||||
/* Fix for firefox not respecting bottom padding in scrolling containers */
|
/* Fix for firefox not respecting bottom padding in scrolling containers */
|
||||||
.canvas > *:last-child {
|
.canvas > *:last-child {
|
||||||
padding-bottom: 40px;
|
padding-bottom: 40px;
|
||||||
|
@ -122,18 +121,19 @@
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.header {
|
||||||
padding-bottom: var(--spacing-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
padding-bottom: var(--spacing-xl);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.icon {
|
.controls,
|
||||||
cursor: pointer;
|
.buttons {
|
||||||
padding-right: var(--spacing-m);
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
.buttons {
|
||||||
|
gap: var(--spacing-s);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue