Merge branch 'develop' into user-limit-ui
This commit is contained in:
commit
fc846e1580
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "2.6.8-alpha.2",
|
"version": "2.6.8-alpha.10",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/backend-core",
|
"packages/backend-core",
|
||||||
|
|
|
@ -5,7 +5,6 @@ import * as db from "../../db"
|
||||||
import { Header } from "../../constants"
|
import { Header } from "../../constants"
|
||||||
import { newid } from "../../utils"
|
import { newid } from "../../utils"
|
||||||
import env from "../../environment"
|
import env from "../../environment"
|
||||||
import { beforeEach } from "@jest/globals"
|
|
||||||
import { BBContext } from "@budibase/types"
|
import { BBContext } from "@budibase/types"
|
||||||
|
|
||||||
describe("utils", () => {
|
describe("utils", () => {
|
||||||
|
@ -124,7 +123,7 @@ describe("utils", () => {
|
||||||
expectResult(true)
|
expectResult(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("returns false if current path is not in builder", async () => {
|
it("returns false if current path doesn't have '/' suffix", async () => {
|
||||||
ctx.path = "/builder/app"
|
ctx.path = "/builder/app"
|
||||||
expectResult(false)
|
expectResult(false)
|
||||||
|
|
||||||
|
@ -173,6 +172,12 @@ describe("utils", () => {
|
||||||
|
|
||||||
ctx.path = "/api/public/v1"
|
ctx.path = "/api/public/v1"
|
||||||
expectResult(true)
|
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 () => {
|
it("returns false if current path doesn't remain to public API", async () => {
|
||||||
|
|
|
@ -18,8 +18,8 @@ const PROD_APP_PREFIX = "/app/"
|
||||||
|
|
||||||
const BUILDER_PREVIEW_PATH = "/app/preview"
|
const BUILDER_PREVIEW_PATH = "/app/preview"
|
||||||
const BUILDER_PREFIX = "/builder"
|
const BUILDER_PREFIX = "/builder"
|
||||||
const BUILDER_REFERER_PREFIX = `${BUILDER_PREFIX}/app/`
|
const BUILDER_APP_PREFIX = `${BUILDER_PREFIX}/app/`
|
||||||
const PUBLIC_API_PREFIX = "/api/public/v1"
|
const PUBLIC_API_PREFIX = "/api/public/v"
|
||||||
|
|
||||||
function confirmAppId(possibleAppId: string | undefined) {
|
function confirmAppId(possibleAppId: string | undefined) {
|
||||||
return possibleAppId && possibleAppId.startsWith(APP_PREFIX)
|
return possibleAppId && possibleAppId.startsWith(APP_PREFIX)
|
||||||
|
@ -66,7 +66,7 @@ export function isServingApp(ctx: Ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isServingBuilder(ctx: Ctx): boolean {
|
export function isServingBuilder(ctx: Ctx): boolean {
|
||||||
return ctx.path.startsWith(BUILDER_REFERER_PREFIX)
|
return ctx.path.startsWith(BUILDER_APP_PREFIX)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isServingBuilderPreview(ctx: Ctx): boolean {
|
export function isServingBuilderPreview(ctx: Ctx): boolean {
|
||||||
|
@ -118,7 +118,7 @@ export async function getAppIdFromCtx(ctx: Ctx) {
|
||||||
// make sure this is performed after prod app url resolution, in case the
|
// make sure this is performed after prod app url resolution, in case the
|
||||||
// referer header is present from a builder redirect
|
// referer header is present from a builder redirect
|
||||||
const referer = ctx.request.headers.referer
|
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)
|
const refererId = parseAppIdFromUrl(ctx.request.headers.referer)
|
||||||
appId = confirmAppId(refererId)
|
appId = confirmAppId(refererId)
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,10 +18,14 @@
|
||||||
export let ignoreTimezones = false
|
export let ignoreTimezones = false
|
||||||
export let time24hr = false
|
export let time24hr = false
|
||||||
export let range = false
|
export let range = false
|
||||||
|
export let flatpickr
|
||||||
|
export let useKeyboardShortcuts = true
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
const flatpickrId = `${uuid()}-wrapper`
|
const flatpickrId = `${uuid()}-wrapper`
|
||||||
|
|
||||||
let open = false
|
let open = false
|
||||||
let flatpickr, flatpickrOptions
|
let flatpickrOptions
|
||||||
|
|
||||||
// Another classic flatpickr issue. Errors were randomly being thrown due to
|
// Another classic flatpickr issue. Errors were randomly being thrown due to
|
||||||
// flatpickr internal code. Making sure that "destroy" is a valid function
|
// flatpickr internal code. Making sure that "destroy" is a valid function
|
||||||
|
@ -59,6 +63,8 @@
|
||||||
dispatch("change", timestamp.toISOString())
|
dispatch("change", timestamp.toISOString())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onOpen: () => dispatch("open"),
|
||||||
|
onClose: () => dispatch("close"),
|
||||||
}
|
}
|
||||||
|
|
||||||
$: redrawOptions = {
|
$: redrawOptions = {
|
||||||
|
@ -113,12 +119,16 @@
|
||||||
|
|
||||||
const onOpen = () => {
|
const onOpen = () => {
|
||||||
open = true
|
open = true
|
||||||
document.addEventListener("keyup", clearDateOnBackspace)
|
if (useKeyboardShortcuts) {
|
||||||
|
document.addEventListener("keyup", clearDateOnBackspace)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
open = false
|
open = false
|
||||||
document.removeEventListener("keyup", clearDateOnBackspace)
|
if (useKeyboardShortcuts) {
|
||||||
|
document.removeEventListener("keyup", clearDateOnBackspace)
|
||||||
|
}
|
||||||
|
|
||||||
// Manually blur all input fields since flatpickr creates a second
|
// Manually blur all input fields since flatpickr creates a second
|
||||||
// duplicate input field.
|
// duplicate input field.
|
||||||
|
|
|
@ -61,11 +61,63 @@
|
||||||
$: isTrigger = block?.type === "TRIGGER"
|
$: isTrigger = block?.type === "TRIGGER"
|
||||||
$: isUpdateRow = stepId === ActionStepID.UPDATE_ROW
|
$: 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) => {
|
const getInputData = (testData, blockInputs) => {
|
||||||
let newInputData = testData || blockInputs
|
let newInputData = testData || blockInputs
|
||||||
if (block.event === "app:trigger" && !newInputData?.fields) {
|
if (block.event === "app:trigger" && !newInputData?.fields) {
|
||||||
newInputData = cloneDeep(blockInputs)
|
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
|
inputData = newInputData
|
||||||
setDefaultEnumValues()
|
setDefaultEnumValues()
|
||||||
}
|
}
|
||||||
|
@ -239,7 +291,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
{#each schemaProperties as [key, value]}
|
{#each deprecatedSchemaProperties as [key, value]}
|
||||||
<div class="block-field">
|
<div class="block-field">
|
||||||
{#if key !== "fields"}
|
{#if key !== "fields"}
|
||||||
<Label
|
<Label
|
||||||
|
@ -256,6 +308,28 @@
|
||||||
options={value.enum}
|
options={value.enum}
|
||||||
getOptionLabel={(x, idx) => (value.pretty ? value.pretty[idx] : x)}
|
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"}
|
{:else if value.customType === "column"}
|
||||||
<Select
|
<Select
|
||||||
on:change={e => onChange(e, key)}
|
on:change={e => onChange(e, key)}
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
export let tab = true
|
export let tab = true
|
||||||
export let mode
|
export let mode
|
||||||
export let editorHeight = 500
|
export let editorHeight = 500
|
||||||
|
export let editorWidth = 640
|
||||||
// export let parameters = []
|
// export let parameters = []
|
||||||
|
|
||||||
let width
|
let width
|
||||||
|
@ -169,7 +170,9 @@
|
||||||
{#if label}
|
{#if label}
|
||||||
<Label small>{label}</Label>
|
<Label small>{label}</Label>
|
||||||
{/if}
|
{/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} />
|
<textarea tabindex="0" bind:this={refs.editor} readonly {value} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -183,6 +186,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
div :global(.CodeMirror) {
|
div :global(.CodeMirror) {
|
||||||
|
width: var(--code-mirror-width) !important;
|
||||||
height: var(--code-mirror-height) !important;
|
height: var(--code-mirror-height) !important;
|
||||||
border-radius: var(--border-radius-s);
|
border-radius: var(--border-radius-s);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
|
|
@ -1,47 +1,28 @@
|
||||||
<script>
|
<script>
|
||||||
import { url, goto } from "@roxi/routify"
|
|
||||||
import {
|
import {
|
||||||
Button,
|
ActionMenu,
|
||||||
Layout,
|
|
||||||
Heading,
|
Heading,
|
||||||
Icon,
|
Icon,
|
||||||
Popover,
|
Layout,
|
||||||
notifications,
|
|
||||||
Table,
|
|
||||||
ActionMenu,
|
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Modal,
|
Modal,
|
||||||
|
Table,
|
||||||
|
notifications,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
|
import { goto, url } from "@roxi/routify"
|
||||||
import { createPaginationStore } from "helpers/pagination"
|
|
||||||
import { users, apps, groups, auth, features } from "stores/portal"
|
|
||||||
import { onMount, setContext } from "svelte"
|
|
||||||
import { roles } from "stores/backend"
|
|
||||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||||
|
import { Breadcrumb, Breadcrumbs } from "components/portal/page"
|
||||||
|
import { roles } from "stores/backend"
|
||||||
|
import { apps, auth, features, groups } from "stores/portal"
|
||||||
|
import { onMount, setContext } from "svelte"
|
||||||
|
import AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte"
|
||||||
|
import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte"
|
||||||
import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte"
|
import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte"
|
||||||
import GroupIcon from "./_components/GroupIcon.svelte"
|
import GroupIcon from "./_components/GroupIcon.svelte"
|
||||||
import { Breadcrumbs, Breadcrumb } from "components/portal/page"
|
import GroupUsers from "./_components/GroupUsers.svelte"
|
||||||
import AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte"
|
|
||||||
import RemoveUserTableRenderer from "./_components/RemoveUserTableRenderer.svelte"
|
|
||||||
import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte"
|
|
||||||
import ScimBanner from "../_components/SCIMBanner.svelte"
|
|
||||||
|
|
||||||
export let groupId
|
export let groupId
|
||||||
|
|
||||||
$: userSchema = {
|
|
||||||
email: {
|
|
||||||
width: "1fr",
|
|
||||||
},
|
|
||||||
...(readonly
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
_id: {
|
|
||||||
displayName: "",
|
|
||||||
width: "auto",
|
|
||||||
borderLeft: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
const appSchema = {
|
const appSchema = {
|
||||||
name: {
|
name: {
|
||||||
width: "2fr",
|
width: "2fr",
|
||||||
|
@ -50,12 +31,6 @@
|
||||||
width: "1fr",
|
width: "1fr",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const customUserTableRenderers = [
|
|
||||||
{
|
|
||||||
column: "_id",
|
|
||||||
component: RemoveUserTableRenderer,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const customAppTableRenderers = [
|
const customAppTableRenderers = [
|
||||||
{
|
{
|
||||||
column: "name",
|
column: "name",
|
||||||
|
@ -67,20 +42,12 @@
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
let popoverAnchor
|
|
||||||
let popover
|
|
||||||
let searchTerm = ""
|
|
||||||
let prevSearch = undefined
|
|
||||||
let pageInfo = createPaginationStore()
|
|
||||||
let loaded = false
|
let loaded = false
|
||||||
let editModal, deleteModal
|
let editModal, deleteModal
|
||||||
|
|
||||||
$: scimEnabled = $features.isScimEnabled
|
$: scimEnabled = $features.isScimEnabled
|
||||||
$: readonly = !$auth.isAdmin || scimEnabled
|
$: readonly = !$auth.isAdmin || scimEnabled
|
||||||
$: page = $pageInfo.page
|
|
||||||
$: fetchUsers(page, searchTerm)
|
|
||||||
$: group = $groups.find(x => x._id === groupId)
|
$: group = $groups.find(x => x._id === groupId)
|
||||||
$: filtered = $users.data
|
|
||||||
$: groupApps = $apps
|
$: groupApps = $apps
|
||||||
.filter(app =>
|
.filter(app =>
|
||||||
groups.actions
|
groups.actions
|
||||||
|
@ -97,25 +64,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUsers(page, search) {
|
|
||||||
if ($pageInfo.loading) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// need to remove the page if they've started searching
|
|
||||||
if (search && !prevSearch) {
|
|
||||||
pageInfo.reset()
|
|
||||||
page = undefined
|
|
||||||
}
|
|
||||||
prevSearch = search
|
|
||||||
try {
|
|
||||||
pageInfo.loading()
|
|
||||||
await users.search({ page, email: search })
|
|
||||||
pageInfo.fetched($users.hasNextPage, $users.nextPage)
|
|
||||||
} catch (error) {
|
|
||||||
notifications.error("Error getting user list")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteGroup() {
|
async function deleteGroup() {
|
||||||
try {
|
try {
|
||||||
await groups.actions.delete(group)
|
await groups.actions.delete(group)
|
||||||
|
@ -130,21 +78,17 @@
|
||||||
try {
|
try {
|
||||||
await groups.actions.save(group)
|
await groups.actions.save(group)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error(`Failed to save user group`)
|
if (error.message) {
|
||||||
|
notifications.error(error.message)
|
||||||
|
} else {
|
||||||
|
notifications.error(`Failed to save user group`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeUser = async id => {
|
|
||||||
await groups.actions.removeUser(groupId, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeApp = async app => {
|
const removeApp = async app => {
|
||||||
await groups.actions.removeApp(groupId, apps.getProdAppID(app.devId))
|
await groups.actions.removeApp(groupId, apps.getProdAppID(app.devId))
|
||||||
}
|
}
|
||||||
|
|
||||||
setContext("users", {
|
|
||||||
removeUser,
|
|
||||||
})
|
|
||||||
setContext("roles", {
|
setContext("roles", {
|
||||||
updateRole: () => {},
|
updateRole: () => {},
|
||||||
removeRole: removeApp,
|
removeRole: removeApp,
|
||||||
|
@ -186,41 +130,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Layout noPadding gap="S">
|
<Layout noPadding gap="S">
|
||||||
<div class="header">
|
<GroupUsers {groupId} />
|
||||||
<Heading size="S">Users</Heading>
|
|
||||||
{#if !scimEnabled}
|
|
||||||
<div bind:this={popoverAnchor}>
|
|
||||||
<Button disabled={readonly} on:click={popover.show()} cta
|
|
||||||
>Add user</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<ScimBanner />
|
|
||||||
{/if}
|
|
||||||
<Popover align="right" bind:this={popover} anchor={popoverAnchor}>
|
|
||||||
<UserGroupPicker
|
|
||||||
bind:searchTerm
|
|
||||||
labelKey="email"
|
|
||||||
selected={group.users?.map(user => user._id)}
|
|
||||||
list={$users.data}
|
|
||||||
on:select={e => groups.actions.addUser(groupId, e.detail)}
|
|
||||||
on:deselect={e => groups.actions.removeUser(groupId, e.detail)}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Table
|
|
||||||
schema={userSchema}
|
|
||||||
data={group?.users}
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</Table>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<Layout noPadding gap="S">
|
<Layout noPadding gap="S">
|
||||||
|
|
|
@ -9,15 +9,23 @@
|
||||||
|
|
||||||
export let group
|
export let group
|
||||||
export let saveGroup
|
export let saveGroup
|
||||||
|
|
||||||
|
let nameError
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ModalContent
|
<ModalContent
|
||||||
onConfirm={() => saveGroup(group)}
|
onConfirm={() => {
|
||||||
|
if (!group.name?.trim()) {
|
||||||
|
nameError = "Group name cannot be empty"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
saveGroup(group)
|
||||||
|
}}
|
||||||
size="M"
|
size="M"
|
||||||
title={group?._rev ? "Edit group" : "Create group"}
|
title={group?._rev ? "Edit group" : "Create group"}
|
||||||
confirmText="Save"
|
confirmText="Save"
|
||||||
>
|
>
|
||||||
<Input bind:value={group.name} label="Name" />
|
<Input bind:value={group.name} label="Name" error={nameError} />
|
||||||
<div class="modal-format">
|
<div class="modal-format">
|
||||||
<div class="modal-inner">
|
<div class="modal-inner">
|
||||||
<Body size="XS">Icon</Body>
|
<Body size="XS">Icon</Body>
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
<script>
|
||||||
|
import { Button, Popover, notifications } from "@budibase/bbui"
|
||||||
|
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
|
||||||
|
import { createPaginationStore } from "helpers/pagination"
|
||||||
|
import { auth, groups, users } from "stores/portal"
|
||||||
|
|
||||||
|
export let groupId
|
||||||
|
export let onUsersUpdated
|
||||||
|
|
||||||
|
let popoverAnchor
|
||||||
|
let popover
|
||||||
|
let searchTerm = ""
|
||||||
|
let prevSearch = undefined
|
||||||
|
let pageInfo = createPaginationStore()
|
||||||
|
|
||||||
|
$: readonly = !$auth.isAdmin
|
||||||
|
$: page = $pageInfo.page
|
||||||
|
$: searchUsers(page, searchTerm)
|
||||||
|
$: group = $groups.find(x => x._id === groupId)
|
||||||
|
|
||||||
|
async function searchUsers(page, search) {
|
||||||
|
if ($pageInfo.loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// need to remove the page if they've started searching
|
||||||
|
if (search && !prevSearch) {
|
||||||
|
pageInfo.reset()
|
||||||
|
page = undefined
|
||||||
|
}
|
||||||
|
prevSearch = search
|
||||||
|
try {
|
||||||
|
pageInfo.loading()
|
||||||
|
await users.search({ page, email: search })
|
||||||
|
pageInfo.fetched($users.hasNextPage, $users.nextPage)
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error("Error getting user list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={popoverAnchor}>
|
||||||
|
<Button disabled={readonly} on:click={popover.show()} cta>Add user</Button>
|
||||||
|
</div>
|
||||||
|
<Popover align="right" bind:this={popover} anchor={popoverAnchor}>
|
||||||
|
<UserGroupPicker
|
||||||
|
bind:searchTerm
|
||||||
|
labelKey="email"
|
||||||
|
selected={group.users?.map(user => user._id)}
|
||||||
|
list={$users.data}
|
||||||
|
on:select={async e => {
|
||||||
|
await groups.actions.addUser(groupId, e.detail)
|
||||||
|
onUsersUpdated()
|
||||||
|
}}
|
||||||
|
on:deselect={async e => {
|
||||||
|
await groups.actions.removeUser(groupId, e.detail)
|
||||||
|
onUsersUpdated()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Popover>
|
|
@ -0,0 +1,112 @@
|
||||||
|
<script>
|
||||||
|
import EditUserPicker from "./EditUserPicker.svelte"
|
||||||
|
|
||||||
|
import { Heading, Pagination, Table } from "@budibase/bbui"
|
||||||
|
import { fetchData } from "@budibase/frontend-core"
|
||||||
|
import { goto } from "@roxi/routify"
|
||||||
|
import { API } from "api"
|
||||||
|
import { auth, features, groups } from "stores/portal"
|
||||||
|
import { setContext } from "svelte"
|
||||||
|
import ScimBanner from "../../_components/SCIMBanner.svelte"
|
||||||
|
import RemoveUserTableRenderer from "../_components/RemoveUserTableRenderer.svelte"
|
||||||
|
|
||||||
|
export let groupId
|
||||||
|
|
||||||
|
const fetchGroupUsers = fetchData({
|
||||||
|
API,
|
||||||
|
datasource: {
|
||||||
|
type: "groupUser",
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
query: {
|
||||||
|
groupId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
$: userSchema = {
|
||||||
|
email: {
|
||||||
|
width: "1fr",
|
||||||
|
},
|
||||||
|
...(readonly
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
_id: {
|
||||||
|
displayName: "",
|
||||||
|
width: "auto",
|
||||||
|
borderLeft: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const customUserTableRenderers = [
|
||||||
|
{
|
||||||
|
column: "_id",
|
||||||
|
component: RemoveUserTableRenderer,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
$: scimEnabled = $features.isScimEnabled
|
||||||
|
$: readonly = !$auth.isAdmin || scimEnabled
|
||||||
|
|
||||||
|
const removeUser = async id => {
|
||||||
|
await groups.actions.removeUser(groupId, id)
|
||||||
|
fetchGroupUsers.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
setContext("users", {
|
||||||
|
removeUser,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<Heading size="S">Users</Heading>
|
||||||
|
{#if !scimEnabled}
|
||||||
|
<EditUserPicker {groupId} onUsersUpdated={fetchGroupUsers.getInitialData} />
|
||||||
|
{:else}
|
||||||
|
<ScimBanner />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
schema={userSchema}
|
||||||
|
data={$fetchGroupUsers?.rows}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<Pagination
|
||||||
|
page={$fetchGroupUsers.pageNumber + 1}
|
||||||
|
hasPrevPage={$fetchGroupUsers.loading
|
||||||
|
? false
|
||||||
|
: $fetchGroupUsers.hasPrevPage}
|
||||||
|
hasNextPage={$fetchGroupUsers.loading
|
||||||
|
? false
|
||||||
|
: $fetchGroupUsers.hasNextPage}
|
||||||
|
goToPrevPage={$fetchGroupUsers.loading ? null : fetchGroupUsers.prevPage}
|
||||||
|
goToNextPage={$fetchGroupUsers.loading ? null : fetchGroupUsers.nextPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-l);
|
||||||
|
}
|
||||||
|
.header :global(.spectrum-Heading) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.placeholder {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -66,6 +66,8 @@
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status === 400) {
|
if (error.status === 400) {
|
||||||
notifications.error(error.message)
|
notifications.error(error.message)
|
||||||
|
} else if (error.message) {
|
||||||
|
notifications.error(error.message)
|
||||||
} else {
|
} else {
|
||||||
notifications.error(`Failed to save group`)
|
notifications.error(`Failed to save group`)
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,7 @@ export function createGroupsStore() {
|
||||||
// on the backend anyway
|
// on the backend anyway
|
||||||
if (get(licensing).groupsEnabled) {
|
if (get(licensing).groupsEnabled) {
|
||||||
const groups = await API.getGroups()
|
const groups = await API.getGroups()
|
||||||
store.set(groups)
|
store.set(groups.data)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -52,6 +52,20 @@ export const buildGroupsEndpoints = API => {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a group users by the group id
|
||||||
|
*/
|
||||||
|
getGroupUsers: async ({ id, bookmark }) => {
|
||||||
|
let url = `/api/global/groups/${id}/users?`
|
||||||
|
if (bookmark) {
|
||||||
|
url += `bookmark=${bookmark}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return await API.get({
|
||||||
|
url,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds users to a group
|
* Adds users to a group
|
||||||
* @param groupId The group to update
|
* @param groupId The group to update
|
||||||
|
|
|
@ -32,6 +32,7 @@
|
||||||
$: readonly =
|
$: readonly =
|
||||||
column.schema.autocolumn ||
|
column.schema.autocolumn ||
|
||||||
column.schema.disabled ||
|
column.schema.disabled ||
|
||||||
|
column.schema.type === "formula" ||
|
||||||
(!$config.allowEditRows && row._id)
|
(!$config.allowEditRows && row._id)
|
||||||
|
|
||||||
// Register this cell API if the row is focused
|
// Register this cell API if the row is focused
|
||||||
|
|
|
@ -1,12 +1,17 @@
|
||||||
<script>
|
<script>
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { CoreDatePicker, Icon } from "@budibase/bbui"
|
import { CoreDatePicker, Icon } from "@budibase/bbui"
|
||||||
|
import { onMount } from "svelte"
|
||||||
|
|
||||||
export let value
|
export let value
|
||||||
export let schema
|
export let schema
|
||||||
export let onChange
|
export let onChange
|
||||||
export let focused = false
|
export let focused = false
|
||||||
export let readonly = 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
|
// adding the 0- will turn a string like 00:00:00 into a valid ISO
|
||||||
// date, but will make actual ISO dates invalid
|
// date, but will make actual ISO dates invalid
|
||||||
|
@ -19,6 +24,26 @@
|
||||||
? "MMM D YYYY"
|
? "MMM D YYYY"
|
||||||
: "MMM D YYYY, HH:mm"
|
: "MMM D YYYY, HH:mm"
|
||||||
$: editable = focused && !readonly
|
$: 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>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
@ -42,6 +67,10 @@
|
||||||
{timeOnly}
|
{timeOnly}
|
||||||
time24hr
|
time24hr
|
||||||
ignoreTimezones={schema.ignoreTimezones}
|
ignoreTimezones={schema.ignoreTimezones}
|
||||||
|
bind:flatpickr
|
||||||
|
on:open={() => (isOpen = true)}
|
||||||
|
on:close={() => (isOpen = false)}
|
||||||
|
useKeyboardShortcuts={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -1,6 +1,13 @@
|
||||||
<script>
|
<script>
|
||||||
import { clickOutside, Menu, MenuItem, notifications } from "@budibase/bbui"
|
import {
|
||||||
|
clickOutside,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Helpers,
|
||||||
|
notifications,
|
||||||
|
} from "@budibase/bbui"
|
||||||
import { getContext } from "svelte"
|
import { getContext } from "svelte"
|
||||||
|
import { NewRowID } from "../lib/constants"
|
||||||
|
|
||||||
const {
|
const {
|
||||||
focusedRow,
|
focusedRow,
|
||||||
|
@ -14,9 +21,11 @@
|
||||||
clipboard,
|
clipboard,
|
||||||
dispatch,
|
dispatch,
|
||||||
focusedCellAPI,
|
focusedCellAPI,
|
||||||
|
focusedRowId,
|
||||||
} = getContext("grid")
|
} = getContext("grid")
|
||||||
|
|
||||||
$: style = makeStyle($menu)
|
$: style = makeStyle($menu)
|
||||||
|
$: isNewRow = $focusedRowId === NewRowID
|
||||||
|
|
||||||
const makeStyle = menu => {
|
const makeStyle = menu => {
|
||||||
return `left:${menu.left}px; top:${menu.top}px;`
|
return `left:${menu.left}px; top:${menu.top}px;`
|
||||||
|
@ -36,6 +45,11 @@
|
||||||
$focusedCellId = `${newRow._id}-${column}`
|
$focusedCellId = `${newRow._id}-${column}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = async value => {
|
||||||
|
await Helpers.copyToClipboard(value)
|
||||||
|
notifications.success("Copied to clipboard")
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $menu.visible}
|
{#if $menu.visible}
|
||||||
|
@ -58,22 +72,38 @@
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="Maximize"
|
icon="Maximize"
|
||||||
disabled={!$config.allowEditRows}
|
disabled={isNewRow || !$config.allowEditRows}
|
||||||
on:click={() => dispatch("edit-row", $focusedRow)}
|
on:click={() => dispatch("edit-row", $focusedRow)}
|
||||||
on:click={menu.actions.close}
|
on:click={menu.actions.close}
|
||||||
>
|
>
|
||||||
Edit row in modal
|
Edit row in modal
|
||||||
</MenuItem>
|
</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
|
<MenuItem
|
||||||
icon="Duplicate"
|
icon="Duplicate"
|
||||||
disabled={!$config.allowAddRows}
|
disabled={isNewRow || !$config.allowAddRows}
|
||||||
on:click={duplicate}
|
on:click={duplicate}
|
||||||
>
|
>
|
||||||
Duplicate row
|
Duplicate row
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="Delete"
|
icon="Delete"
|
||||||
disabled={!$config.allowDeleteRows}
|
disabled={isNewRow || !$config.allowDeleteRows}
|
||||||
on:click={deleteRow}
|
on:click={deleteRow}
|
||||||
>
|
>
|
||||||
Delete row
|
Delete row
|
||||||
|
|
|
@ -338,15 +338,11 @@ export const deriveStores = context => {
|
||||||
...state,
|
...state,
|
||||||
[rowId]: true,
|
[rowId]: true,
|
||||||
}))
|
}))
|
||||||
const newRow = { ...row, ...get(rowChangeCache)[rowId] }
|
const saved = await API.saveRow({ ...row, ...get(rowChangeCache)[rowId] })
|
||||||
const saved = await API.saveRow(newRow)
|
|
||||||
|
|
||||||
// Update state after a successful change
|
// Update state after a successful change
|
||||||
rows.update(state => {
|
rows.update(state => {
|
||||||
state[index] = {
|
state[index] = saved
|
||||||
...newRow,
|
|
||||||
_rev: saved._rev,
|
|
||||||
}
|
|
||||||
return state.slice()
|
return state.slice()
|
||||||
})
|
})
|
||||||
rowChangeCache.update(state => {
|
rowChangeCache.update(state => {
|
||||||
|
|
|
@ -362,13 +362,35 @@ export default class DataFetch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.store.update($store => ({ ...$store, loading: true }))
|
this.store.update($store => ({ ...$store, loading: true }))
|
||||||
const { rows, info, error } = await this.getPage()
|
const { rows, info, error, cursor } = await this.getPage()
|
||||||
|
|
||||||
|
let { cursors } = get(this.store)
|
||||||
|
const { pageNumber } = get(this.store)
|
||||||
|
|
||||||
|
if (!rows.length && pageNumber > 0) {
|
||||||
|
// If the full page is gone but we have previous pages, navigate to the previous page
|
||||||
|
this.store.update($store => ({
|
||||||
|
...$store,
|
||||||
|
loading: false,
|
||||||
|
cursors: cursors.slice(0, pageNumber),
|
||||||
|
}))
|
||||||
|
return await this.prevPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentNextCursor = cursors[pageNumber + 1]
|
||||||
|
if (currentNextCursor != cursor) {
|
||||||
|
// If the current cursor changed, all the next pages need to be updated, so we mark them as stale
|
||||||
|
cursors = cursors.slice(0, pageNumber + 1)
|
||||||
|
cursors[pageNumber + 1] = cursor
|
||||||
|
}
|
||||||
|
|
||||||
this.store.update($store => ({
|
this.store.update($store => ({
|
||||||
...$store,
|
...$store,
|
||||||
rows,
|
rows,
|
||||||
info,
|
info,
|
||||||
loading: false,
|
loading: false,
|
||||||
error,
|
error,
|
||||||
|
cursors,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { get } from "svelte/store"
|
||||||
|
import DataFetch from "./DataFetch.js"
|
||||||
|
import { TableNames } from "../constants"
|
||||||
|
|
||||||
|
export default class GroupUserFetch extends DataFetch {
|
||||||
|
constructor(opts) {
|
||||||
|
super({
|
||||||
|
...opts,
|
||||||
|
datasource: {
|
||||||
|
tableId: TableNames.USERS,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
determineFeatureFlags() {
|
||||||
|
return {
|
||||||
|
supportsSearch: true,
|
||||||
|
supportsSort: false,
|
||||||
|
supportsPagination: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDefinition() {
|
||||||
|
return {
|
||||||
|
schema: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getData() {
|
||||||
|
const { query, cursor } = get(this.store)
|
||||||
|
try {
|
||||||
|
const res = await this.API.getGroupUsers({
|
||||||
|
id: query.groupId,
|
||||||
|
bookmark: cursor,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: res?.users || [],
|
||||||
|
hasNextPage: res?.hasNextPage || false,
|
||||||
|
cursor: res?.bookmark || null,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
rows: [],
|
||||||
|
hasNextPage: false,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ import NestedProviderFetch from "./NestedProviderFetch.js"
|
||||||
import FieldFetch from "./FieldFetch.js"
|
import FieldFetch from "./FieldFetch.js"
|
||||||
import JSONArrayFetch from "./JSONArrayFetch.js"
|
import JSONArrayFetch from "./JSONArrayFetch.js"
|
||||||
import UserFetch from "./UserFetch.js"
|
import UserFetch from "./UserFetch.js"
|
||||||
|
import GroupUserFetch from "./GroupUserFetch.js"
|
||||||
|
|
||||||
const DataFetchMap = {
|
const DataFetchMap = {
|
||||||
table: TableFetch,
|
table: TableFetch,
|
||||||
|
@ -13,6 +14,7 @@ const DataFetchMap = {
|
||||||
query: QueryFetch,
|
query: QueryFetch,
|
||||||
link: RelationshipFetch,
|
link: RelationshipFetch,
|
||||||
user: UserFetch,
|
user: UserFetch,
|
||||||
|
groupUser: GroupUserFetch,
|
||||||
|
|
||||||
// Client specific datasource types
|
// Client specific datasource types
|
||||||
provider: NestedProviderFetch,
|
provider: NestedProviderFetch,
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
Subproject commit 1bc218c228538b5bdba8b1b5686b05a7993b3ce9
|
Subproject commit 14345384f7a6755d1e2de327104741e0f208f55d
|
|
@ -47,7 +47,7 @@
|
||||||
"@apidevtools/swagger-parser": "10.0.3",
|
"@apidevtools/swagger-parser": "10.0.3",
|
||||||
"@budibase/backend-core": "0.0.1",
|
"@budibase/backend-core": "0.0.1",
|
||||||
"@budibase/client": "0.0.1",
|
"@budibase/client": "0.0.1",
|
||||||
"@budibase/pro": "develop",
|
"@budibase/pro": "0.0.1",
|
||||||
"@budibase/shared-core": "0.0.1",
|
"@budibase/shared-core": "0.0.1",
|
||||||
"@budibase/string-templates": "0.0.1",
|
"@budibase/string-templates": "0.0.1",
|
||||||
"@budibase/types": "0.0.1",
|
"@budibase/types": "0.0.1",
|
||||||
|
|
|
@ -26,6 +26,10 @@ export const definition: AutomationStepSchema = {
|
||||||
type: AutomationIOType.STRING,
|
type: AutomationIOType.STRING,
|
||||||
title: "Webhook URL",
|
title: "Webhook URL",
|
||||||
},
|
},
|
||||||
|
body: {
|
||||||
|
type: AutomationIOType.JSON,
|
||||||
|
title: "Payload",
|
||||||
|
},
|
||||||
value1: {
|
value1: {
|
||||||
type: AutomationIOType.STRING,
|
type: AutomationIOType.STRING,
|
||||||
title: "Input Value 1",
|
title: "Input Value 1",
|
||||||
|
@ -70,7 +74,19 @@ export const definition: AutomationStepSchema = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function run({ inputs }: AutomationStepInput) {
|
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) {
|
if (!url?.trim()?.length) {
|
||||||
return {
|
return {
|
||||||
|
@ -89,6 +105,7 @@ export async function run({ inputs }: AutomationStepInput) {
|
||||||
value3,
|
value3,
|
||||||
value4,
|
value4,
|
||||||
value5,
|
value5,
|
||||||
|
...payload,
|
||||||
}),
|
}),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
|
@ -24,6 +24,10 @@ export const definition: AutomationStepSchema = {
|
||||||
type: AutomationIOType.STRING,
|
type: AutomationIOType.STRING,
|
||||||
title: "Webhook URL",
|
title: "Webhook URL",
|
||||||
},
|
},
|
||||||
|
body: {
|
||||||
|
type: AutomationIOType.JSON,
|
||||||
|
title: "Payload",
|
||||||
|
},
|
||||||
value1: {
|
value1: {
|
||||||
type: AutomationIOType.STRING,
|
type: AutomationIOType.STRING,
|
||||||
title: "Payload Value 1",
|
title: "Payload Value 1",
|
||||||
|
@ -63,7 +67,19 @@ export const definition: AutomationStepSchema = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function run({ inputs }: AutomationStepInput) {
|
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) {
|
if (!url?.trim()?.length) {
|
||||||
return {
|
return {
|
||||||
|
@ -85,6 +101,7 @@ export async function run({ inputs }: AutomationStepInput) {
|
||||||
value3,
|
value3,
|
||||||
value4,
|
value4,
|
||||||
value5,
|
value5,
|
||||||
|
...payload,
|
||||||
}),
|
}),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"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)
|
||||||
|
})
|
||||||
|
})
|
|
@ -15,7 +15,7 @@ import {
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { OAuth2Client } from "google-auth-library"
|
import { OAuth2Client } from "google-auth-library"
|
||||||
import { buildExternalTableId, finaliseExternalTables } from "./utils"
|
import { buildExternalTableId, finaliseExternalTables } from "./utils"
|
||||||
import { GoogleSpreadsheet } from "google-spreadsheet"
|
import { GoogleSpreadsheet, GoogleSpreadsheetRow } from "google-spreadsheet"
|
||||||
import fetch from "node-fetch"
|
import fetch from "node-fetch"
|
||||||
import { configs, HTTPError } from "@budibase/backend-core"
|
import { configs, HTTPError } from "@budibase/backend-core"
|
||||||
import { dataFilters } from "@budibase/shared-core"
|
import { dataFilters } from "@budibase/shared-core"
|
||||||
|
@ -434,7 +434,20 @@ class GoogleSheetsIntegration implements DatasourcePlus {
|
||||||
try {
|
try {
|
||||||
await this.connect()
|
await this.connect()
|
||||||
const sheet = this.client.sheetsByTitle[query.sheet]
|
const sheet = this.client.sheetsByTitle[query.sheet]
|
||||||
const rows = await sheet.getRows()
|
let rows: GoogleSpreadsheetRow[] = []
|
||||||
|
if (query.paginate) {
|
||||||
|
const limit = query.paginate.limit || 100
|
||||||
|
let page: number =
|
||||||
|
typeof query.paginate.page === "number"
|
||||||
|
? query.paginate.page
|
||||||
|
: parseInt(query.paginate.page || "1")
|
||||||
|
rows = await sheet.getRows({
|
||||||
|
limit,
|
||||||
|
offset: (page - 1) * limit,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
rows = await sheet.getRows()
|
||||||
|
}
|
||||||
const filtered = dataFilters.runLuceneQuery(rows, query.filters)
|
const filtered = dataFilters.runLuceneQuery(rows, query.filters)
|
||||||
const headerValues = sheet.headerValues
|
const headerValues = sheet.headerValues
|
||||||
let response = []
|
let response = []
|
||||||
|
|
|
@ -7,6 +7,7 @@ export enum AutomationIOType {
|
||||||
BOOLEAN = "boolean",
|
BOOLEAN = "boolean",
|
||||||
NUMBER = "number",
|
NUMBER = "number",
|
||||||
ARRAY = "array",
|
ARRAY = "array",
|
||||||
|
JSON = "json",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AutomationCustomIOType {
|
export enum AutomationCustomIOType {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { PaginationResponse } from "../../api"
|
||||||
import { Document } from "../document"
|
import { Document } from "../document"
|
||||||
|
|
||||||
export interface UserGroup extends Document {
|
export interface UserGroup extends Document {
|
||||||
|
@ -21,3 +22,15 @@ export interface GroupUser {
|
||||||
export interface UserGroupRoles {
|
export interface UserGroupRoles {
|
||||||
[key: string]: string
|
[key: string]: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearchGroupRequest {}
|
||||||
|
export interface SearchGroupResponse {
|
||||||
|
data: UserGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchUserGroupResponse extends PaginationResponse {
|
||||||
|
users: {
|
||||||
|
_id: any
|
||||||
|
email: any
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
|
@ -65,6 +65,7 @@ export type DatabaseQueryOpts = {
|
||||||
key?: string
|
key?: string
|
||||||
keys?: string[]
|
keys?: string[]
|
||||||
group?: boolean
|
group?: boolean
|
||||||
|
startkey_docid?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isDocument = (doc: any): doc is Document => {
|
export const isDocument = (doc: any): doc is Document => {
|
||||||
|
|
|
@ -38,7 +38,7 @@
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@budibase/backend-core": "0.0.1",
|
"@budibase/backend-core": "0.0.1",
|
||||||
"@budibase/pro": "develop",
|
"@budibase/pro": "0.0.1",
|
||||||
"@budibase/string-templates": "0.0.1",
|
"@budibase/string-templates": "0.0.1",
|
||||||
"@budibase/types": "0.0.1",
|
"@budibase/types": "0.0.1",
|
||||||
"@koa/router": "8.0.8",
|
"@koa/router": "8.0.8",
|
||||||
|
|
|
@ -69,9 +69,11 @@ const bulkCreate = async (users: User[], groupIds: string[]) => {
|
||||||
return await userSdk.bulkCreate(users, groupIds)
|
return await userSdk.bulkCreate(users, groupIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const bulkUpdate = async (ctx: any) => {
|
export const bulkUpdate = async (
|
||||||
|
ctx: Ctx<BulkUserRequest, BulkUserResponse>
|
||||||
|
) => {
|
||||||
const currentUserId = ctx.user._id
|
const currentUserId = ctx.user._id
|
||||||
const input = ctx.request.body as BulkUserRequest
|
const input = ctx.request.body
|
||||||
let created, deleted
|
let created, deleted
|
||||||
try {
|
try {
|
||||||
if (input.create) {
|
if (input.create) {
|
||||||
|
@ -83,7 +85,7 @@ export const bulkUpdate = async (ctx: any) => {
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.throw(err.status || 400, err?.message || err)
|
ctx.throw(err.status || 400, err?.message || err)
|
||||||
}
|
}
|
||||||
ctx.body = { created, deleted } as BulkUserResponse
|
ctx.body = { created, deleted }
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseBooleanParam = (param: any) => {
|
const parseBooleanParam = (param: any) => {
|
||||||
|
@ -184,15 +186,15 @@ export const destroy = async (ctx: any) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAppUsers = async (ctx: any) => {
|
export const getAppUsers = async (ctx: Ctx<SearchUsersRequest>) => {
|
||||||
const body = ctx.request.body as SearchUsersRequest
|
const body = ctx.request.body
|
||||||
const users = await userSdk.getUsersByAppAccess(body?.appId)
|
const users = await userSdk.getUsersByAppAccess(body?.appId)
|
||||||
|
|
||||||
ctx.body = { data: users }
|
ctx.body = { data: users }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const search = async (ctx: any) => {
|
export const search = async (ctx: Ctx<SearchUsersRequest>) => {
|
||||||
const body = ctx.request.body as SearchUsersRequest
|
const body = ctx.request.body
|
||||||
|
|
||||||
if (body.paginated === false) {
|
if (body.paginated === false) {
|
||||||
await getAppUsers(ctx)
|
await getAppUsers(ctx)
|
||||||
|
@ -238,8 +240,8 @@ export const tenantUserLookup = async (ctx: any) => {
|
||||||
/*
|
/*
|
||||||
Encapsulate the app user onboarding flows here.
|
Encapsulate the app user onboarding flows here.
|
||||||
*/
|
*/
|
||||||
export const onboardUsers = async (ctx: any) => {
|
export const onboardUsers = async (ctx: Ctx<InviteUsersRequest>) => {
|
||||||
const request = ctx.request.body as InviteUsersRequest | BulkUserRequest
|
const request = ctx.request.body
|
||||||
const isBulkCreate = "create" in request
|
const isBulkCreate = "create" in request
|
||||||
|
|
||||||
const emailConfigured = await isEmailConfigured()
|
const emailConfigured = await isEmailConfigured()
|
||||||
|
@ -255,7 +257,7 @@ export const onboardUsers = async (ctx: any) => {
|
||||||
} else if (emailConfigured) {
|
} else if (emailConfigured) {
|
||||||
onboardingResponse = await inviteMultiple(ctx)
|
onboardingResponse = await inviteMultiple(ctx)
|
||||||
} else if (!emailConfigured) {
|
} else if (!emailConfigured) {
|
||||||
const inviteRequest = ctx.request.body as InviteUsersRequest
|
const inviteRequest = ctx.request.body
|
||||||
|
|
||||||
let createdPasswords: any = {}
|
let createdPasswords: any = {}
|
||||||
|
|
||||||
|
@ -295,10 +297,10 @@ export const onboardUsers = async (ctx: any) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const invite = async (ctx: any) => {
|
export const invite = async (ctx: Ctx<InviteUserRequest>) => {
|
||||||
const request = ctx.request.body as InviteUserRequest
|
const request = ctx.request.body
|
||||||
|
|
||||||
let multiRequest = [request] as InviteUsersRequest
|
let multiRequest = [request]
|
||||||
const response = await userSdk.invite(multiRequest)
|
const response = await userSdk.invite(multiRequest)
|
||||||
|
|
||||||
// explicitly throw for single user invite
|
// explicitly throw for single user invite
|
||||||
|
@ -318,8 +320,8 @@ export const invite = async (ctx: any) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const inviteMultiple = async (ctx: any) => {
|
export const inviteMultiple = async (ctx: Ctx<InviteUsersRequest>) => {
|
||||||
const request = ctx.request.body as InviteUsersRequest
|
const request = ctx.request.body
|
||||||
ctx.body = await userSdk.invite(request)
|
ctx.body = await userSdk.invite(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -424,7 +426,6 @@ export const inviteAccept = async (
|
||||||
if (err.code === ErrorCode.USAGE_LIMIT_EXCEEDED) {
|
if (err.code === ErrorCode.USAGE_LIMIT_EXCEEDED) {
|
||||||
// explicitly re-throw limit exceeded errors
|
// explicitly re-throw limit exceeded errors
|
||||||
ctx.throw(400, err)
|
ctx.throw(400, err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
console.warn("Error inviting user", err)
|
console.warn("Error inviting user", err)
|
||||||
ctx.throw(400, "Unable to create new user, invitation invalid.")
|
ctx.throw(400, "Unable to create new user, invitation invalid.")
|
||||||
|
|
|
@ -13,6 +13,7 @@ describe("/api/global/groups", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
jest.resetAllMocks()
|
||||||
mocks.licenses.useGroups()
|
mocks.licenses.useGroups()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -24,6 +25,63 @@ describe("/api/global/groups", () => {
|
||||||
expect(events.group.updated).not.toBeCalled()
|
expect(events.group.updated).not.toBeCalled()
|
||||||
expect(events.group.permissionsEdited).not.toBeCalled()
|
expect(events.group.permissionsEdited).not.toBeCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should not allow undefined names", async () => {
|
||||||
|
const group = { ...structures.groups.UserGroup(), name: undefined } as any
|
||||||
|
const response = await config.api.groups.saveGroup(group, { expect: 400 })
|
||||||
|
expect(JSON.parse(response.text).message).toEqual(
|
||||||
|
'Invalid body - "name" is required'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not allow empty names", async () => {
|
||||||
|
const group = { ...structures.groups.UserGroup(), name: "" }
|
||||||
|
const response = await config.api.groups.saveGroup(group, { expect: 400 })
|
||||||
|
expect(JSON.parse(response.text).message).toEqual(
|
||||||
|
'Invalid body - "name" is not allowed to be empty'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not allow whitespace names", async () => {
|
||||||
|
const group = { ...structures.groups.UserGroup(), name: " " }
|
||||||
|
const response = await config.api.groups.saveGroup(group, { expect: 400 })
|
||||||
|
expect(JSON.parse(response.text).message).toEqual(
|
||||||
|
'Invalid body - "name" is not allowed to be empty'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should trim names", async () => {
|
||||||
|
const group = { ...structures.groups.UserGroup(), name: " group name " }
|
||||||
|
await config.api.groups.saveGroup(group)
|
||||||
|
expect(events.group.created).toBeCalledWith(
|
||||||
|
expect.objectContaining({ name: "group name" })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("name max length", () => {
|
||||||
|
const maxLength = 50
|
||||||
|
|
||||||
|
it(`should allow names shorter than ${maxLength} characters`, async () => {
|
||||||
|
const group = {
|
||||||
|
...structures.groups.UserGroup(),
|
||||||
|
name: structures.generator.word({ length: maxLength }),
|
||||||
|
}
|
||||||
|
await config.api.groups.saveGroup(group, { expect: 200 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`should not allow names longer than ${maxLength} characters`, async () => {
|
||||||
|
const group = {
|
||||||
|
...structures.groups.UserGroup(),
|
||||||
|
name: structures.generator.word({ length: maxLength + 1 }),
|
||||||
|
}
|
||||||
|
const response = await config.api.groups.saveGroup(group, {
|
||||||
|
expect: 400,
|
||||||
|
})
|
||||||
|
expect(JSON.parse(response.text).message).toEqual(
|
||||||
|
'Invalid body - "name" length must be less than or equal to 50 characters long'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("update", () => {
|
describe("update", () => {
|
||||||
|
|
|
@ -7,13 +7,13 @@ export class GroupsAPI extends TestAPI {
|
||||||
super(config)
|
super(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
saveGroup = (group: UserGroup) => {
|
saveGroup = (group: UserGroup, { expect } = { expect: 200 }) => {
|
||||||
return this.request
|
return this.request
|
||||||
.post(`/api/global/groups`)
|
.post(`/api/global/groups`)
|
||||||
.send(group)
|
.send(group)
|
||||||
.set(this.config.defaultHeaders())
|
.set(this.config.defaultHeaders())
|
||||||
.expect("Content-Type", /json/)
|
.expect("Content-Type", /json/)
|
||||||
.expect(200)
|
.expect(expect)
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteGroup = (id: string, rev: string) => {
|
deleteGroup = (id: string, rev: string) => {
|
||||||
|
|
|
@ -1,10 +1,25 @@
|
||||||
|
import { generator } from "@budibase/backend-core/tests"
|
||||||
|
import { db } from "@budibase/backend-core"
|
||||||
|
import { UserGroupRoles } from "@budibase/types"
|
||||||
|
|
||||||
export const UserGroup = () => {
|
export const UserGroup = () => {
|
||||||
|
const appsCount = generator.integer({ min: 0, max: 3 })
|
||||||
|
const roles = Array.from({ length: appsCount }).reduce(
|
||||||
|
(p: UserGroupRoles, v) => {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
[db.generateAppID()]: generator.pickone(["ADMIN", "POWER", "BASIC"]),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
let group = {
|
let group = {
|
||||||
apps: [],
|
apps: [],
|
||||||
color: "var(--spectrum-global-color-blue-600)",
|
color: generator.color(),
|
||||||
icon: "UserGroup",
|
icon: generator.word(),
|
||||||
name: "New group",
|
name: generator.word({ length: 2 }),
|
||||||
roles: { app_uuid1: "ADMIN", app_uuid2: "POWER" },
|
roles: roles,
|
||||||
users: [],
|
users: [],
|
||||||
}
|
}
|
||||||
return group
|
return group
|
||||||
|
|
|
@ -20,6 +20,12 @@ export default class LicenseAPI {
|
||||||
internal: true,
|
internal: true,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not update license for accountId=${accountId}: ${response.status}`
|
||||||
|
)
|
||||||
|
}
|
||||||
return [response, json]
|
return [response, json]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,9 +11,6 @@ describe("Internal API - App Specific Roles & Permissions", () => {
|
||||||
await config.beforeAll()
|
await config.beforeAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await config.afterAll()
|
|
||||||
})
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await config.afterAll()
|
await config.afterAll()
|
||||||
})
|
})
|
||||||
|
|
131
yarn.lock
131
yarn.lock
|
@ -1386,47 +1386,6 @@
|
||||||
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
|
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
|
||||||
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
|
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
|
||||||
|
|
||||||
"@budibase/backend-core@2.5.10-alpha.3":
|
|
||||||
version "2.5.10-alpha.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/@budibase/backend-core/-/backend-core-2.5.10-alpha.3.tgz#bd4a34a95ebbf1528f4315e2307309f677a06f4b"
|
|
||||||
integrity sha512-UZgZivQpJ02pPCZuY3bRjAK/tkoz6HP3F2AUu922U1RQdwZiIUfYUxyVIZK8Lo4UXhz3EnaThJGwOtWaEelyAQ==
|
|
||||||
dependencies:
|
|
||||||
"@budibase/nano" "10.1.2"
|
|
||||||
"@budibase/pouchdb-replication-stream" "1.2.10"
|
|
||||||
"@budibase/types" "2.5.10-alpha.3"
|
|
||||||
"@shopify/jest-koa-mocks" "5.0.1"
|
|
||||||
"@techpass/passport-openidconnect" "0.3.2"
|
|
||||||
aws-cloudfront-sign "2.2.0"
|
|
||||||
aws-sdk "2.1030.0"
|
|
||||||
bcrypt "5.0.1"
|
|
||||||
bcryptjs "2.4.3"
|
|
||||||
bull "4.10.1"
|
|
||||||
correlation-id "4.0.0"
|
|
||||||
dotenv "16.0.1"
|
|
||||||
emitter-listener "1.1.2"
|
|
||||||
ioredis "4.28.0"
|
|
||||||
joi "17.6.0"
|
|
||||||
jsonwebtoken "9.0.0"
|
|
||||||
koa-passport "4.1.4"
|
|
||||||
koa-pino-logger "4.0.0"
|
|
||||||
lodash "4.17.21"
|
|
||||||
lodash.isarguments "3.1.0"
|
|
||||||
node-fetch "2.6.7"
|
|
||||||
passport-google-oauth "2.0.0"
|
|
||||||
passport-jwt "4.0.0"
|
|
||||||
passport-local "1.0.0"
|
|
||||||
passport-oauth2-refresh "^2.1.0"
|
|
||||||
pino "8.11.0"
|
|
||||||
pino-http "8.3.3"
|
|
||||||
posthog-node "1.3.0"
|
|
||||||
pouchdb "7.3.0"
|
|
||||||
pouchdb-find "7.2.2"
|
|
||||||
redlock "4.2.0"
|
|
||||||
sanitize-s3-objectkey "0.0.1"
|
|
||||||
semver "7.3.7"
|
|
||||||
tar-fs "2.1.1"
|
|
||||||
uuid "8.3.2"
|
|
||||||
|
|
||||||
"@budibase/bbui@^0.9.139":
|
"@budibase/bbui@^0.9.139":
|
||||||
version "0.9.190"
|
version "0.9.190"
|
||||||
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-0.9.190.tgz#e1ec400ac90f556bfbc80fc23a04506f1585ea81"
|
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-0.9.190.tgz#e1ec400ac90f556bfbc80fc23a04506f1585ea81"
|
||||||
|
@ -1527,32 +1486,6 @@
|
||||||
pouchdb-promise "^6.0.4"
|
pouchdb-promise "^6.0.4"
|
||||||
through2 "^2.0.0"
|
through2 "^2.0.0"
|
||||||
|
|
||||||
"@budibase/pro@develop":
|
|
||||||
version "2.5.10-alpha.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.5.10-alpha.3.tgz#6bf06be3e8f87b785d618847adf3ca235097a29b"
|
|
||||||
integrity sha512-w0CCBok8gvill38sPgN3EUztwQTnfZVw/I6pbzOQaRUaH4Hi0To94HarLHQ0Awu1HM8/xnke3V3zukPVhFrMYA==
|
|
||||||
dependencies:
|
|
||||||
"@budibase/backend-core" "2.5.10-alpha.3"
|
|
||||||
"@budibase/shared-core" "2.5.9"
|
|
||||||
"@budibase/string-templates" "2.5.9"
|
|
||||||
"@budibase/types" "2.5.10-alpha.3"
|
|
||||||
"@koa/router" "8.0.8"
|
|
||||||
bull "4.10.1"
|
|
||||||
joi "17.6.0"
|
|
||||||
jsonwebtoken "8.5.1"
|
|
||||||
lru-cache "^7.14.1"
|
|
||||||
memorystream "^0.3.1"
|
|
||||||
node-fetch "^2.6.1"
|
|
||||||
scim-patch "^0.7.0"
|
|
||||||
scim2-parse-filter "^0.2.8"
|
|
||||||
|
|
||||||
"@budibase/shared-core@2.5.9":
|
|
||||||
version "2.5.9"
|
|
||||||
resolved "https://registry.yarnpkg.com/@budibase/shared-core/-/shared-core-2.5.9.tgz#f22f22637fb7618ded1c7292b10793d7969827ce"
|
|
||||||
integrity sha512-l417Rb2+1tuXbjNL42wJHmqIQQyla2pPglOnapxfOdRuvzng+5GqlrTV1caLNf/53TS9U6ueGJdPOtxZTTFGUA==
|
|
||||||
dependencies:
|
|
||||||
"@budibase/types" "^2.5.9"
|
|
||||||
|
|
||||||
"@budibase/standard-components@^0.9.139":
|
"@budibase/standard-components@^0.9.139":
|
||||||
version "0.9.139"
|
version "0.9.139"
|
||||||
resolved "https://registry.yarnpkg.com/@budibase/standard-components/-/standard-components-0.9.139.tgz#cf8e2b759ae863e469e50272b3ca87f2827e66e3"
|
resolved "https://registry.yarnpkg.com/@budibase/standard-components/-/standard-components-0.9.139.tgz#cf8e2b759ae863e469e50272b3ca87f2827e66e3"
|
||||||
|
@ -1571,32 +1504,6 @@
|
||||||
svelte-apexcharts "^1.0.2"
|
svelte-apexcharts "^1.0.2"
|
||||||
svelte-flatpickr "^3.1.0"
|
svelte-flatpickr "^3.1.0"
|
||||||
|
|
||||||
"@budibase/string-templates@2.5.9":
|
|
||||||
version "2.5.9"
|
|
||||||
resolved "https://registry.yarnpkg.com/@budibase/string-templates/-/string-templates-2.5.9.tgz#64582730421801e1e829b430136b449b1adc0314"
|
|
||||||
integrity sha512-Szu06M0JFHuUVIil2aHZWU8hvsROYpDx9raX9uIv4DcwBOAtyvVzD16wOCHzlmj8wWeV8fbKe4JF4q4GXnilJg==
|
|
||||||
dependencies:
|
|
||||||
"@budibase/handlebars-helpers" "^0.11.8"
|
|
||||||
dayjs "^1.10.4"
|
|
||||||
handlebars "^4.7.6"
|
|
||||||
handlebars-utils "^1.0.6"
|
|
||||||
lodash "^4.17.20"
|
|
||||||
vm2 "^3.9.15"
|
|
||||||
|
|
||||||
"@budibase/types@2.5.10-alpha.3":
|
|
||||||
version "2.5.10-alpha.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/@budibase/types/-/types-2.5.10-alpha.3.tgz#9587a30cb5fbd05f7abe779a971a443920d5b0af"
|
|
||||||
integrity sha512-R8mlreITQb3FYsF9g0CLvzadMmg5PJc2+a5oobGhlxfheuyE+2IMNuyEC1yCeSfhh2JgzSnCwcIDiOq5ZYlT3g==
|
|
||||||
dependencies:
|
|
||||||
scim-patch "^0.7.0"
|
|
||||||
|
|
||||||
"@budibase/types@^2.5.9":
|
|
||||||
version "2.6.7"
|
|
||||||
resolved "https://registry.yarnpkg.com/@budibase/types/-/types-2.6.7.tgz#ee41bd22a8304d72412fb62b97fd2a81199bcf67"
|
|
||||||
integrity sha512-JIE1M5mkR8xdmdlmfvIPw4E6A95lL/LA1uXPRHv7+UAtHPQyjKcirkrNr18mgAb2dYWCYP0404+wgxRRGQg0GQ==
|
|
||||||
dependencies:
|
|
||||||
scim-patch "^0.7.0"
|
|
||||||
|
|
||||||
"@bull-board/api@3.7.0":
|
"@bull-board/api@3.7.0":
|
||||||
version "3.7.0"
|
version "3.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/@bull-board/api/-/api-3.7.0.tgz#231f687187c0cb34e0b97f463917b6aaeb4ef6af"
|
resolved "https://registry.yarnpkg.com/@bull-board/api/-/api-3.7.0.tgz#231f687187c0cb34e0b97f463917b6aaeb4ef6af"
|
||||||
|
@ -3057,7 +2964,7 @@
|
||||||
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
|
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
|
||||||
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
|
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
|
||||||
|
|
||||||
"@jridgewell/sourcemap-codec@^1.4.10":
|
"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.13":
|
||||||
version "1.4.15"
|
version "1.4.15"
|
||||||
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
|
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
|
||||||
integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
|
integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
|
||||||
|
@ -3767,7 +3674,7 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
slash "^3.0.0"
|
slash "^3.0.0"
|
||||||
|
|
||||||
"@rollup/plugin-commonjs@^16.0.0":
|
"@rollup/plugin-commonjs@16.0.0", "@rollup/plugin-commonjs@^16.0.0":
|
||||||
version "16.0.0"
|
version "16.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-16.0.0.tgz#169004d56cd0f0a1d0f35915d31a036b0efe281f"
|
resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-16.0.0.tgz#169004d56cd0f0a1d0f35915d31a036b0efe281f"
|
||||||
integrity sha512-LuNyypCP3msCGVQJ7ki8PqYdpjfEkE/xtFa5DqlF+7IBD0JsfMZ87C58heSwIMint58sAUZbt3ITqOmdQv/dXw==
|
integrity sha512-LuNyypCP3msCGVQJ7ki8PqYdpjfEkE/xtFa5DqlF+7IBD0JsfMZ87C58heSwIMint58sAUZbt3ITqOmdQv/dXw==
|
||||||
|
@ -3850,6 +3757,22 @@
|
||||||
"@rollup/pluginutils" "^3.1.0"
|
"@rollup/pluginutils" "^3.1.0"
|
||||||
magic-string "^0.25.7"
|
magic-string "^0.25.7"
|
||||||
|
|
||||||
|
"@rollup/plugin-replace@^5.0.2":
|
||||||
|
version "5.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/plugin-replace/-/plugin-replace-5.0.2.tgz#45f53501b16311feded2485e98419acb8448c61d"
|
||||||
|
integrity sha512-M9YXNekv/C/iHHK+cvORzfRYfPbq0RDD8r0G+bMiTXjNGKulPnCT9O3Ss46WfhI6ZOCgApOP7xAdmCQJ+U2LAA==
|
||||||
|
dependencies:
|
||||||
|
"@rollup/pluginutils" "^5.0.1"
|
||||||
|
magic-string "^0.27.0"
|
||||||
|
|
||||||
|
"@rollup/plugin-typescript@8.3.0":
|
||||||
|
version "8.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/plugin-typescript/-/plugin-typescript-8.3.0.tgz#bc1077fa5897b980fc27e376c4e377882c63e68b"
|
||||||
|
integrity sha512-I5FpSvLbtAdwJ+naznv+B4sjXZUcIvLLceYpITAn7wAP8W0wqc5noLdGIp9HGVntNhRWXctwPYrSSFQxtl0FPA==
|
||||||
|
dependencies:
|
||||||
|
"@rollup/pluginutils" "^3.1.0"
|
||||||
|
resolve "^1.17.0"
|
||||||
|
|
||||||
"@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0":
|
"@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0":
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
|
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
|
||||||
|
@ -11748,7 +11671,7 @@ fs.realpath@^1.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
|
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
|
||||||
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
||||||
|
|
||||||
fsevents@^2.1.2, fsevents@^2.3.2, fsevents@~2.3.2:
|
fsevents@^2.1.2, fsevents@^2.3.2, fsevents@~2.3.1, fsevents@~2.3.2:
|
||||||
version "2.3.2"
|
version "2.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
|
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
|
||||||
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
|
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
|
||||||
|
@ -16738,6 +16661,13 @@ magic-string@^0.26.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
sourcemap-codec "^1.4.8"
|
sourcemap-codec "^1.4.8"
|
||||||
|
|
||||||
|
magic-string@^0.27.0:
|
||||||
|
version "0.27.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.27.0.tgz#e4a3413b4bab6d98d2becffd48b4a257effdbbf3"
|
||||||
|
integrity sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==
|
||||||
|
dependencies:
|
||||||
|
"@jridgewell/sourcemap-codec" "^1.4.13"
|
||||||
|
|
||||||
make-dir@3.1.0, make-dir@^3.0.0, make-dir@^3.1.0:
|
make-dir@3.1.0, make-dir@^3.0.0, make-dir@^3.1.0:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
|
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
|
||||||
|
@ -21509,6 +21439,13 @@ rollup-pluginutils@^2.3.1, rollup-pluginutils@^2.5.0, rollup-pluginutils@^2.6.0,
|
||||||
dependencies:
|
dependencies:
|
||||||
estree-walker "^0.6.1"
|
estree-walker "^0.6.1"
|
||||||
|
|
||||||
|
rollup@2.45.2:
|
||||||
|
version "2.45.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.45.2.tgz#8fb85917c9f35605720e92328f3ccbfba6f78b48"
|
||||||
|
integrity sha512-kRRU7wXzFHUzBIv0GfoFFIN3m9oteY4uAsKllIpQDId5cfnkWF2J130l+27dzDju0E6MScKiV0ZM5Bw8m4blYQ==
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents "~2.3.1"
|
||||||
|
|
||||||
rollup@^2.36.2, rollup@^2.44.0, rollup@^2.45.2, rollup@^2.79.1:
|
rollup@^2.36.2, rollup@^2.44.0, rollup@^2.45.2, rollup@^2.79.1:
|
||||||
version "2.79.1"
|
version "2.79.1"
|
||||||
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7"
|
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7"
|
||||||
|
@ -23485,7 +23422,7 @@ timed-out@^4.0.1:
|
||||||
resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f"
|
resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f"
|
||||||
integrity sha512-G7r3AhovYtr5YKOWQkta8RKAPb+J9IsO4uVmzjl8AZwfhs8UcUwTiD6gcJYSgOtzyjvQKrKYn41syHbUWMkafA==
|
integrity sha512-G7r3AhovYtr5YKOWQkta8RKAPb+J9IsO4uVmzjl8AZwfhs8UcUwTiD6gcJYSgOtzyjvQKrKYn41syHbUWMkafA==
|
||||||
|
|
||||||
timekeeper@2.2.0:
|
timekeeper@2.2.0, timekeeper@^2.2.0:
|
||||||
version "2.2.0"
|
version "2.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/timekeeper/-/timekeeper-2.2.0.tgz#9645731fce9e3280a18614a57a9d1b72af3ca368"
|
resolved "https://registry.yarnpkg.com/timekeeper/-/timekeeper-2.2.0.tgz#9645731fce9e3280a18614a57a9d1b72af3ca368"
|
||||||
integrity sha512-W3AmPTJWZkRwu+iSNxPIsLZ2ByADsOLbbLxe46UJyWj3mlYLlwucKiq+/dPm0l9wTzqoF3/2PH0AGFCebjq23A==
|
integrity sha512-W3AmPTJWZkRwu+iSNxPIsLZ2ByADsOLbbLxe46UJyWj3mlYLlwucKiq+/dPm0l9wTzqoF3/2PH0AGFCebjq23A==
|
||||||
|
|
Loading…
Reference in New Issue