commit
353847c27d
25 changed files with 253 additions and 199 deletions
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "0.9.152",
|
"version": "0.9.153-alpha.1",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@budibase/auth",
|
"name": "@budibase/auth",
|
||||||
"version": "0.9.152",
|
"version": "0.9.153-alpha.1",
|
||||||
"description": "Authentication middlewares for budibase builder and apps",
|
"description": "Authentication middlewares for budibase builder and apps",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"author": "Budibase",
|
"author": "Budibase",
|
||||||
|
|
|
@ -20,6 +20,10 @@ const getErrorMessage = () => {
|
||||||
return done.mock.calls[0][2].message
|
return done.mock.calls[0][2].message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saveUser = async (user) => {
|
||||||
|
return await db.put(user)
|
||||||
|
}
|
||||||
|
|
||||||
describe("third party common", () => {
|
describe("third party common", () => {
|
||||||
describe("authenticateThirdParty", () => {
|
describe("authenticateThirdParty", () => {
|
||||||
let thirdPartyUser
|
let thirdPartyUser
|
||||||
|
@ -36,7 +40,7 @@ describe("third party common", () => {
|
||||||
|
|
||||||
describe("validation", () => {
|
describe("validation", () => {
|
||||||
const testValidation = async (message) => {
|
const testValidation = async (message) => {
|
||||||
await authenticateThirdParty(thirdPartyUser, false, done)
|
await authenticateThirdParty(thirdPartyUser, false, done, saveUser)
|
||||||
expect(done.mock.calls.length).toBe(1)
|
expect(done.mock.calls.length).toBe(1)
|
||||||
expect(getErrorMessage()).toContain(message)
|
expect(getErrorMessage()).toContain(message)
|
||||||
}
|
}
|
||||||
|
@ -78,7 +82,7 @@ describe("third party common", () => {
|
||||||
describe("when the user doesn't exist", () => {
|
describe("when the user doesn't exist", () => {
|
||||||
describe("when a local account is required", () => {
|
describe("when a local account is required", () => {
|
||||||
it("returns an error message", async () => {
|
it("returns an error message", async () => {
|
||||||
await authenticateThirdParty(thirdPartyUser, true, done)
|
await authenticateThirdParty(thirdPartyUser, true, done, saveUser)
|
||||||
expect(done.mock.calls.length).toBe(1)
|
expect(done.mock.calls.length).toBe(1)
|
||||||
expect(getErrorMessage()).toContain("Email does not yet exist. You must set up your local budibase account first.")
|
expect(getErrorMessage()).toContain("Email does not yet exist. You must set up your local budibase account first.")
|
||||||
})
|
})
|
||||||
|
@ -86,7 +90,7 @@ describe("third party common", () => {
|
||||||
|
|
||||||
describe("when a local account isn't required", () => {
|
describe("when a local account isn't required", () => {
|
||||||
it("creates and authenticates the user", async () => {
|
it("creates and authenticates the user", async () => {
|
||||||
await authenticateThirdParty(thirdPartyUser, false, done)
|
await authenticateThirdParty(thirdPartyUser, false, done, saveUser)
|
||||||
const user = expectUserIsAuthenticated()
|
const user = expectUserIsAuthenticated()
|
||||||
expectUserIsSynced(user, thirdPartyUser)
|
expectUserIsSynced(user, thirdPartyUser)
|
||||||
expect(user.roles).toStrictEqual({})
|
expect(user.roles).toStrictEqual({})
|
||||||
|
@ -123,7 +127,7 @@ describe("third party common", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("syncs and authenticates the user", async () => {
|
it("syncs and authenticates the user", async () => {
|
||||||
await authenticateThirdParty(thirdPartyUser, true, done)
|
await authenticateThirdParty(thirdPartyUser, true, done, saveUser)
|
||||||
|
|
||||||
const user = expectUserIsAuthenticated()
|
const user = expectUserIsAuthenticated()
|
||||||
expectUserIsSynced(user, thirdPartyUser)
|
expectUserIsSynced(user, thirdPartyUser)
|
||||||
|
@ -139,7 +143,7 @@ describe("third party common", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("syncs and authenticates the user", async () => {
|
it("syncs and authenticates the user", async () => {
|
||||||
await authenticateThirdParty(thirdPartyUser, true, done)
|
await authenticateThirdParty(thirdPartyUser, true, done, saveUser)
|
||||||
|
|
||||||
const user = expectUserIsAuthenticated()
|
const user = expectUserIsAuthenticated()
|
||||||
expectUserIsSynced(user, thirdPartyUser)
|
expectUserIsSynced(user, thirdPartyUser)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
const env = require("../../environment")
|
const env = require("../../environment")
|
||||||
const jwt = require("jsonwebtoken")
|
const jwt = require("jsonwebtoken")
|
||||||
const { generateGlobalUserID } = require("../../db/utils")
|
const { generateGlobalUserID } = require("../../db/utils")
|
||||||
|
const { saveUser } = require("../../utils")
|
||||||
const { authError } = require("./utils")
|
const { authError } = require("./utils")
|
||||||
const { newid } = require("../../hashing")
|
const { newid } = require("../../hashing")
|
||||||
const { createASession } = require("../../security/sessions")
|
const { createASession } = require("../../security/sessions")
|
||||||
|
@ -14,7 +15,8 @@ const fetch = require("node-fetch")
|
||||||
exports.authenticateThirdParty = async function (
|
exports.authenticateThirdParty = async function (
|
||||||
thirdPartyUser,
|
thirdPartyUser,
|
||||||
requireLocalAccount = true,
|
requireLocalAccount = true,
|
||||||
done
|
done,
|
||||||
|
saveUserFn = saveUser
|
||||||
) {
|
) {
|
||||||
if (!thirdPartyUser.provider) {
|
if (!thirdPartyUser.provider) {
|
||||||
return authError(done, "third party user provider required")
|
return authError(done, "third party user provider required")
|
||||||
|
@ -71,7 +73,13 @@ exports.authenticateThirdParty = async function (
|
||||||
dbUser = await syncUser(dbUser, thirdPartyUser)
|
dbUser = await syncUser(dbUser, thirdPartyUser)
|
||||||
|
|
||||||
// create or sync the user
|
// create or sync the user
|
||||||
const response = await db.put(dbUser)
|
let response
|
||||||
|
try {
|
||||||
|
response = await saveUserFn(dbUser, getTenantId(), false, false)
|
||||||
|
} catch (err) {
|
||||||
|
return authError(done, err)
|
||||||
|
}
|
||||||
|
|
||||||
dbUser._rev = response.rev
|
dbUser._rev = response.rev
|
||||||
|
|
||||||
// authenticate
|
// authenticate
|
||||||
|
|
|
@ -265,7 +265,7 @@ exports.downloadTarball = async (url, bucketName, path) => {
|
||||||
|
|
||||||
const tmpPath = join(budibaseTempDir(), path)
|
const tmpPath = join(budibaseTempDir(), path)
|
||||||
await streamPipeline(response.body, zlib.Unzip(), tar.extract(tmpPath))
|
await streamPipeline(response.body, zlib.Unzip(), tar.extract(tmpPath))
|
||||||
if (!env.isTest()) {
|
if (!env.isTest() && env.SELF_HOSTED) {
|
||||||
await exports.uploadDirectory(bucketName, tmpPath, path)
|
await exports.uploadDirectory(bucketName, tmpPath, path)
|
||||||
}
|
}
|
||||||
// return the temporary path incase there is a use for it
|
// return the temporary path incase there is a use for it
|
||||||
|
|
|
@ -107,3 +107,13 @@ exports.lookupTenantId = async userId => {
|
||||||
}
|
}
|
||||||
return tenantId
|
return tenantId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lookup, could be email or userId, either will return a doc
|
||||||
|
exports.getTenantUser = async identifier => {
|
||||||
|
const db = getDB(PLATFORM_INFO_DB)
|
||||||
|
try {
|
||||||
|
return await db.get(identifier)
|
||||||
|
} catch (err) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,10 +1,24 @@
|
||||||
const { DocumentTypes, SEPARATOR, ViewNames } = require("./db/utils")
|
const {
|
||||||
|
DocumentTypes,
|
||||||
|
SEPARATOR,
|
||||||
|
ViewNames,
|
||||||
|
generateGlobalUserID,
|
||||||
|
} = require("./db/utils")
|
||||||
const jwt = require("jsonwebtoken")
|
const jwt = require("jsonwebtoken")
|
||||||
const { options } = require("./middleware/passport/jwt")
|
const { options } = require("./middleware/passport/jwt")
|
||||||
const { createUserEmailView } = require("./db/views")
|
const { createUserEmailView } = require("./db/views")
|
||||||
const { Headers } = require("./constants")
|
const { Headers, UserStatus } = require("./constants")
|
||||||
const { getGlobalDB } = require("./tenancy")
|
const {
|
||||||
|
getGlobalDB,
|
||||||
|
updateTenantId,
|
||||||
|
getTenantUser,
|
||||||
|
tryAddTenant,
|
||||||
|
} = require("./tenancy")
|
||||||
const environment = require("./environment")
|
const environment = require("./environment")
|
||||||
|
const accounts = require("./cloud/accounts")
|
||||||
|
const { hash } = require("./hashing")
|
||||||
|
const userCache = require("./cache/user")
|
||||||
|
const env = require("./environment")
|
||||||
|
|
||||||
const APP_PREFIX = DocumentTypes.APP + SEPARATOR
|
const APP_PREFIX = DocumentTypes.APP + SEPARATOR
|
||||||
|
|
||||||
|
@ -131,3 +145,93 @@ exports.getGlobalUserByEmail = async email => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exports.saveUser = async (
|
||||||
|
user,
|
||||||
|
tenantId,
|
||||||
|
hashPassword = true,
|
||||||
|
requirePassword = true
|
||||||
|
) => {
|
||||||
|
if (!tenantId) {
|
||||||
|
throw "No tenancy specified."
|
||||||
|
}
|
||||||
|
// need to set the context for this request, as specified
|
||||||
|
updateTenantId(tenantId)
|
||||||
|
// specify the tenancy incase we're making a new admin user (public)
|
||||||
|
const db = getGlobalDB(tenantId)
|
||||||
|
let { email, password, _id } = user
|
||||||
|
// make sure another user isn't using the same email
|
||||||
|
let dbUser
|
||||||
|
if (email) {
|
||||||
|
// check budibase users inside the tenant
|
||||||
|
dbUser = await exports.getGlobalUserByEmail(email)
|
||||||
|
if (dbUser != null && (dbUser._id !== _id || Array.isArray(dbUser))) {
|
||||||
|
throw `Email address ${email} already in use.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// check budibase users in other tenants
|
||||||
|
if (env.MULTI_TENANCY) {
|
||||||
|
dbUser = await getTenantUser(email)
|
||||||
|
if (dbUser != null && dbUser.tenantId !== tenantId) {
|
||||||
|
throw `Email address ${email} already in use.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check root account users in account portal
|
||||||
|
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
|
||||||
|
const account = await accounts.getAccount(email)
|
||||||
|
if (account && account.verified && account.tenantId !== tenantId) {
|
||||||
|
throw `Email address ${email} already in use.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dbUser = await db.get(_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the password, make sure one is defined
|
||||||
|
let hashedPassword
|
||||||
|
if (password) {
|
||||||
|
hashedPassword = hashPassword ? await hash(password) : password
|
||||||
|
} else if (dbUser) {
|
||||||
|
hashedPassword = dbUser.password
|
||||||
|
} else if (requirePassword) {
|
||||||
|
throw "Password must be specified."
|
||||||
|
}
|
||||||
|
|
||||||
|
_id = _id || generateGlobalUserID()
|
||||||
|
user = {
|
||||||
|
createdAt: Date.now(),
|
||||||
|
...dbUser,
|
||||||
|
...user,
|
||||||
|
_id,
|
||||||
|
password: hashedPassword,
|
||||||
|
tenantId,
|
||||||
|
}
|
||||||
|
// make sure the roles object is always present
|
||||||
|
if (!user.roles) {
|
||||||
|
user.roles = {}
|
||||||
|
}
|
||||||
|
// add the active status to a user if its not provided
|
||||||
|
if (user.status == null) {
|
||||||
|
user.status = UserStatus.ACTIVE
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await db.put({
|
||||||
|
password: hashedPassword,
|
||||||
|
...user,
|
||||||
|
})
|
||||||
|
await tryAddTenant(tenantId, _id, email)
|
||||||
|
await userCache.invalidateUser(response.id)
|
||||||
|
return {
|
||||||
|
_id: response.id,
|
||||||
|
_rev: response.rev,
|
||||||
|
email,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 409) {
|
||||||
|
throw "User exists already"
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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": "0.9.152",
|
"version": "0.9.153-alpha.1",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"svelte": "src/index.js",
|
"svelte": "src/index.js",
|
||||||
"module": "dist/bbui.es.js",
|
"module": "dist/bbui.es.js",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@budibase/builder",
|
"name": "@budibase/builder",
|
||||||
"version": "0.9.152",
|
"version": "0.9.153-alpha.1",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -65,10 +65,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@budibase/bbui": "^0.9.152",
|
"@budibase/bbui": "^0.9.153-alpha.1",
|
||||||
"@budibase/client": "^0.9.152",
|
"@budibase/client": "^0.9.153-alpha.1",
|
||||||
"@budibase/colorpicker": "1.1.2",
|
"@budibase/colorpicker": "1.1.2",
|
||||||
"@budibase/string-templates": "^0.9.152",
|
"@budibase/string-templates": "^0.9.153-alpha.1",
|
||||||
"@sentry/browser": "5.19.1",
|
"@sentry/browser": "5.19.1",
|
||||||
"@spectrum-css/page": "^3.0.1",
|
"@spectrum-css/page": "^3.0.1",
|
||||||
"@spectrum-css/vars": "^3.0.1",
|
"@spectrum-css/vars": "^3.0.1",
|
||||||
|
|
|
@ -17,7 +17,7 @@ export default class Automation {
|
||||||
this.automation.testData = data
|
this.automation.testData = data
|
||||||
}
|
}
|
||||||
|
|
||||||
addBlock(block) {
|
addBlock(block, idx) {
|
||||||
// Make sure to add trigger if doesn't exist
|
// Make sure to add trigger if doesn't exist
|
||||||
if (!this.hasTrigger() && block.type === "TRIGGER") {
|
if (!this.hasTrigger() && block.type === "TRIGGER") {
|
||||||
const trigger = { id: generate(), ...block }
|
const trigger = { id: generate(), ...block }
|
||||||
|
@ -26,10 +26,7 @@ export default class Automation {
|
||||||
}
|
}
|
||||||
|
|
||||||
const newBlock = { id: generate(), ...block }
|
const newBlock = { id: generate(), ...block }
|
||||||
this.automation.definition.steps = [
|
this.automation.definition.steps.splice(idx, 0, newBlock)
|
||||||
...this.automation.definition.steps,
|
|
||||||
newBlock,
|
|
||||||
]
|
|
||||||
return newBlock
|
return newBlock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -104,9 +104,12 @@ const automationActions = store => ({
|
||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
addBlockToAutomation: block => {
|
addBlockToAutomation: (block, blockIdx) => {
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
const newBlock = state.selectedAutomation.addBlock(cloneDeep(block))
|
const newBlock = state.selectedAutomation.addBlock(
|
||||||
|
cloneDeep(block),
|
||||||
|
blockIdx
|
||||||
|
)
|
||||||
state.selectedBlock = newBlock
|
state.selectedBlock = newBlock
|
||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
<script>
|
<script>
|
||||||
import { ModalContent, Layout, Detail, Body, Icon } from "@budibase/bbui"
|
import { ModalContent, Layout, Detail, Body, Icon } from "@budibase/bbui"
|
||||||
import { automationStore } from "builderStore"
|
import { automationStore } from "builderStore"
|
||||||
import { database } from "stores/backend"
|
|
||||||
import { externalActions } from "./ExternalActions"
|
import { externalActions } from "./ExternalActions"
|
||||||
$: instanceId = $database._id
|
|
||||||
|
|
||||||
|
export let blockIdx
|
||||||
let selectedAction
|
let selectedAction
|
||||||
let actionVal
|
let actionVal
|
||||||
let actions = Object.entries($automationStore.blockDefinitions.ACTION)
|
let actions = Object.entries($automationStore.blockDefinitions.ACTION)
|
||||||
|
@ -39,7 +38,8 @@
|
||||||
)
|
)
|
||||||
automationStore.actions.addBlockToAutomation(newBlock)
|
automationStore.actions.addBlockToAutomation(newBlock)
|
||||||
await automationStore.actions.save(
|
await automationStore.actions.save(
|
||||||
$automationStore.selectedAutomation?.automation
|
$automationStore.selectedAutomation?.automation,
|
||||||
|
blockIdx
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -4,9 +4,9 @@
|
||||||
import FlowItem from "./FlowItem.svelte"
|
import FlowItem from "./FlowItem.svelte"
|
||||||
import TestDataModal from "./TestDataModal.svelte"
|
import TestDataModal from "./TestDataModal.svelte"
|
||||||
import { flip } from "svelte/animate"
|
import { flip } from "svelte/animate"
|
||||||
import { fade, fly } from "svelte/transition"
|
import { fly } from "svelte/transition"
|
||||||
import {
|
import {
|
||||||
Detail,
|
Heading,
|
||||||
Icon,
|
Icon,
|
||||||
ActionButton,
|
ActionButton,
|
||||||
notifications,
|
notifications,
|
||||||
|
@ -57,26 +57,24 @@
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<div class="subtitle">
|
<div class="subtitle">
|
||||||
<Detail size="L">{automation.name}</Detail>
|
<Heading size="S">{automation.name}</Heading>
|
||||||
<div
|
<div style="display:flex;">
|
||||||
style="display:flex;
|
<div class="iconPadding">
|
||||||
color: var(--spectrum-global-color-gray-400);"
|
|
||||||
>
|
|
||||||
<span class="iconPadding">
|
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<Icon
|
<Icon
|
||||||
on:click={confirmDeleteDialog.show}
|
on:click={confirmDeleteDialog.show}
|
||||||
hoverable
|
hoverable
|
||||||
|
size="M"
|
||||||
name="DeleteOutline"
|
name="DeleteOutline"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</div>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
testDataModal.show()
|
testDataModal.show()
|
||||||
}}
|
}}
|
||||||
icon="MultipleCheck"
|
icon="MultipleCheck"
|
||||||
size="S">Run test</ActionButton
|
size="M">Run test</ActionButton
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -84,16 +82,11 @@
|
||||||
{#each blocks as block, idx (block.id)}
|
{#each blocks as block, idx (block.id)}
|
||||||
<div
|
<div
|
||||||
class="block"
|
class="block"
|
||||||
animate:flip={{ duration: 800 }}
|
animate:flip={{ duration: 500 }}
|
||||||
in:fade|local
|
in:fly|local={{ x: 500, duration: 1500 }}
|
||||||
out:fly|local={{ x: 500 }}
|
out:fly|local={{ x: 500, duration: 800 }}
|
||||||
>
|
>
|
||||||
<FlowItem {testDataModal} {testAutomation} {onSelect} {block} />
|
<FlowItem {testDataModal} {testAutomation} {onSelect} {block} />
|
||||||
{#if idx !== blocks.length - 1}
|
|
||||||
<div class="separator" />
|
|
||||||
<Icon name="AddCircle" size="S" />
|
|
||||||
<div class="separator" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
@ -114,14 +107,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.separator {
|
|
||||||
width: 1px;
|
|
||||||
height: 25px;
|
|
||||||
border-left: 1px dashed var(--grey-4);
|
|
||||||
color: var(--grey-4);
|
|
||||||
/* center horizontally */
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
.canvas {
|
.canvas {
|
||||||
margin: 0 -40px calc(-1 * var(--spacing-l)) -40px;
|
margin: 0 -40px calc(-1 * var(--spacing-l)) -40px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
@ -153,11 +138,14 @@
|
||||||
padding-bottom: var(--spacing-xl);
|
padding-bottom: var(--spacing-xl);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.iconPadding {
|
||||||
|
padding-top: var(--spacing-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
|
||||||
padding-right: var(--spacing-m);
|
padding-right: var(--spacing-m);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -14,7 +14,6 @@
|
||||||
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
|
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
|
||||||
import ResultsModal from "./ResultsModal.svelte"
|
import ResultsModal from "./ResultsModal.svelte"
|
||||||
import ActionModal from "./ActionModal.svelte"
|
import ActionModal from "./ActionModal.svelte"
|
||||||
import { database } from "stores/backend"
|
|
||||||
import { externalActions } from "./ExternalActions"
|
import { externalActions } from "./ExternalActions"
|
||||||
|
|
||||||
export let onSelect
|
export let onSelect
|
||||||
|
@ -29,7 +28,6 @@
|
||||||
$: testResult = $automationStore.selectedAutomation.testResults?.steps.filter(
|
$: testResult = $automationStore.selectedAutomation.testResults?.steps.filter(
|
||||||
step => step.stepId === block.stepId
|
step => step.stepId === block.stepId
|
||||||
)
|
)
|
||||||
$: instanceId = $database._id
|
|
||||||
|
|
||||||
$: isTrigger = block.type === "TRIGGER"
|
$: isTrigger = block.type === "TRIGGER"
|
||||||
|
|
||||||
|
@ -40,6 +38,10 @@
|
||||||
$: blockIdx = steps.findIndex(step => step.id === block.id)
|
$: blockIdx = steps.findIndex(step => step.id === block.id)
|
||||||
$: lastStep = !isTrigger && blockIdx + 1 === steps.length
|
$: lastStep = !isTrigger && blockIdx + 1 === steps.length
|
||||||
|
|
||||||
|
$: totalBlocks =
|
||||||
|
$automationStore.selectedAutomation?.automation?.definition?.steps.length +
|
||||||
|
1
|
||||||
|
|
||||||
// Logic for hiding / showing the add button.first we check if it has a child
|
// Logic for hiding / showing the add button.first we check if it has a child
|
||||||
// then we check to see whether its inputs have been commpleted
|
// then we check to see whether its inputs have been commpleted
|
||||||
$: disableAddButton = isTrigger
|
$: disableAddButton = isTrigger
|
||||||
|
@ -167,13 +169,24 @@
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal bind:this={actionModal} width="30%">
|
<Modal bind:this={actionModal} width="30%">
|
||||||
<ActionModal bind:blockComplete />
|
<ActionModal {blockIdx} bind:blockComplete />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal bind:this={webhookModal} width="30%">
|
<Modal bind:this={webhookModal} width="30%">
|
||||||
<CreateWebhookModal />
|
<CreateWebhookModal />
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="separator" />
|
||||||
|
<Icon
|
||||||
|
on:click={() => actionModal.show()}
|
||||||
|
disabled={!hasCompletedInputs}
|
||||||
|
hoverable
|
||||||
|
name="AddCircle"
|
||||||
|
size="S"
|
||||||
|
/>
|
||||||
|
{#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2}
|
||||||
|
<div class="separator" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.center-items {
|
.center-items {
|
||||||
|
@ -191,8 +204,7 @@
|
||||||
.block {
|
.block {
|
||||||
width: 360px;
|
width: 360px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
background-color: var(--spectrum-alias-background-color-secondary);
|
background-color: var(--background);
|
||||||
color: var(--grey-9);
|
|
||||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||||
border-radius: 4px 4px 4px 4px;
|
border-radius: 4px 4px 4px 4px;
|
||||||
}
|
}
|
||||||
|
@ -200,4 +212,13 @@
|
||||||
.blockSection {
|
.blockSection {
|
||||||
padding: var(--spacing-xl);
|
padding: var(--spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 25px;
|
||||||
|
border-left: 1px dashed var(--grey-4);
|
||||||
|
color: var(--grey-4);
|
||||||
|
/* center horizontally */
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -5,24 +5,29 @@
|
||||||
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte"
|
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte"
|
||||||
import { datasources } from "stores/backend"
|
import { datasources } from "stores/backend"
|
||||||
import { IntegrationNames } from "constants"
|
import { IntegrationNames } from "constants"
|
||||||
|
import cloneDeep from "lodash/cloneDeepWith"
|
||||||
|
|
||||||
export let integration
|
export let integration
|
||||||
|
export let modal
|
||||||
|
|
||||||
|
// kill the reference so the input isn't saved
|
||||||
|
let config = cloneDeep(integration)
|
||||||
|
|
||||||
function prepareData() {
|
function prepareData() {
|
||||||
let datasource = {}
|
let datasource = {}
|
||||||
let existingTypeCount = $datasources.list.filter(
|
let existingTypeCount = $datasources.list.filter(
|
||||||
ds => ds.source == integration.type
|
ds => ds.source == config.type
|
||||||
).length
|
).length
|
||||||
|
|
||||||
let baseName = IntegrationNames[integration.type]
|
let baseName = IntegrationNames[config.type]
|
||||||
let name =
|
let name =
|
||||||
existingTypeCount == 0 ? baseName : `${baseName}-${existingTypeCount + 1}`
|
existingTypeCount == 0 ? baseName : `${baseName}-${existingTypeCount + 1}`
|
||||||
|
|
||||||
datasource.type = "datasource"
|
datasource.type = "datasource"
|
||||||
datasource.source = integration.type
|
datasource.source = config.type
|
||||||
datasource.config = integration.config
|
datasource.config = config.config
|
||||||
datasource.name = name
|
datasource.name = name
|
||||||
datasource.plus = integration.plus
|
datasource.plus = config.plus
|
||||||
|
|
||||||
return datasource
|
return datasource
|
||||||
}
|
}
|
||||||
|
@ -48,9 +53,10 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ModalContent
|
<ModalContent
|
||||||
title={`Connect to ${IntegrationNames[integration.type]}`}
|
title={`Connect to ${IntegrationNames[config.type]}`}
|
||||||
onConfirm={() => saveDatasource()}
|
onConfirm={() => saveDatasource()}
|
||||||
confirmText={integration.plus
|
onCancel={() => modal.show()}
|
||||||
|
confirmText={config.plus
|
||||||
? "Fetch tables from database"
|
? "Fetch tables from database"
|
||||||
: "Save and continue to query"}
|
: "Save and continue to query"}
|
||||||
cancelText="Back"
|
cancelText="Back"
|
||||||
|
@ -62,10 +68,7 @@
|
||||||
</Body>
|
</Body>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<IntegrationConfigForm
|
<IntegrationConfigForm schema={config.schema} integration={config.config} />
|
||||||
schema={integration.schema}
|
|
||||||
bind:integration={integration.config}
|
|
||||||
/>
|
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
@ -27,13 +27,13 @@
|
||||||
urlTenantId = hostParts[0]
|
urlTenantId = hostParts[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// no tenant in the url - send to account portal to fix this
|
|
||||||
if (!urlTenantId) {
|
|
||||||
window.location.href = $admin.accountPortalUrl
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user && user.tenantId) {
|
if (user && user.tenantId) {
|
||||||
|
// no tenant in the url - send to account portal to fix this
|
||||||
|
if (!urlTenantId) {
|
||||||
|
window.location.href = $admin.accountPortalUrl
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (user.tenantId !== urlTenantId) {
|
if (user.tenantId !== urlTenantId) {
|
||||||
// user should not be here - play it safe and log them out
|
// user should not be here - play it safe and log them out
|
||||||
await auth.logout()
|
await auth.logout()
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
Input,
|
Input,
|
||||||
Layout,
|
Layout,
|
||||||
notifications,
|
notifications,
|
||||||
|
Link,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import { goto, params } from "@roxi/routify"
|
import { goto, params } from "@roxi/routify"
|
||||||
import { auth, organisation, oidc, admin } from "stores/portal"
|
import { auth, organisation, oidc, admin } from "stores/portal"
|
||||||
|
@ -97,6 +98,16 @@
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
{/if}
|
{/if}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
{#if cloud}
|
||||||
|
<Body size="xs" textAlign="center">
|
||||||
|
By using Budibase Cloud
|
||||||
|
<br />
|
||||||
|
you are agreeing to our
|
||||||
|
<Link href="https://budibase.com/eula" target="_blank"
|
||||||
|
>License Agreement</Link
|
||||||
|
>
|
||||||
|
</Body>
|
||||||
|
{/if}
|
||||||
</Layout>
|
</Layout>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@budibase/cli",
|
"name": "@budibase/cli",
|
||||||
"version": "0.9.152",
|
"version": "0.9.153-alpha.1",
|
||||||
"description": "Budibase CLI, for developers, self hosting and migrations.",
|
"description": "Budibase CLI, for developers, self hosting and migrations.",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@budibase/client",
|
"name": "@budibase/client",
|
||||||
"version": "0.9.152",
|
"version": "0.9.153-alpha.1",
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"module": "dist/budibase-client.js",
|
"module": "dist/budibase-client.js",
|
||||||
"main": "dist/budibase-client.js",
|
"main": "dist/budibase-client.js",
|
||||||
|
@ -19,9 +19,9 @@
|
||||||
"dev:builder": "rollup -cw"
|
"dev:builder": "rollup -cw"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@budibase/bbui": "^0.9.152",
|
"@budibase/bbui": "^0.9.153-alpha.1",
|
||||||
"@budibase/standard-components": "^0.9.139",
|
"@budibase/standard-components": "^0.9.139",
|
||||||
"@budibase/string-templates": "^0.9.152",
|
"@budibase/string-templates": "^0.9.153-alpha.1",
|
||||||
"regexparam": "^1.3.0",
|
"regexparam": "^1.3.0",
|
||||||
"shortid": "^2.2.15",
|
"shortid": "^2.2.15",
|
||||||
"svelte-spa-router": "^3.0.5"
|
"svelte-spa-router": "^3.0.5"
|
||||||
|
|
|
@ -353,7 +353,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reduce padding */
|
/* Reduce padding */
|
||||||
.mobile .main {
|
.mobile:not(.layout--none) .main {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,17 @@
|
||||||
if (!bounds || !side) {
|
if (!bounds || !side) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const { left, top, width, height } = bounds
|
|
||||||
|
// Get preview offset
|
||||||
|
const root = document.getElementById("clip-root")
|
||||||
|
const rootBounds = root.getBoundingClientRect()
|
||||||
|
|
||||||
|
// Subtract preview offset from bounds
|
||||||
|
let { left, top, width, height } = bounds
|
||||||
|
left -= rootBounds.left
|
||||||
|
top -= rootBounds.top
|
||||||
|
|
||||||
|
// Determine position
|
||||||
if (side === Sides.Top || side === Sides.Bottom) {
|
if (side === Sides.Top || side === Sides.Bottom) {
|
||||||
return {
|
return {
|
||||||
top: side === Sides.Top ? top - 4 : top + height,
|
top: side === Sides.Top ? top - 4 : top + height,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@budibase/server",
|
"name": "@budibase/server",
|
||||||
"email": "hi@budibase.com",
|
"email": "hi@budibase.com",
|
||||||
"version": "0.9.152",
|
"version": "0.9.153-alpha.1",
|
||||||
"description": "Budibase Web Server",
|
"description": "Budibase Web Server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
@ -66,9 +66,9 @@
|
||||||
"author": "Budibase",
|
"author": "Budibase",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@budibase/auth": "^0.9.152",
|
"@budibase/auth": "^0.9.153-alpha.1",
|
||||||
"@budibase/client": "^0.9.152",
|
"@budibase/client": "^0.9.153-alpha.1",
|
||||||
"@budibase/string-templates": "^0.9.152",
|
"@budibase/string-templates": "^0.9.153-alpha.1",
|
||||||
"@elastic/elasticsearch": "7.10.0",
|
"@elastic/elasticsearch": "7.10.0",
|
||||||
"@koa/router": "8.0.0",
|
"@koa/router": "8.0.0",
|
||||||
"@sendgrid/mail": "7.1.1",
|
"@sendgrid/mail": "7.1.1",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@budibase/string-templates",
|
"name": "@budibase/string-templates",
|
||||||
"version": "0.9.152",
|
"version": "0.9.153-alpha.1",
|
||||||
"description": "Handlebars wrapper for Budibase templating.",
|
"description": "Handlebars wrapper for Budibase templating.",
|
||||||
"main": "src/index.cjs",
|
"main": "src/index.cjs",
|
||||||
"module": "dist/bundle.mjs",
|
"module": "dist/bundle.mjs",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@budibase/worker",
|
"name": "@budibase/worker",
|
||||||
"email": "hi@budibase.com",
|
"email": "hi@budibase.com",
|
||||||
"version": "0.9.152",
|
"version": "0.9.153-alpha.1",
|
||||||
"description": "Budibase background service",
|
"description": "Budibase background service",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
@ -27,8 +27,8 @@
|
||||||
"author": "Budibase",
|
"author": "Budibase",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@budibase/auth": "^0.9.152",
|
"@budibase/auth": "^0.9.153-alpha.1",
|
||||||
"@budibase/string-templates": "^0.9.152",
|
"@budibase/string-templates": "^0.9.153-alpha.1",
|
||||||
"@koa/router": "^8.0.0",
|
"@koa/router": "^8.0.0",
|
||||||
"@techpass/passport-openidconnect": "^0.3.0",
|
"@techpass/passport-openidconnect": "^0.3.0",
|
||||||
"aws-sdk": "^2.811.0",
|
"aws-sdk": "^2.811.0",
|
||||||
|
|
|
@ -1,29 +1,24 @@
|
||||||
const {
|
const {
|
||||||
generateGlobalUserID,
|
|
||||||
getGlobalUserParams,
|
getGlobalUserParams,
|
||||||
StaticDatabases,
|
StaticDatabases,
|
||||||
generateNewUsageQuotaDoc,
|
generateNewUsageQuotaDoc,
|
||||||
} = require("@budibase/auth/db")
|
} = require("@budibase/auth/db")
|
||||||
const { hash, getGlobalUserByEmail } = require("@budibase/auth").utils
|
const { hash, getGlobalUserByEmail, saveUser } = require("@budibase/auth").utils
|
||||||
const { UserStatus, EmailTemplatePurpose } = require("../../../constants")
|
const { EmailTemplatePurpose } = require("../../../constants")
|
||||||
const { checkInviteCode } = require("../../../utilities/redis")
|
const { checkInviteCode } = require("../../../utilities/redis")
|
||||||
const { sendEmail } = require("../../../utilities/email")
|
const { sendEmail } = require("../../../utilities/email")
|
||||||
const { user: userCache } = require("@budibase/auth/cache")
|
const { user: userCache } = require("@budibase/auth/cache")
|
||||||
const { invalidateSessions } = require("@budibase/auth/sessions")
|
const { invalidateSessions } = require("@budibase/auth/sessions")
|
||||||
const CouchDB = require("../../../db")
|
|
||||||
const accounts = require("@budibase/auth/accounts")
|
const accounts = require("@budibase/auth/accounts")
|
||||||
const {
|
const {
|
||||||
getGlobalDB,
|
getGlobalDB,
|
||||||
getTenantId,
|
getTenantId,
|
||||||
|
getTenantUser,
|
||||||
doesTenantExist,
|
doesTenantExist,
|
||||||
tryAddTenant,
|
|
||||||
updateTenantId,
|
|
||||||
} = require("@budibase/auth/tenancy")
|
} = require("@budibase/auth/tenancy")
|
||||||
const { removeUserFromInfoDB } = require("@budibase/auth/deprovision")
|
const { removeUserFromInfoDB } = require("@budibase/auth/deprovision")
|
||||||
const env = require("../../../environment")
|
const env = require("../../../environment")
|
||||||
|
|
||||||
const PLATFORM_INFO_DB = StaticDatabases.PLATFORM_INFO.name
|
|
||||||
|
|
||||||
async function allUsers() {
|
async function allUsers() {
|
||||||
const db = getGlobalDB()
|
const db = getGlobalDB()
|
||||||
const response = await db.allDocs(
|
const response = await db.allDocs(
|
||||||
|
@ -34,96 +29,6 @@ async function allUsers() {
|
||||||
return response.rows.map(row => row.doc)
|
return response.rows.map(row => row.doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveUser(
|
|
||||||
user,
|
|
||||||
tenantId,
|
|
||||||
hashPassword = true,
|
|
||||||
requirePassword = true
|
|
||||||
) {
|
|
||||||
if (!tenantId) {
|
|
||||||
throw "No tenancy specified."
|
|
||||||
}
|
|
||||||
// need to set the context for this request, as specified
|
|
||||||
updateTenantId(tenantId)
|
|
||||||
// specify the tenancy incase we're making a new admin user (public)
|
|
||||||
const db = getGlobalDB(tenantId)
|
|
||||||
let { email, password, _id } = user
|
|
||||||
// make sure another user isn't using the same email
|
|
||||||
let dbUser
|
|
||||||
if (email) {
|
|
||||||
// check budibase users inside the tenant
|
|
||||||
dbUser = await getGlobalUserByEmail(email)
|
|
||||||
if (dbUser != null && (dbUser._id !== _id || Array.isArray(dbUser))) {
|
|
||||||
throw `Email address ${email} already in use.`
|
|
||||||
}
|
|
||||||
|
|
||||||
// check budibase users in other tenants
|
|
||||||
if (env.MULTI_TENANCY) {
|
|
||||||
dbUser = await getTenantUser(email)
|
|
||||||
if (dbUser != null && dbUser.tenantId !== tenantId) {
|
|
||||||
throw `Email address ${email} already in use.`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// check root account users in account portal
|
|
||||||
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
|
|
||||||
const account = await accounts.getAccount(email)
|
|
||||||
if (account && account.verified && account.tenantId !== tenantId) {
|
|
||||||
throw `Email address ${email} already in use.`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
dbUser = await db.get(_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// get the password, make sure one is defined
|
|
||||||
let hashedPassword
|
|
||||||
if (password) {
|
|
||||||
hashedPassword = hashPassword ? await hash(password) : password
|
|
||||||
} else if (dbUser) {
|
|
||||||
hashedPassword = dbUser.password
|
|
||||||
} else if (requirePassword) {
|
|
||||||
throw "Password must be specified."
|
|
||||||
}
|
|
||||||
|
|
||||||
_id = _id || generateGlobalUserID()
|
|
||||||
user = {
|
|
||||||
createdAt: Date.now(),
|
|
||||||
...dbUser,
|
|
||||||
...user,
|
|
||||||
_id,
|
|
||||||
password: hashedPassword,
|
|
||||||
tenantId,
|
|
||||||
}
|
|
||||||
// make sure the roles object is always present
|
|
||||||
if (!user.roles) {
|
|
||||||
user.roles = {}
|
|
||||||
}
|
|
||||||
// add the active status to a user if its not provided
|
|
||||||
if (user.status == null) {
|
|
||||||
user.status = UserStatus.ACTIVE
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await db.put({
|
|
||||||
password: hashedPassword,
|
|
||||||
...user,
|
|
||||||
})
|
|
||||||
await tryAddTenant(tenantId, _id, email)
|
|
||||||
await userCache.invalidateUser(response.id)
|
|
||||||
return {
|
|
||||||
_id: response.id,
|
|
||||||
_rev: response.rev,
|
|
||||||
email,
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err.status === 409) {
|
|
||||||
throw "User exists already"
|
|
||||||
} else {
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.save = async ctx => {
|
exports.save = async ctx => {
|
||||||
try {
|
try {
|
||||||
ctx.body = await saveUser(ctx.request.body, getTenantId())
|
ctx.body = await saveUser(ctx.request.body, getTenantId())
|
||||||
|
@ -310,16 +215,6 @@ exports.find = async ctx => {
|
||||||
ctx.body = user
|
ctx.body = user
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookup, could be email or userId, either will return a doc
|
|
||||||
const getTenantUser = async identifier => {
|
|
||||||
const db = new CouchDB(PLATFORM_INFO_DB)
|
|
||||||
try {
|
|
||||||
return await db.get(identifier)
|
|
||||||
} catch (err) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.tenantUserLookup = async ctx => {
|
exports.tenantUserLookup = async ctx => {
|
||||||
const id = ctx.params.id
|
const id = ctx.params.id
|
||||||
const user = await getTenantUser(id)
|
const user = await getTenantUser(id)
|
||||||
|
|
Loading…
Reference in a new issue