1
0
Fork 0
mirror of synced 2024-07-01 04:21:06 +12:00

Merge pull request #2904 from Budibase/develop

Develop -> Master
This commit is contained in:
Martin McKeaveney 2021-10-06 16:26:05 +01:00 committed by GitHub
commit 526584d179
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 253 additions and 199 deletions

View file

@ -1,5 +1,5 @@
{
"version": "0.9.152",
"version": "0.9.153-alpha.1",
"npmClient": "yarn",
"packages": [
"packages/*"

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/auth",
"version": "0.9.152",
"version": "0.9.153-alpha.1",
"description": "Authentication middlewares for budibase builder and apps",
"main": "src/index.js",
"author": "Budibase",

View file

@ -20,6 +20,10 @@ const getErrorMessage = () => {
return done.mock.calls[0][2].message
}
const saveUser = async (user) => {
return await db.put(user)
}
describe("third party common", () => {
describe("authenticateThirdParty", () => {
let thirdPartyUser
@ -36,7 +40,7 @@ describe("third party common", () => {
describe("validation", () => {
const testValidation = async (message) => {
await authenticateThirdParty(thirdPartyUser, false, done)
await authenticateThirdParty(thirdPartyUser, false, done, saveUser)
expect(done.mock.calls.length).toBe(1)
expect(getErrorMessage()).toContain(message)
}
@ -78,7 +82,7 @@ describe("third party common", () => {
describe("when the user doesn't exist", () => {
describe("when a local account is required", () => {
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(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", () => {
it("creates and authenticates the user", async () => {
await authenticateThirdParty(thirdPartyUser, false, done)
await authenticateThirdParty(thirdPartyUser, false, done, saveUser)
const user = expectUserIsAuthenticated()
expectUserIsSynced(user, thirdPartyUser)
expect(user.roles).toStrictEqual({})
@ -123,7 +127,7 @@ describe("third party common", () => {
})
it("syncs and authenticates the user", async () => {
await authenticateThirdParty(thirdPartyUser, true, done)
await authenticateThirdParty(thirdPartyUser, true, done, saveUser)
const user = expectUserIsAuthenticated()
expectUserIsSynced(user, thirdPartyUser)
@ -139,7 +143,7 @@ describe("third party common", () => {
})
it("syncs and authenticates the user", async () => {
await authenticateThirdParty(thirdPartyUser, true, done)
await authenticateThirdParty(thirdPartyUser, true, done, saveUser)
const user = expectUserIsAuthenticated()
expectUserIsSynced(user, thirdPartyUser)

View file

@ -1,6 +1,7 @@
const env = require("../../environment")
const jwt = require("jsonwebtoken")
const { generateGlobalUserID } = require("../../db/utils")
const { saveUser } = require("../../utils")
const { authError } = require("./utils")
const { newid } = require("../../hashing")
const { createASession } = require("../../security/sessions")
@ -14,7 +15,8 @@ const fetch = require("node-fetch")
exports.authenticateThirdParty = async function (
thirdPartyUser,
requireLocalAccount = true,
done
done,
saveUserFn = saveUser
) {
if (!thirdPartyUser.provider) {
return authError(done, "third party user provider required")
@ -71,7 +73,13 @@ exports.authenticateThirdParty = async function (
dbUser = await syncUser(dbUser, thirdPartyUser)
// 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
// authenticate

View file

@ -265,7 +265,7 @@ exports.downloadTarball = async (url, bucketName, path) => {
const tmpPath = join(budibaseTempDir(), path)
await streamPipeline(response.body, zlib.Unzip(), tar.extract(tmpPath))
if (!env.isTest()) {
if (!env.isTest() && env.SELF_HOSTED) {
await exports.uploadDirectory(bucketName, tmpPath, path)
}
// return the temporary path incase there is a use for it

View file

@ -107,3 +107,13 @@ exports.lookupTenantId = async userId => {
}
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
}
}

View file

@ -1,10 +1,24 @@
const { DocumentTypes, SEPARATOR, ViewNames } = require("./db/utils")
const {
DocumentTypes,
SEPARATOR,
ViewNames,
generateGlobalUserID,
} = require("./db/utils")
const jwt = require("jsonwebtoken")
const { options } = require("./middleware/passport/jwt")
const { createUserEmailView } = require("./db/views")
const { Headers } = require("./constants")
const { getGlobalDB } = require("./tenancy")
const { Headers, UserStatus } = require("./constants")
const {
getGlobalDB,
updateTenantId,
getTenantUser,
tryAddTenant,
} = require("./tenancy")
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
@ -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
}
}
}

View file

@ -1,7 +1,7 @@
{
"name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.",
"version": "0.9.152",
"version": "0.9.153-alpha.1",
"license": "AGPL-3.0",
"svelte": "src/index.js",
"module": "dist/bbui.es.js",

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/builder",
"version": "0.9.152",
"version": "0.9.153-alpha.1",
"license": "AGPL-3.0",
"private": true,
"scripts": {
@ -65,10 +65,10 @@
}
},
"dependencies": {
"@budibase/bbui": "^0.9.152",
"@budibase/client": "^0.9.152",
"@budibase/bbui": "^0.9.153-alpha.1",
"@budibase/client": "^0.9.153-alpha.1",
"@budibase/colorpicker": "1.1.2",
"@budibase/string-templates": "^0.9.152",
"@budibase/string-templates": "^0.9.153-alpha.1",
"@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1",

View file

@ -17,7 +17,7 @@ export default class Automation {
this.automation.testData = data
}
addBlock(block) {
addBlock(block, idx) {
// Make sure to add trigger if doesn't exist
if (!this.hasTrigger() && block.type === "TRIGGER") {
const trigger = { id: generate(), ...block }
@ -26,10 +26,7 @@ export default class Automation {
}
const newBlock = { id: generate(), ...block }
this.automation.definition.steps = [
...this.automation.definition.steps,
newBlock,
]
this.automation.definition.steps.splice(idx, 0, newBlock)
return newBlock
}

View file

@ -104,9 +104,12 @@ const automationActions = store => ({
return state
})
},
addBlockToAutomation: block => {
addBlockToAutomation: (block, blockIdx) => {
store.update(state => {
const newBlock = state.selectedAutomation.addBlock(cloneDeep(block))
const newBlock = state.selectedAutomation.addBlock(
cloneDeep(block),
blockIdx
)
state.selectedBlock = newBlock
return state
})

View file

@ -1,10 +1,9 @@
<script>
import { ModalContent, Layout, Detail, Body, Icon } from "@budibase/bbui"
import { automationStore } from "builderStore"
import { database } from "stores/backend"
import { externalActions } from "./ExternalActions"
$: instanceId = $database._id
export let blockIdx
let selectedAction
let actionVal
let actions = Object.entries($automationStore.blockDefinitions.ACTION)
@ -39,7 +38,8 @@
)
automationStore.actions.addBlockToAutomation(newBlock)
await automationStore.actions.save(
$automationStore.selectedAutomation?.automation
$automationStore.selectedAutomation?.automation,
blockIdx
)
}
</script>

View file

@ -4,9 +4,9 @@
import FlowItem from "./FlowItem.svelte"
import TestDataModal from "./TestDataModal.svelte"
import { flip } from "svelte/animate"
import { fade, fly } from "svelte/transition"
import { fly } from "svelte/transition"
import {
Detail,
Heading,
Icon,
ActionButton,
notifications,
@ -57,26 +57,24 @@
<div class="content">
<div class="title">
<div class="subtitle">
<Detail size="L">{automation.name}</Detail>
<div
style="display:flex;
color: var(--spectrum-global-color-gray-400);"
>
<span class="iconPadding">
<Heading size="S">{automation.name}</Heading>
<div style="display:flex;">
<div class="iconPadding">
<div class="icon">
<Icon
on:click={confirmDeleteDialog.show}
hoverable
size="M"
name="DeleteOutline"
/>
</div>
</span>
</div>
<ActionButton
on:click={() => {
testDataModal.show()
}}
icon="MultipleCheck"
size="S">Run test</ActionButton
size="M">Run test</ActionButton
>
</div>
</div>
@ -84,16 +82,11 @@
{#each blocks as block, idx (block.id)}
<div
class="block"
animate:flip={{ duration: 800 }}
in:fade|local
out:fly|local={{ x: 500 }}
animate:flip={{ duration: 500 }}
in:fly|local={{ x: 500, duration: 1500 }}
out:fly|local={{ x: 500, duration: 800 }}
>
<FlowItem {testDataModal} {testAutomation} {onSelect} {block} />
{#if idx !== blocks.length - 1}
<div class="separator" />
<Icon name="AddCircle" size="S" />
<div class="separator" />
{/if}
</div>
{/each}
</div>
@ -114,14 +107,6 @@
</div>
<style>
.separator {
width: 1px;
height: 25px;
border-left: 1px dashed var(--grey-4);
color: var(--grey-4);
/* center horizontally */
align-self: center;
}
.canvas {
margin: 0 -40px calc(-1 * var(--spacing-l)) -40px;
overflow-y: auto;
@ -153,11 +138,14 @@
padding-bottom: var(--spacing-xl);
display: flex;
justify-content: space-between;
align-items: center;
}
.iconPadding {
padding-top: var(--spacing-s);
}
.icon {
cursor: pointer;
display: flex;
padding-right: var(--spacing-m);
}
</style>

View file

@ -14,7 +14,6 @@
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import ResultsModal from "./ResultsModal.svelte"
import ActionModal from "./ActionModal.svelte"
import { database } from "stores/backend"
import { externalActions } from "./ExternalActions"
export let onSelect
@ -29,7 +28,6 @@
$: testResult = $automationStore.selectedAutomation.testResults?.steps.filter(
step => step.stepId === block.stepId
)
$: instanceId = $database._id
$: isTrigger = block.type === "TRIGGER"
@ -40,6 +38,10 @@
$: blockIdx = steps.findIndex(step => step.id === block.id)
$: 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
// then we check to see whether its inputs have been commpleted
$: disableAddButton = isTrigger
@ -167,13 +169,24 @@
</Modal>
<Modal bind:this={actionModal} width="30%">
<ActionModal bind:blockComplete />
<ActionModal {blockIdx} bind:blockComplete />
</Modal>
<Modal bind:this={webhookModal} width="30%">
<CreateWebhookModal />
</Modal>
</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>
.center-items {
@ -191,8 +204,7 @@
.block {
width: 360px;
font-size: 16px;
background-color: var(--spectrum-alias-background-color-secondary);
color: var(--grey-9);
background-color: var(--background);
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px 4px 4px 4px;
}
@ -200,4 +212,13 @@
.blockSection {
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>

View file

@ -5,24 +5,29 @@
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte"
import { datasources } from "stores/backend"
import { IntegrationNames } from "constants"
import cloneDeep from "lodash/cloneDeepWith"
export let integration
export let modal
// kill the reference so the input isn't saved
let config = cloneDeep(integration)
function prepareData() {
let datasource = {}
let existingTypeCount = $datasources.list.filter(
ds => ds.source == integration.type
ds => ds.source == config.type
).length
let baseName = IntegrationNames[integration.type]
let baseName = IntegrationNames[config.type]
let name =
existingTypeCount == 0 ? baseName : `${baseName}-${existingTypeCount + 1}`
datasource.type = "datasource"
datasource.source = integration.type
datasource.config = integration.config
datasource.source = config.type
datasource.config = config.config
datasource.name = name
datasource.plus = integration.plus
datasource.plus = config.plus
return datasource
}
@ -48,9 +53,10 @@
</script>
<ModalContent
title={`Connect to ${IntegrationNames[integration.type]}`}
title={`Connect to ${IntegrationNames[config.type]}`}
onConfirm={() => saveDatasource()}
confirmText={integration.plus
onCancel={() => modal.show()}
confirmText={config.plus
? "Fetch tables from database"
: "Save and continue to query"}
cancelText="Back"
@ -62,10 +68,7 @@
</Body>
</Layout>
<IntegrationConfigForm
schema={integration.schema}
bind:integration={integration.config}
/>
<IntegrationConfigForm schema={config.schema} integration={config.config} />
</ModalContent>
<style>

View file

@ -27,13 +27,13 @@
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) {
// no tenant in the url - send to account portal to fix this
if (!urlTenantId) {
window.location.href = $admin.accountPortalUrl
return
}
if (user.tenantId !== urlTenantId) {
// user should not be here - play it safe and log them out
await auth.logout()

View file

@ -8,6 +8,7 @@
Input,
Layout,
notifications,
Link,
} from "@budibase/bbui"
import { goto, params } from "@roxi/routify"
import { auth, organisation, oidc, admin } from "stores/portal"
@ -97,6 +98,16 @@
</ActionButton>
{/if}
</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>
</div>
</div>

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/cli",
"version": "0.9.152",
"version": "0.9.153-alpha.1",
"description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js",
"bin": {

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/client",
"version": "0.9.152",
"version": "0.9.153-alpha.1",
"license": "MPL-2.0",
"module": "dist/budibase-client.js",
"main": "dist/budibase-client.js",
@ -19,9 +19,9 @@
"dev:builder": "rollup -cw"
},
"dependencies": {
"@budibase/bbui": "^0.9.152",
"@budibase/bbui": "^0.9.153-alpha.1",
"@budibase/standard-components": "^0.9.139",
"@budibase/string-templates": "^0.9.152",
"@budibase/string-templates": "^0.9.153-alpha.1",
"regexparam": "^1.3.0",
"shortid": "^2.2.15",
"svelte-spa-router": "^3.0.5"

View file

@ -353,7 +353,7 @@
}
/* Reduce padding */
.mobile .main {
.mobile:not(.layout--none) .main {
padding: 16px;
}

View file

@ -17,7 +17,17 @@
if (!bounds || !side) {
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) {
return {
top: side === Sides.Top ? top - 4 : top + height,

View file

@ -1,7 +1,7 @@
{
"name": "@budibase/server",
"email": "hi@budibase.com",
"version": "0.9.152",
"version": "0.9.153-alpha.1",
"description": "Budibase Web Server",
"main": "src/index.js",
"repository": {
@ -66,9 +66,9 @@
"author": "Budibase",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@budibase/auth": "^0.9.152",
"@budibase/client": "^0.9.152",
"@budibase/string-templates": "^0.9.152",
"@budibase/auth": "^0.9.153-alpha.1",
"@budibase/client": "^0.9.153-alpha.1",
"@budibase/string-templates": "^0.9.153-alpha.1",
"@elastic/elasticsearch": "7.10.0",
"@koa/router": "8.0.0",
"@sendgrid/mail": "7.1.1",

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/string-templates",
"version": "0.9.152",
"version": "0.9.153-alpha.1",
"description": "Handlebars wrapper for Budibase templating.",
"main": "src/index.cjs",
"module": "dist/bundle.mjs",

View file

@ -1,7 +1,7 @@
{
"name": "@budibase/worker",
"email": "hi@budibase.com",
"version": "0.9.152",
"version": "0.9.153-alpha.1",
"description": "Budibase background service",
"main": "src/index.js",
"repository": {
@ -27,8 +27,8 @@
"author": "Budibase",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@budibase/auth": "^0.9.152",
"@budibase/string-templates": "^0.9.152",
"@budibase/auth": "^0.9.153-alpha.1",
"@budibase/string-templates": "^0.9.153-alpha.1",
"@koa/router": "^8.0.0",
"@techpass/passport-openidconnect": "^0.3.0",
"aws-sdk": "^2.811.0",

View file

@ -1,29 +1,24 @@
const {
generateGlobalUserID,
getGlobalUserParams,
StaticDatabases,
generateNewUsageQuotaDoc,
} = require("@budibase/auth/db")
const { hash, getGlobalUserByEmail } = require("@budibase/auth").utils
const { UserStatus, EmailTemplatePurpose } = require("../../../constants")
const { hash, getGlobalUserByEmail, saveUser } = require("@budibase/auth").utils
const { EmailTemplatePurpose } = require("../../../constants")
const { checkInviteCode } = require("../../../utilities/redis")
const { sendEmail } = require("../../../utilities/email")
const { user: userCache } = require("@budibase/auth/cache")
const { invalidateSessions } = require("@budibase/auth/sessions")
const CouchDB = require("../../../db")
const accounts = require("@budibase/auth/accounts")
const {
getGlobalDB,
getTenantId,
getTenantUser,
doesTenantExist,
tryAddTenant,
updateTenantId,
} = require("@budibase/auth/tenancy")
const { removeUserFromInfoDB } = require("@budibase/auth/deprovision")
const env = require("../../../environment")
const PLATFORM_INFO_DB = StaticDatabases.PLATFORM_INFO.name
async function allUsers() {
const db = getGlobalDB()
const response = await db.allDocs(
@ -34,96 +29,6 @@ async function allUsers() {
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 => {
try {
ctx.body = await saveUser(ctx.request.body, getTenantId())
@ -310,16 +215,6 @@ exports.find = async ctx => {
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 => {
const id = ctx.params.id
const user = await getTenantUser(id)