Merge remote-tracking branch 'origin/develop' into feature/sync-automations
This commit is contained in:
commit
007f5ca8a9
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "2.6.8-alpha.7",
|
||||
"version": "2.6.8-alpha.14",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/backend-core",
|
||||
|
|
|
@ -21,7 +21,7 @@ export enum ViewName {
|
|||
AUTOMATION_LOGS = "automation_logs",
|
||||
ACCOUNT_BY_EMAIL = "account_by_email",
|
||||
PLATFORM_USERS_LOWERCASE = "platform_users_lowercase",
|
||||
USER_BY_GROUP = "by_group_user",
|
||||
USER_BY_GROUP = "user_by_group",
|
||||
APP_BACKUP_BY_TRIGGER = "by_trigger",
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import * as db from "../../db"
|
|||
import { Header } from "../../constants"
|
||||
import { newid } from "../../utils"
|
||||
import env from "../../environment"
|
||||
import { BBContext } from "@budibase/types"
|
||||
|
||||
describe("utils", () => {
|
||||
const config = new DBTestConfiguration()
|
||||
|
@ -106,4 +107,85 @@ describe("utils", () => {
|
|||
expect(actual).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isServingBuilder", () => {
|
||||
let ctx: BBContext
|
||||
|
||||
const expectResult = (result: boolean) =>
|
||||
expect(utils.isServingBuilder(ctx)).toBe(result)
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = structures.koa.newContext()
|
||||
})
|
||||
|
||||
it("returns true if current path is in builder", async () => {
|
||||
ctx.path = "/builder/app/app_"
|
||||
expectResult(true)
|
||||
})
|
||||
|
||||
it("returns false if current path doesn't have '/' suffix", async () => {
|
||||
ctx.path = "/builder/app"
|
||||
expectResult(false)
|
||||
|
||||
ctx.path = "/xx"
|
||||
expectResult(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isServingBuilderPreview", () => {
|
||||
let ctx: BBContext
|
||||
|
||||
const expectResult = (result: boolean) =>
|
||||
expect(utils.isServingBuilderPreview(ctx)).toBe(result)
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = structures.koa.newContext()
|
||||
})
|
||||
|
||||
it("returns true if current path is in builder preview", async () => {
|
||||
ctx.path = "/app/preview/xx"
|
||||
expectResult(true)
|
||||
})
|
||||
|
||||
it("returns false if current path is not in builder preview", async () => {
|
||||
ctx.path = "/builder"
|
||||
expectResult(false)
|
||||
|
||||
ctx.path = "/xx"
|
||||
expectResult(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isPublicAPIRequest", () => {
|
||||
let ctx: BBContext
|
||||
|
||||
const expectResult = (result: boolean) =>
|
||||
expect(utils.isPublicApiRequest(ctx)).toBe(result)
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = structures.koa.newContext()
|
||||
})
|
||||
|
||||
it("returns true if current path remains to public API", async () => {
|
||||
ctx.path = "/api/public/v1/invoices"
|
||||
expectResult(true)
|
||||
|
||||
ctx.path = "/api/public/v1"
|
||||
expectResult(true)
|
||||
|
||||
ctx.path = "/api/public/v2"
|
||||
expectResult(true)
|
||||
|
||||
ctx.path = "/api/public/v21"
|
||||
expectResult(true)
|
||||
})
|
||||
|
||||
it("returns false if current path doesn't remain to public API", async () => {
|
||||
ctx.path = "/api/public"
|
||||
expectResult(false)
|
||||
|
||||
ctx.path = "/xx"
|
||||
expectResult(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,11 +1,5 @@
|
|||
import { getAllApps, queryGlobalView } from "../db"
|
||||
import {
|
||||
Header,
|
||||
MAX_VALID_DATE,
|
||||
DocumentType,
|
||||
SEPARATOR,
|
||||
ViewName,
|
||||
} from "../constants"
|
||||
import { getAllApps } from "../db"
|
||||
import { Header, MAX_VALID_DATE, DocumentType, SEPARATOR } from "../constants"
|
||||
import env from "../environment"
|
||||
import * as tenancy from "../tenancy"
|
||||
import * as context from "../context"
|
||||
|
@ -23,7 +17,9 @@ const APP_PREFIX = DocumentType.APP + SEPARATOR
|
|||
const PROD_APP_PREFIX = "/app/"
|
||||
|
||||
const BUILDER_PREVIEW_PATH = "/app/preview"
|
||||
const BUILDER_REFERER_PREFIX = "/builder/app/"
|
||||
const BUILDER_PREFIX = "/builder"
|
||||
const BUILDER_APP_PREFIX = `${BUILDER_PREFIX}/app/`
|
||||
const PUBLIC_API_PREFIX = "/api/public/v"
|
||||
|
||||
function confirmAppId(possibleAppId: string | undefined) {
|
||||
return possibleAppId && possibleAppId.startsWith(APP_PREFIX)
|
||||
|
@ -69,6 +65,18 @@ export function isServingApp(ctx: Ctx) {
|
|||
return false
|
||||
}
|
||||
|
||||
export function isServingBuilder(ctx: Ctx): boolean {
|
||||
return ctx.path.startsWith(BUILDER_APP_PREFIX)
|
||||
}
|
||||
|
||||
export function isServingBuilderPreview(ctx: Ctx): boolean {
|
||||
return ctx.path.startsWith(BUILDER_PREVIEW_PATH)
|
||||
}
|
||||
|
||||
export function isPublicApiRequest(ctx: Ctx): boolean {
|
||||
return ctx.path.startsWith(PUBLIC_API_PREFIX)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a request tries to find the appId, which can be located in various places
|
||||
* @param {object} ctx The main request body to look through.
|
||||
|
@ -110,7 +118,7 @@ export async function getAppIdFromCtx(ctx: Ctx) {
|
|||
// make sure this is performed after prod app url resolution, in case the
|
||||
// referer header is present from a builder redirect
|
||||
const referer = ctx.request.headers.referer
|
||||
if (!appId && referer?.includes(BUILDER_REFERER_PREFIX)) {
|
||||
if (!appId && referer?.includes(BUILDER_APP_PREFIX)) {
|
||||
const refererId = parseAppIdFromUrl(ctx.request.headers.referer)
|
||||
appId = confirmAppId(refererId)
|
||||
}
|
||||
|
|
|
@ -18,10 +18,14 @@
|
|||
export let ignoreTimezones = false
|
||||
export let time24hr = false
|
||||
export let range = false
|
||||
export let flatpickr
|
||||
export let useKeyboardShortcuts = true
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const flatpickrId = `${uuid()}-wrapper`
|
||||
|
||||
let open = false
|
||||
let flatpickr, flatpickrOptions
|
||||
let flatpickrOptions
|
||||
|
||||
// Another classic flatpickr issue. Errors were randomly being thrown due to
|
||||
// flatpickr internal code. Making sure that "destroy" is a valid function
|
||||
|
@ -59,6 +63,8 @@
|
|||
dispatch("change", timestamp.toISOString())
|
||||
}
|
||||
},
|
||||
onOpen: () => dispatch("open"),
|
||||
onClose: () => dispatch("close"),
|
||||
}
|
||||
|
||||
$: redrawOptions = {
|
||||
|
@ -113,12 +119,16 @@
|
|||
|
||||
const onOpen = () => {
|
||||
open = true
|
||||
if (useKeyboardShortcuts) {
|
||||
document.addEventListener("keyup", clearDateOnBackspace)
|
||||
}
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
open = false
|
||||
if (useKeyboardShortcuts) {
|
||||
document.removeEventListener("keyup", clearDateOnBackspace)
|
||||
}
|
||||
|
||||
// Manually blur all input fields since flatpickr creates a second
|
||||
// duplicate input field.
|
||||
|
|
|
@ -61,11 +61,63 @@
|
|||
$: isTrigger = block?.type === "TRIGGER"
|
||||
$: isUpdateRow = stepId === ActionStepID.UPDATE_ROW
|
||||
|
||||
/**
|
||||
* TODO - Remove after November 2023
|
||||
* *******************************
|
||||
* Code added to provide backwards compatibility between Values 1,2,3,4,5
|
||||
* and the new JSON body.
|
||||
*/
|
||||
let deprecatedSchemaProperties
|
||||
$: {
|
||||
if (block?.stepId === "integromat" || block?.stepId === "zapier") {
|
||||
deprecatedSchemaProperties = schemaProperties.filter(
|
||||
prop => !prop[0].startsWith("value")
|
||||
)
|
||||
if (!deprecatedSchemaProperties.map(entry => entry[0]).includes("body")) {
|
||||
deprecatedSchemaProperties.push([
|
||||
"body",
|
||||
{
|
||||
title: "Payload",
|
||||
type: "json",
|
||||
},
|
||||
])
|
||||
}
|
||||
} else {
|
||||
deprecatedSchemaProperties = schemaProperties
|
||||
}
|
||||
}
|
||||
/****************************************************/
|
||||
|
||||
const getInputData = (testData, blockInputs) => {
|
||||
let newInputData = testData || blockInputs
|
||||
if (block.event === "app:trigger" && !newInputData?.fields) {
|
||||
newInputData = cloneDeep(blockInputs)
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO - Remove after November 2023
|
||||
* *******************************
|
||||
* Code added to provide backwards compatibility between Values 1,2,3,4,5
|
||||
* and the new JSON body.
|
||||
*/
|
||||
if (
|
||||
(block?.stepId === "integromat" || block?.stepId === "zapier") &&
|
||||
!newInputData?.body?.value
|
||||
) {
|
||||
let deprecatedValues = {
|
||||
...newInputData,
|
||||
}
|
||||
delete deprecatedValues.url
|
||||
delete deprecatedValues.body
|
||||
newInputData = {
|
||||
url: newInputData.url,
|
||||
body: {
|
||||
value: JSON.stringify(deprecatedValues),
|
||||
},
|
||||
}
|
||||
}
|
||||
/**********************************/
|
||||
|
||||
inputData = newInputData
|
||||
setDefaultEnumValues()
|
||||
}
|
||||
|
@ -239,7 +291,7 @@
|
|||
</script>
|
||||
|
||||
<div class="fields">
|
||||
{#each schemaProperties as [key, value]}
|
||||
{#each deprecatedSchemaProperties as [key, value]}
|
||||
<div class="block-field">
|
||||
{#if key !== "fields"}
|
||||
<Label
|
||||
|
@ -256,6 +308,28 @@
|
|||
options={value.enum}
|
||||
getOptionLabel={(x, idx) => (value.pretty ? value.pretty[idx] : x)}
|
||||
/>
|
||||
{:else if value.type === "json"}
|
||||
<Editor
|
||||
editorHeight="250"
|
||||
editorWidth="448"
|
||||
mode="json"
|
||||
value={inputData[key]?.value}
|
||||
on:change={e => {
|
||||
/**
|
||||
* TODO - Remove after November 2023
|
||||
* *******************************
|
||||
* Code added to provide backwards compatibility between Values 1,2,3,4,5
|
||||
* and the new JSON body.
|
||||
*/
|
||||
delete inputData.value1
|
||||
delete inputData.value2
|
||||
delete inputData.value3
|
||||
delete inputData.value4
|
||||
delete inputData.value5
|
||||
/***********************/
|
||||
onChange(e, key)
|
||||
}}
|
||||
/>
|
||||
{:else if value.customType === "column"}
|
||||
<Select
|
||||
on:change={e => onChange(e, key)}
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
export let rowCount
|
||||
export let disableSorting = false
|
||||
export let customPlaceholder = false
|
||||
export let allowClickRows
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
|
@ -110,6 +111,7 @@
|
|||
{disableSorting}
|
||||
{customPlaceholder}
|
||||
showAutoColumns={!hideAutocolumns}
|
||||
{allowClickRows}
|
||||
on:clickrelationship={e => selectRelationship(e.detail)}
|
||||
on:sort
|
||||
>
|
||||
|
|
|
@ -6,7 +6,8 @@
|
|||
import NavItem from "components/common/NavItem.svelte"
|
||||
import { goto, isActive } from "@roxi/routify"
|
||||
|
||||
const alphabetical = (a, b) => a.name?.toLowerCase() > b.name?.toLowerCase()
|
||||
const alphabetical = (a, b) =>
|
||||
a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
|
||||
|
||||
export let sourceId
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
export let highlighted = false
|
||||
export let rightAlignIcon = false
|
||||
export let id
|
||||
export let showTooltip = false
|
||||
|
||||
const scrollApi = getContext("scroll")
|
||||
const dispatch = createEventDispatcher()
|
||||
|
@ -84,7 +85,7 @@
|
|||
<Icon color={iconColor} size="S" name={icon} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text">{text}</div>
|
||||
<div class="text" title={showTooltip ? text : null}>{text}</div>
|
||||
{#if withActions}
|
||||
<div class="actions">
|
||||
<slot />
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
export let tab = true
|
||||
export let mode
|
||||
export let editorHeight = 500
|
||||
export let editorWidth = 640
|
||||
// export let parameters = []
|
||||
|
||||
let width
|
||||
|
@ -169,7 +170,9 @@
|
|||
{#if label}
|
||||
<Label small>{label}</Label>
|
||||
{/if}
|
||||
<div style={`--code-mirror-height: ${editorHeight}px`}>
|
||||
<div
|
||||
style={`--code-mirror-height: ${editorHeight}px; --code-mirror-width: ${editorWidth}px;`}
|
||||
>
|
||||
<textarea tabindex="0" bind:this={refs.editor} readonly {value} />
|
||||
</div>
|
||||
|
||||
|
@ -183,6 +186,7 @@
|
|||
}
|
||||
|
||||
div :global(.CodeMirror) {
|
||||
width: var(--code-mirror-width) !important;
|
||||
height: var(--code-mirror-height) !important;
|
||||
border-radius: var(--border-radius-s);
|
||||
font-family: var(--font-mono);
|
||||
|
|
|
@ -28,13 +28,16 @@
|
|||
let inviting = false
|
||||
let searchFocus = false
|
||||
|
||||
// Initially filter entities without app access
|
||||
// Show all when false
|
||||
let filterByAppAccess = true
|
||||
|
||||
let appInvites = []
|
||||
let filteredInvites = []
|
||||
let filteredUsers = []
|
||||
let filteredGroups = []
|
||||
let selectedGroup
|
||||
let userOnboardResponse = null
|
||||
|
||||
let userLimitReachedModal
|
||||
|
||||
$: queryIsEmail = emailValidator(query) === true
|
||||
|
@ -52,15 +55,32 @@
|
|||
}
|
||||
|
||||
const filterInvites = async query => {
|
||||
appInvites = await getInvites()
|
||||
if (!query || query == "") {
|
||||
filteredInvites = appInvites
|
||||
if (!prodAppId) {
|
||||
return
|
||||
}
|
||||
filteredInvites = appInvites.filter(invite => invite.email.includes(query))
|
||||
|
||||
appInvites = await getInvites()
|
||||
|
||||
//On Focus behaviour
|
||||
if (!filterByAppAccess && !query) {
|
||||
filteredInvites =
|
||||
appInvites.length > 100 ? appInvites.slice(0, 100) : [...appInvites]
|
||||
return
|
||||
}
|
||||
|
||||
$: filterInvites(query)
|
||||
filteredInvites = appInvites.filter(invite => {
|
||||
const inviteInfo = invite.info?.apps
|
||||
if (!query && inviteInfo && prodAppId) {
|
||||
return Object.keys(inviteInfo).includes(prodAppId)
|
||||
}
|
||||
return invite.email.includes(query)
|
||||
})
|
||||
}
|
||||
|
||||
$: filterByAppAccess, prodAppId, filterInvites(query)
|
||||
$: if (searchFocus === true) {
|
||||
filterByAppAccess = false
|
||||
}
|
||||
|
||||
const usersFetch = fetchData({
|
||||
API,
|
||||
|
@ -79,9 +99,9 @@
|
|||
}
|
||||
await usersFetch.update({
|
||||
query: {
|
||||
appId: query ? null : prodAppId,
|
||||
appId: query || !filterByAppAccess ? null : prodAppId,
|
||||
email: query,
|
||||
paginated: query ? null : false,
|
||||
paginated: query || !filterByAppAccess ? null : false,
|
||||
},
|
||||
})
|
||||
await usersFetch.refresh()
|
||||
|
@ -107,7 +127,12 @@
|
|||
}
|
||||
|
||||
const debouncedUpdateFetch = Utils.debounce(searchUsers, 250)
|
||||
$: debouncedUpdateFetch(query, $store.builderSidePanel, loaded)
|
||||
$: debouncedUpdateFetch(
|
||||
query,
|
||||
$store.builderSidePanel,
|
||||
loaded,
|
||||
filterByAppAccess
|
||||
)
|
||||
|
||||
const updateAppUser = async (user, role) => {
|
||||
if (!prodAppId) {
|
||||
|
@ -182,7 +207,8 @@
|
|||
}
|
||||
|
||||
const searchGroups = (userGroups, query) => {
|
||||
let filterGroups = query?.length
|
||||
let filterGroups =
|
||||
query?.length || !filterByAppAccess
|
||||
? userGroups
|
||||
: getAppGroups(userGroups, prodAppId)
|
||||
return filterGroups
|
||||
|
@ -214,7 +240,7 @@
|
|||
}
|
||||
|
||||
// Adds the 'role' attribute and sets it to the current app.
|
||||
$: enrichedGroups = getEnrichedGroups($groups)
|
||||
$: enrichedGroups = getEnrichedGroups($groups, filterByAppAccess)
|
||||
$: filteredGroups = searchGroups(enrichedGroups, query)
|
||||
$: groupUsers = buildGroupUsers(filteredGroups, filteredUsers)
|
||||
$: allUsers = [...filteredUsers, ...groupUsers]
|
||||
|
@ -226,7 +252,7 @@
|
|||
specific roles for the app.
|
||||
*/
|
||||
const buildGroupUsers = (userGroups, filteredUsers) => {
|
||||
if (query) {
|
||||
if (query || !filterByAppAccess) {
|
||||
return []
|
||||
}
|
||||
// Must exclude users who have explicit privileges
|
||||
|
@ -321,12 +347,12 @@
|
|||
[prodAppId]: role,
|
||||
},
|
||||
})
|
||||
await filterInvites()
|
||||
await filterInvites(query)
|
||||
}
|
||||
|
||||
const onUninviteAppUser = async invite => {
|
||||
await uninviteAppUser(invite)
|
||||
await filterInvites()
|
||||
await filterInvites(query)
|
||||
}
|
||||
|
||||
// Purge only the app from the invite or recind the invite if only 1 app remains?
|
||||
|
@ -351,7 +377,6 @@
|
|||
|
||||
onMount(() => {
|
||||
rendered = true
|
||||
searchFocus = true
|
||||
})
|
||||
|
||||
function handleKeyDown(evt) {
|
||||
|
@ -417,7 +442,6 @@
|
|||
autocomplete="off"
|
||||
disabled={inviting}
|
||||
value={query}
|
||||
autofocus
|
||||
on:input={e => {
|
||||
query = e.target.value.trim()
|
||||
}}
|
||||
|
@ -428,16 +452,20 @@
|
|||
|
||||
<span
|
||||
class="search-input-icon"
|
||||
class:searching={query}
|
||||
class:searching={query || !filterByAppAccess}
|
||||
on:click={() => {
|
||||
if (!filterByAppAccess) {
|
||||
filterByAppAccess = true
|
||||
}
|
||||
if (!query) {
|
||||
return
|
||||
}
|
||||
query = null
|
||||
userOnboardResponse = null
|
||||
filterByAppAccess = true
|
||||
}}
|
||||
>
|
||||
<Icon name={query ? "Close" : "Search"} />
|
||||
<Icon name={!filterByAppAccess || query ? "Close" : "Search"} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
@ -696,7 +724,7 @@
|
|||
max-width: calc(100vw - 40px);
|
||||
background: var(--background);
|
||||
border-left: var(--border-light);
|
||||
z-index: 3;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
|
|
@ -59,6 +59,7 @@
|
|||
text={screen.routing.route}
|
||||
on:click={() => store.actions.screens.select(screen._id)}
|
||||
rightAlignIcon
|
||||
showTooltip
|
||||
>
|
||||
<ScreenDropdownMenu screenId={screen._id} />
|
||||
<RoleIndicator slot="right" roleId={screen.routing.roleId} />
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import EditUserPicker from "./EditUserPicker.svelte"
|
||||
|
||||
import { Heading, Pagination, Table } from "@budibase/bbui"
|
||||
import { Heading, Pagination, Table, Search } from "@budibase/bbui"
|
||||
import { fetchData } from "@budibase/frontend-core"
|
||||
import { goto } from "@roxi/routify"
|
||||
import { API } from "api"
|
||||
|
@ -12,7 +12,9 @@
|
|||
|
||||
export let groupId
|
||||
|
||||
const fetchGroupUsers = fetchData({
|
||||
let emailSearch
|
||||
let fetchGroupUsers
|
||||
$: fetchGroupUsers = fetchData({
|
||||
API,
|
||||
datasource: {
|
||||
type: "groupUser",
|
||||
|
@ -20,6 +22,7 @@
|
|||
options: {
|
||||
query: {
|
||||
groupId,
|
||||
emailSearch,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
@ -59,24 +62,31 @@
|
|||
</script>
|
||||
|
||||
<div class="header">
|
||||
<Heading size="S">Users</Heading>
|
||||
{#if !scimEnabled}
|
||||
<EditUserPicker {groupId} onUsersUpdated={fetchGroupUsers.getInitialData} />
|
||||
{:else}
|
||||
<ScimBanner />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<Search bind:value={emailSearch} placeholder="Search email" />
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
schema={userSchema}
|
||||
data={$fetchGroupUsers?.rows}
|
||||
loading={$fetchGroupUsers.loading}
|
||||
allowEditRows={false}
|
||||
customPlaceholder
|
||||
customRenderers={customUserTableRenderers}
|
||||
on:click={e => $goto(`../users/${e.detail._id}`)}
|
||||
>
|
||||
<div class="placeholder" slot="placeholder">
|
||||
<Heading size="S">This user group doesn't have any users</Heading>
|
||||
<Heading size="S"
|
||||
>{emailSearch
|
||||
? `No users found matching the email "${emailSearch}"`
|
||||
: "This user group doesn't have any users"}</Heading
|
||||
>
|
||||
</div>
|
||||
</Table>
|
||||
|
||||
|
@ -98,7 +108,7 @@
|
|||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--spacing-l);
|
||||
}
|
||||
|
@ -109,4 +119,15 @@
|
|||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
.controls-right :global(.spectrum-Search) {
|
||||
width: 200px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -88,6 +88,16 @@
|
|||
},
|
||||
}
|
||||
|
||||
const getPendingSchema = tblSchema => {
|
||||
if (!tblSchema) {
|
||||
return {}
|
||||
}
|
||||
let pendingSchema = JSON.parse(JSON.stringify(tblSchema))
|
||||
pendingSchema.email.displayName = "Pending Invites"
|
||||
return pendingSchema
|
||||
}
|
||||
|
||||
$: pendingSchema = getPendingSchema(schema)
|
||||
$: userData = []
|
||||
$: inviteUsersResponse = { successful: [], unsuccessful: [] }
|
||||
$: {
|
||||
|
@ -110,6 +120,24 @@
|
|||
}
|
||||
})
|
||||
}
|
||||
let invitesLoaded = false
|
||||
let pendingInvites = []
|
||||
let parsedInvites = []
|
||||
|
||||
const invitesToSchema = invites => {
|
||||
return invites.map(invite => {
|
||||
const { admin, builder, userGroups, apps } = invite.info
|
||||
|
||||
return {
|
||||
email: invite.email,
|
||||
builder,
|
||||
admin,
|
||||
userGroups: userGroups,
|
||||
apps: apps ? [...new Set(Object.keys(apps))] : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
$: parsedInvites = invitesToSchema(pendingInvites)
|
||||
|
||||
const updateFetch = email => {
|
||||
fetch.update({
|
||||
|
@ -144,6 +172,7 @@
|
|||
}))
|
||||
try {
|
||||
inviteUsersResponse = await users.invite(payload)
|
||||
pendingInvites = await users.getInvites()
|
||||
inviteConfirmationModal.show()
|
||||
} catch (error) {
|
||||
notifications.error("Error inviting user")
|
||||
|
@ -232,6 +261,9 @@
|
|||
try {
|
||||
await groups.actions.init()
|
||||
groupsLoaded = true
|
||||
|
||||
pendingInvites = await users.getInvites()
|
||||
invitesLoaded = true
|
||||
} catch (error) {
|
||||
notifications.error("Error fetching user group data")
|
||||
}
|
||||
|
@ -324,6 +356,15 @@
|
|||
goToNextPage={fetch.nextPage}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
schema={pendingSchema}
|
||||
data={parsedInvites}
|
||||
allowEditColumns={false}
|
||||
allowEditRows={false}
|
||||
{customRenderers}
|
||||
loading={!invitesLoaded}
|
||||
allowClickRows={false}
|
||||
/>
|
||||
</Layout>
|
||||
|
||||
<Modal bind:this={createUserModal}>
|
||||
|
|
|
@ -55,10 +55,13 @@ export const buildGroupsEndpoints = API => {
|
|||
/**
|
||||
* Gets a group users by the group id
|
||||
*/
|
||||
getGroupUsers: async ({ id, bookmark }) => {
|
||||
getGroupUsers: async ({ id, bookmark, emailSearch }) => {
|
||||
let url = `/api/global/groups/${id}/users?`
|
||||
if (bookmark) {
|
||||
url += `bookmark=${bookmark}`
|
||||
url += `bookmark=${bookmark}&`
|
||||
}
|
||||
if (emailSearch) {
|
||||
url += `emailSearch=${emailSearch}&`
|
||||
}
|
||||
|
||||
return await API.get({
|
||||
|
|
|
@ -32,6 +32,7 @@
|
|||
$: readonly =
|
||||
column.schema.autocolumn ||
|
||||
column.schema.disabled ||
|
||||
column.schema.type === "formula" ||
|
||||
(!$config.allowEditRows && row._id)
|
||||
|
||||
// Register this cell API if the row is focused
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
<script>
|
||||
import dayjs from "dayjs"
|
||||
import { CoreDatePicker, Icon } from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
export let value
|
||||
export let schema
|
||||
export let onChange
|
||||
export let focused = false
|
||||
export let readonly = false
|
||||
export let api
|
||||
|
||||
let flatpickr
|
||||
let isOpen
|
||||
|
||||
// adding the 0- will turn a string like 00:00:00 into a valid ISO
|
||||
// date, but will make actual ISO dates invalid
|
||||
|
@ -19,6 +24,26 @@
|
|||
? "MMM D YYYY"
|
||||
: "MMM D YYYY, HH:mm"
|
||||
$: editable = focused && !readonly
|
||||
|
||||
// Ensure we close flatpickr when unselected
|
||||
$: {
|
||||
if (!focused) {
|
||||
flatpickr?.close()
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = () => {
|
||||
return isOpen
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
api = {
|
||||
onKeyDown,
|
||||
focus: () => flatpickr?.open(),
|
||||
blur: () => flatpickr?.close(),
|
||||
isActive: () => isOpen,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
|
@ -42,6 +67,10 @@
|
|||
{timeOnly}
|
||||
time24hr
|
||||
ignoreTimezones={schema.ignoreTimezones}
|
||||
bind:flatpickr
|
||||
on:open={() => (isOpen = true)}
|
||||
on:close={() => (isOpen = false)}
|
||||
useKeyboardShortcuts={false}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
<script>
|
||||
import { clickOutside, Menu, MenuItem, notifications } from "@budibase/bbui"
|
||||
import {
|
||||
clickOutside,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Helpers,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { getContext } from "svelte"
|
||||
import { NewRowID } from "../lib/constants"
|
||||
|
||||
const {
|
||||
focusedRow,
|
||||
|
@ -14,9 +21,11 @@
|
|||
clipboard,
|
||||
dispatch,
|
||||
focusedCellAPI,
|
||||
focusedRowId,
|
||||
} = getContext("grid")
|
||||
|
||||
$: style = makeStyle($menu)
|
||||
$: isNewRow = $focusedRowId === NewRowID
|
||||
|
||||
const makeStyle = menu => {
|
||||
return `left:${menu.left}px; top:${menu.top}px;`
|
||||
|
@ -36,6 +45,11 @@
|
|||
$focusedCellId = `${newRow._id}-${column}`
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = async value => {
|
||||
await Helpers.copyToClipboard(value)
|
||||
notifications.success("Copied to clipboard")
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $menu.visible}
|
||||
|
@ -58,22 +72,38 @@
|
|||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Maximize"
|
||||
disabled={!$config.allowEditRows}
|
||||
disabled={isNewRow || !$config.allowEditRows}
|
||||
on:click={() => dispatch("edit-row", $focusedRow)}
|
||||
on:click={menu.actions.close}
|
||||
>
|
||||
Edit row in modal
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Copy"
|
||||
disabled={isNewRow || !$focusedRow?._id}
|
||||
on:click={() => copyToClipboard($focusedRow?._id)}
|
||||
on:click={menu.actions.close}
|
||||
>
|
||||
Copy row _id
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Copy"
|
||||
disabled={isNewRow || !$focusedRow?._rev}
|
||||
on:click={() => copyToClipboard($focusedRow?._rev)}
|
||||
on:click={menu.actions.close}
|
||||
>
|
||||
Copy row _rev
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Duplicate"
|
||||
disabled={!$config.allowAddRows}
|
||||
disabled={isNewRow || !$config.allowAddRows}
|
||||
on:click={duplicate}
|
||||
>
|
||||
Duplicate row
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="Delete"
|
||||
disabled={!$config.allowDeleteRows}
|
||||
disabled={isNewRow || !$config.allowDeleteRows}
|
||||
on:click={deleteRow}
|
||||
>
|
||||
Delete row
|
||||
|
|
|
@ -338,15 +338,11 @@ export const deriveStores = context => {
|
|||
...state,
|
||||
[rowId]: true,
|
||||
}))
|
||||
const newRow = { ...row, ...get(rowChangeCache)[rowId] }
|
||||
const saved = await API.saveRow(newRow)
|
||||
const saved = await API.saveRow({ ...row, ...get(rowChangeCache)[rowId] })
|
||||
|
||||
// Update state after a successful change
|
||||
rows.update(state => {
|
||||
state[index] = {
|
||||
...newRow,
|
||||
_rev: saved._rev,
|
||||
}
|
||||
state[index] = saved
|
||||
return state.slice()
|
||||
})
|
||||
rowChangeCache.update(state => {
|
||||
|
|
|
@ -31,6 +31,7 @@ export default class GroupUserFetch extends DataFetch {
|
|||
try {
|
||||
const res = await this.API.getGroupUsers({
|
||||
id: query.groupId,
|
||||
emailSearch: query.emailSearch,
|
||||
bookmark: cursor,
|
||||
})
|
||||
|
||||
|
|
|
@ -27,6 +27,10 @@ export const definition: AutomationStepSchema = {
|
|||
type: AutomationIOType.STRING,
|
||||
title: "Webhook URL",
|
||||
},
|
||||
body: {
|
||||
type: AutomationIOType.JSON,
|
||||
title: "Payload",
|
||||
},
|
||||
value1: {
|
||||
type: AutomationIOType.STRING,
|
||||
title: "Input Value 1",
|
||||
|
@ -71,7 +75,19 @@ export const definition: AutomationStepSchema = {
|
|||
}
|
||||
|
||||
export async function run({ inputs }: AutomationStepInput) {
|
||||
const { url, value1, value2, value3, value4, value5 } = inputs
|
||||
//TODO - Remove deprecated values 1,2,3,4,5 after November 2023
|
||||
const { url, value1, value2, value3, value4, value5, body } = inputs
|
||||
|
||||
let payload = {}
|
||||
try {
|
||||
payload = body?.value ? JSON.parse(body?.value) : {}
|
||||
} catch (err) {
|
||||
return {
|
||||
httpStatus: 400,
|
||||
response: "Invalid payload JSON",
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (!url?.trim()?.length) {
|
||||
return {
|
||||
|
@ -90,6 +106,7 @@ export async function run({ inputs }: AutomationStepInput) {
|
|||
value3,
|
||||
value4,
|
||||
value5,
|
||||
...payload,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
|
@ -25,6 +25,10 @@ export const definition: AutomationStepSchema = {
|
|||
type: AutomationIOType.STRING,
|
||||
title: "Webhook URL",
|
||||
},
|
||||
body: {
|
||||
type: AutomationIOType.JSON,
|
||||
title: "Payload",
|
||||
},
|
||||
value1: {
|
||||
type: AutomationIOType.STRING,
|
||||
title: "Payload Value 1",
|
||||
|
@ -64,7 +68,19 @@ export const definition: AutomationStepSchema = {
|
|||
}
|
||||
|
||||
export async function run({ inputs }: AutomationStepInput) {
|
||||
const { url, value1, value2, value3, value4, value5 } = inputs
|
||||
//TODO - Remove deprecated values 1,2,3,4,5 after November 2023
|
||||
const { url, value1, value2, value3, value4, value5, body } = inputs
|
||||
|
||||
let payload = {}
|
||||
try {
|
||||
payload = body?.value ? JSON.parse(body?.value) : {}
|
||||
} catch (err) {
|
||||
return {
|
||||
httpStatus: 400,
|
||||
response: "Invalid payload JSON",
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (!url?.trim()?.length) {
|
||||
return {
|
||||
|
@ -86,6 +102,7 @@ export async function run({ inputs }: AutomationStepInput) {
|
|||
value3,
|
||||
value4,
|
||||
value5,
|
||||
...payload,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
import { getConfig, afterAll, runStep, actions } from "./utilities"
|
||||
|
||||
describe("test the outgoing webhook action", () => {
|
||||
let config = getConfig()
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
|
||||
afterAll()
|
||||
|
||||
it("should be able to run the action", async () => {
|
||||
const res = await runStep(actions.integromat.stepId, {
|
||||
value1: "test",
|
||||
url: "http://www.test.com",
|
||||
})
|
||||
expect(res.response.url).toEqual("http://www.test.com")
|
||||
expect(res.response.method).toEqual("post")
|
||||
expect(res.success).toEqual(true)
|
||||
})
|
||||
|
||||
it("should add the payload props when a JSON string is provided", async () => {
|
||||
const payload = `{"value1":1,"value2":2,"value3":3,"value4":4,"value5":5,"name":"Adam","age":9}`
|
||||
const res = await runStep(actions.integromat.stepId, {
|
||||
value1: "ONE",
|
||||
value2: "TWO",
|
||||
value3: "THREE",
|
||||
value4: "FOUR",
|
||||
value5: "FIVE",
|
||||
body: {
|
||||
value: payload,
|
||||
},
|
||||
url: "http://www.test.com",
|
||||
})
|
||||
expect(res.response.url).toEqual("http://www.test.com")
|
||||
expect(res.response.method).toEqual("post")
|
||||
expect(res.response.body).toEqual(payload)
|
||||
expect(res.success).toEqual(true)
|
||||
})
|
||||
|
||||
it("should return a 400 if the JSON payload string is malformed", async () => {
|
||||
const payload = `{ value1 1 }`
|
||||
const res = await runStep(actions.integromat.stepId, {
|
||||
value1: "ONE",
|
||||
body: {
|
||||
value: payload,
|
||||
},
|
||||
url: "http://www.test.com",
|
||||
})
|
||||
expect(res.httpStatus).toEqual(400)
|
||||
expect(res.response).toEqual("Invalid payload JSON")
|
||||
expect(res.success).toEqual(false)
|
||||
})
|
||||
})
|
|
@ -1,27 +0,0 @@
|
|||
const setup = require("./utilities")
|
||||
const fetch = require("node-fetch")
|
||||
|
||||
jest.mock("node-fetch")
|
||||
|
||||
describe("test the outgoing webhook action", () => {
|
||||
let inputs
|
||||
let config = setup.getConfig()
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
inputs = {
|
||||
value1: "test",
|
||||
url: "http://www.test.com",
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
|
||||
it("should be able to run the action", async () => {
|
||||
const res = await setup.runStep(setup.actions.zapier.stepId, inputs)
|
||||
expect(res.response.url).toEqual("http://www.test.com")
|
||||
expect(res.response.method).toEqual("post")
|
||||
expect(res.success).toEqual(true)
|
||||
})
|
||||
|
||||
})
|
|
@ -0,0 +1,56 @@
|
|||
import { getConfig, afterAll, runStep, actions } from "./utilities"
|
||||
|
||||
describe("test the outgoing webhook action", () => {
|
||||
let config = getConfig()
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
|
||||
afterAll()
|
||||
|
||||
it("should be able to run the action", async () => {
|
||||
const res = await runStep(actions.zapier.stepId, {
|
||||
value1: "test",
|
||||
url: "http://www.test.com",
|
||||
})
|
||||
expect(res.response.url).toEqual("http://www.test.com")
|
||||
expect(res.response.method).toEqual("post")
|
||||
expect(res.success).toEqual(true)
|
||||
})
|
||||
|
||||
it("should add the payload props when a JSON string is provided", async () => {
|
||||
const payload = `{ "value1": 1, "value2": 2, "value3": 3, "value4": 4, "value5": 5, "name": "Adam", "age": 9 }`
|
||||
const res = await runStep(actions.zapier.stepId, {
|
||||
value1: "ONE",
|
||||
value2: "TWO",
|
||||
value3: "THREE",
|
||||
value4: "FOUR",
|
||||
value5: "FIVE",
|
||||
body: {
|
||||
value: payload,
|
||||
},
|
||||
url: "http://www.test.com",
|
||||
})
|
||||
expect(res.response.url).toEqual("http://www.test.com")
|
||||
expect(res.response.method).toEqual("post")
|
||||
expect(res.response.body).toEqual(
|
||||
`{"platform":"budibase","value1":1,"value2":2,"value3":3,"value4":4,"value5":5,"name":"Adam","age":9}`
|
||||
)
|
||||
expect(res.success).toEqual(true)
|
||||
})
|
||||
|
||||
it("should return a 400 if the JSON payload string is malformed", async () => {
|
||||
const payload = `{ value1 1 }`
|
||||
const res = await runStep(actions.zapier.stepId, {
|
||||
value1: "ONE",
|
||||
body: {
|
||||
value: payload,
|
||||
},
|
||||
url: "http://www.test.com",
|
||||
})
|
||||
expect(res.httpStatus).toEqual(400)
|
||||
expect(res.response).toEqual("Invalid payload JSON")
|
||||
expect(res.success).toEqual(false)
|
||||
})
|
||||
})
|
|
@ -7,6 +7,7 @@ export enum AutomationIOType {
|
|||
BOOLEAN = "boolean",
|
||||
NUMBER = "number",
|
||||
ARRAY = "array",
|
||||
JSON = "json",
|
||||
}
|
||||
|
||||
export enum AutomationCustomIOType {
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
import { events } from "@budibase/backend-core"
|
||||
import { generator } from "@budibase/backend-core/tests"
|
||||
import { structures, TestConfiguration, mocks } from "../../../../tests"
|
||||
import { UserGroup } from "@budibase/types"
|
||||
|
||||
mocks.licenses.useGroups()
|
||||
|
||||
describe("/api/global/groups", () => {
|
||||
const config = new TestConfiguration()
|
||||
|
@ -113,4 +117,118 @@ describe("/api/global/groups", () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("find users", () => {
|
||||
describe("without users", () => {
|
||||
let group: UserGroup
|
||||
beforeAll(async () => {
|
||||
group = structures.groups.UserGroup()
|
||||
await config.api.groups.saveGroup(group)
|
||||
})
|
||||
|
||||
it("should return empty", async () => {
|
||||
const result = await config.api.groups.searchUsers(group._id!)
|
||||
expect(result.body).toEqual({
|
||||
users: [],
|
||||
bookmark: undefined,
|
||||
hasNextPage: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("existing users", () => {
|
||||
let groupId: string
|
||||
let users: { _id: string; email: string }[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
groupId = (
|
||||
await config.api.groups.saveGroup(structures.groups.UserGroup())
|
||||
).body._id
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 30 }).map(async (_, i) => {
|
||||
const email = `user${i}@${generator.domain()}`
|
||||
const user = await config.api.users.saveUser({
|
||||
...structures.users.user(),
|
||||
email,
|
||||
})
|
||||
users.push({ _id: user.body._id, email })
|
||||
})
|
||||
)
|
||||
users = users.sort((a, b) => a._id.localeCompare(b._id))
|
||||
await config.api.groups.updateGroupUsers(groupId, {
|
||||
add: users.map(u => u._id),
|
||||
remove: [],
|
||||
})
|
||||
})
|
||||
|
||||
describe("pagination", () => {
|
||||
it("should return first page", async () => {
|
||||
const result = await config.api.groups.searchUsers(groupId)
|
||||
expect(result.body).toEqual({
|
||||
users: users.slice(0, 10),
|
||||
bookmark: users[10]._id,
|
||||
hasNextPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("given a bookmark, should return skip items", async () => {
|
||||
const result = await config.api.groups.searchUsers(groupId, {
|
||||
bookmark: users[7]._id,
|
||||
})
|
||||
expect(result.body).toEqual({
|
||||
users: users.slice(7, 17),
|
||||
bookmark: users[17]._id,
|
||||
hasNextPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("bookmarking the last page, should return last page info", async () => {
|
||||
const result = await config.api.groups.searchUsers(groupId, {
|
||||
bookmark: users[20]._id,
|
||||
})
|
||||
expect(result.body).toEqual({
|
||||
users: users.slice(20),
|
||||
bookmark: undefined,
|
||||
hasNextPage: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("search by email", () => {
|
||||
it('should be able to search "starting" by email', async () => {
|
||||
const result = await config.api.groups.searchUsers(groupId, {
|
||||
emailSearch: `user1`,
|
||||
})
|
||||
|
||||
const matchedUsers = users
|
||||
.filter(u => u.email.startsWith("user1"))
|
||||
.sort((a, b) => a.email.localeCompare(b.email))
|
||||
|
||||
expect(result.body).toEqual({
|
||||
users: matchedUsers.slice(0, 10),
|
||||
bookmark: matchedUsers[10].email,
|
||||
hasNextPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("should be able to bookmark when searching by email", async () => {
|
||||
const matchedUsers = users
|
||||
.filter(u => u.email.startsWith("user1"))
|
||||
.sort((a, b) => a.email.localeCompare(b.email))
|
||||
|
||||
const result = await config.api.groups.searchUsers(groupId, {
|
||||
emailSearch: `user1`,
|
||||
bookmark: matchedUsers[4].email,
|
||||
})
|
||||
|
||||
expect(result.body).toEqual({
|
||||
users: matchedUsers.slice(4),
|
||||
bookmark: undefined,
|
||||
hasNextPage: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -23,4 +23,34 @@ export class GroupsAPI extends TestAPI {
|
|||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
}
|
||||
|
||||
searchUsers = (
|
||||
id: string,
|
||||
params?: { bookmark?: string; emailSearch?: string }
|
||||
) => {
|
||||
let url = `/api/global/groups/${id}/users?`
|
||||
if (params?.bookmark) {
|
||||
url += `bookmark=${params.bookmark}&`
|
||||
}
|
||||
if (params?.emailSearch) {
|
||||
url += `emailSearch=${params.emailSearch}&`
|
||||
}
|
||||
return this.request
|
||||
.get(url)
|
||||
.set(this.config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
}
|
||||
|
||||
updateGroupUsers = (
|
||||
id: string,
|
||||
body: { add: string[]; remove: string[] }
|
||||
) => {
|
||||
return this.request
|
||||
.post(`/api/global/groups/${id}/users`)
|
||||
.send(body)
|
||||
.set(this.config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,6 +20,12 @@ export default class LicenseAPI {
|
|||
internal: true,
|
||||
}
|
||||
)
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(
|
||||
`Could not update license for accountId=${accountId}: ${response.status}`
|
||||
)
|
||||
}
|
||||
return [response, json]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,9 +11,6 @@ describe("Internal API - App Specific Roles & Permissions", () => {
|
|||
await config.beforeAll()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await config.afterAll()
|
||||
})
|
||||
afterAll(async () => {
|
||||
await config.afterAll()
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue